GT 총자산 비율 매수·leg 티어 배분과 시뮬/실거래 포지션 사이징을 통합한다.
타점·비중을 gt_model로 일반화하고, amount_krw 시각순 배분·EV/WF·상위 leg 대형 매수를 position_sizing과 시뮬 HTML(고정 ₩/회 비교)에 반영한다. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -37,7 +37,13 @@ CHART_REPORT_CSS = """
|
||||
.table-scroll { max-height: 480px; overflow-y: auto; border: 1px solid #e2e8f0; border-radius: 8px; }
|
||||
.pass { color: #16a34a; font-weight: 600; }
|
||||
.fail { color: #dc2626; font-weight: 600; }
|
||||
.cards-group-title { font-size: 0.82rem; color: #475569; margin: 14px 0 6px; font-weight: 600; }
|
||||
.summary-cards { margin: 16px 0; }
|
||||
.summary-cards .cards-row-block { display: block; width: 100%; margin-bottom: 14px; }
|
||||
.summary-cards .cards-row-block:last-child { margin-bottom: 0; }
|
||||
.cards-group-title {
|
||||
font-size: 0.82rem; color: #475569; margin: 0 0 8px; font-weight: 600;
|
||||
display: block;
|
||||
}
|
||||
"""
|
||||
|
||||
|
||||
@@ -116,6 +122,28 @@ def card_html(label: str, value: str) -> str:
|
||||
return f'<div class="card"><span>{label}</span><b>{value}</b></div>'
|
||||
|
||||
|
||||
def stacked_summary_cards_html(
|
||||
title: str,
|
||||
cards_inner: str,
|
||||
) -> str:
|
||||
"""
|
||||
제목 한 줄 + 카드 flex 한 줄을 세로 블록으로 묶습니다.
|
||||
|
||||
Args:
|
||||
title: cards-group-title 텍스트(HTML 허용).
|
||||
cards_inner: .cards 안에 넣을 card div 문자열.
|
||||
|
||||
Returns:
|
||||
cards-row-block HTML.
|
||||
"""
|
||||
return (
|
||||
'<div class="cards-row-block">'
|
||||
f'<p class="cards-group-title">{title}</p>'
|
||||
f'<div class="cards">{cards_inner}</div>'
|
||||
"</div>"
|
||||
)
|
||||
|
||||
|
||||
def wrap_chart_report_page(
|
||||
page_title: str,
|
||||
heading: str,
|
||||
@@ -135,7 +163,7 @@ def wrap_chart_report_page(
|
||||
meta_line: 기간·추세 등.
|
||||
note_html: 안내 박스.
|
||||
legend_html: 차트 범례 설명.
|
||||
cards_html: .cards 내부 HTML.
|
||||
cards_html: .cards 내부 HTML 또는 .summary-cards 블록 전체.
|
||||
chart_html: plotly embed.
|
||||
sections_html: h2·테이블·criteria 등 본문 하단.
|
||||
|
||||
@@ -154,7 +182,7 @@ def wrap_chart_report_page(
|
||||
<p class="meta">{meta_line}</p>
|
||||
{note_html}
|
||||
<div class="legend-box">{legend_html}</div>
|
||||
<div class="cards">{cards_html}</div>
|
||||
{cards_html if "summary-cards" in cards_html else f'<div class="cards">{cards_html}</div>'}
|
||||
<div class="chart-wrap">{chart_html}</div>
|
||||
{sections_html}
|
||||
</body>
|
||||
|
||||
@@ -19,9 +19,18 @@ from config import (
|
||||
LIVE_ORDER_KRW,
|
||||
LIVE_TRADING_ENABLED,
|
||||
SYMBOL,
|
||||
TRADING_FEE_RATE,
|
||||
)
|
||||
from deepcoin.ground_truth.ground_truth import load_ground_truth
|
||||
from deepcoin.matching.live_eval import evaluate_live_rules
|
||||
from deepcoin.matching.load_rules import load_monitor_rules
|
||||
from deepcoin.matching.position_sizing import (
|
||||
compute_buy_amount_krw,
|
||||
live_buy_asset_pct_scale,
|
||||
load_ev_wf_approved_rule_ids,
|
||||
top_leg_ids_by_forward_return,
|
||||
)
|
||||
from deepcoin.paths import resolve_ground_truth_file
|
||||
from deepcoin.ops.alert_message import build_rule_alert_message
|
||||
from deepcoin.ops.monitor import Monitor
|
||||
from deepcoin.paths import LIVE_TRADES_LOG
|
||||
@@ -40,6 +49,17 @@ class LiveTrader(Monitor):
|
||||
self._day_spent_krw: float = 0.0
|
||||
self._day_trades: int = 0
|
||||
self._day_pnl_krw: float = 0.0
|
||||
self._gt_trades: list[dict] = []
|
||||
self._large_legs: set[int] = set()
|
||||
self._approved_rules: set[str] = set()
|
||||
self._load_sizing_context()
|
||||
|
||||
def _load_sizing_context(self) -> None:
|
||||
"""GT leg·EV/WF 통과 규칙 캐시."""
|
||||
gt = load_ground_truth(resolve_ground_truth_file()) or {}
|
||||
self._gt_trades = gt.get("trades") or []
|
||||
self._large_legs = top_leg_ids_by_forward_return(self._gt_trades)
|
||||
self._approved_rules = load_ev_wf_approved_rule_ids()
|
||||
|
||||
def _reset_day_if_needed(self) -> None:
|
||||
"""날짜 변경 시 일별 한도 카운터 초기화."""
|
||||
@@ -61,7 +81,7 @@ class LiveTrader(Monitor):
|
||||
with LIVE_TRADES_LOG.open("a", encoding="utf-8") as f:
|
||||
f.write(json.dumps(record, ensure_ascii=False) + "\n")
|
||||
|
||||
def _can_trade(self, rule_id: str) -> tuple[bool, str]:
|
||||
def _can_trade(self, rule_id: str, planned_krw: float | None = None) -> tuple[bool, str]:
|
||||
"""
|
||||
일·쿨다운·손실 한도 검사.
|
||||
|
||||
@@ -74,7 +94,8 @@ class LiveTrader(Monitor):
|
||||
self._reset_day_if_needed()
|
||||
if self._day_trades >= LIVE_MAX_TRADES_PER_DAY:
|
||||
return False, "일 최대 거래 수 초과"
|
||||
if self._day_spent_krw + LIVE_ORDER_KRW > LIVE_DAILY_KRW_MAX:
|
||||
need = float(planned_krw if planned_krw is not None else LIVE_ORDER_KRW)
|
||||
if self._day_spent_krw + need > LIVE_DAILY_KRW_MAX:
|
||||
return False, "일 주문 한도 초과"
|
||||
if self._day_pnl_krw <= -abs(LIVE_DAILY_LOSS_LIMIT_KRW):
|
||||
return False, "일 손실 한도 초과"
|
||||
@@ -83,6 +104,46 @@ class LiveTrader(Monitor):
|
||||
return False, f"규칙 쿨다운({LIVE_COOLDOWN_MIN}분)"
|
||||
return True, ""
|
||||
|
||||
def _resolve_buy_amount_krw(self, hit: dict[str, Any]) -> float:
|
||||
"""
|
||||
총자산·현금·EV/WF·leg 티어로 매수 원화 산출.
|
||||
|
||||
Args:
|
||||
hit: evaluate_live_rules 항목.
|
||||
|
||||
Returns:
|
||||
매수 원화.
|
||||
"""
|
||||
rid = hit["rule_id"]
|
||||
if rid not in self._approved_rules:
|
||||
return 0.0
|
||||
price = float(hit["close"])
|
||||
cash = 0.0
|
||||
qty = 0.0
|
||||
try:
|
||||
bal = self.load_balances_dict()
|
||||
sym = bal.get(SYMBOL, {})
|
||||
cash = float(sym.get("available_krw") or sym.get("krw") or 0)
|
||||
qty = float(sym.get("balance") or 0)
|
||||
except Exception:
|
||||
return 0.0
|
||||
scale = live_buy_asset_pct_scale(
|
||||
rid,
|
||||
hit["dt"],
|
||||
self._gt_trades,
|
||||
approved_rules=self._approved_rules,
|
||||
large_legs=self._large_legs,
|
||||
)
|
||||
return compute_buy_amount_krw(
|
||||
cash,
|
||||
qty,
|
||||
price,
|
||||
1.0,
|
||||
1.0,
|
||||
asset_pct_scale=scale,
|
||||
fee_rate=TRADING_FEE_RATE,
|
||||
)
|
||||
|
||||
def _execute_order(self, hit: dict[str, Any]) -> dict[str, Any]:
|
||||
"""
|
||||
매수·매도 주문 실행 또는 드라이런.
|
||||
@@ -95,7 +156,22 @@ class LiveTrader(Monitor):
|
||||
"""
|
||||
side = hit["side"]
|
||||
price = float(hit["close"])
|
||||
amount_krw = float(LIVE_ORDER_KRW)
|
||||
if side == "buy":
|
||||
amount_krw = self._resolve_buy_amount_krw(hit)
|
||||
if amount_krw <= 0:
|
||||
return {
|
||||
"ts": datetime.now().isoformat(timespec="seconds"),
|
||||
"rule_id": hit["rule_id"],
|
||||
"side": side,
|
||||
"signal_dt": hit["dt"],
|
||||
"price": price,
|
||||
"amount_krw": 0,
|
||||
"live_enabled": LIVE_TRADING_ENABLED,
|
||||
"ok": False,
|
||||
"message": "매수 스킵(EV/WF·leg·현금)",
|
||||
}
|
||||
else:
|
||||
amount_krw = float(LIVE_ORDER_KRW)
|
||||
record: dict[str, Any] = {
|
||||
"ts": datetime.now().isoformat(timespec="seconds"),
|
||||
"rule_id": hit["rule_id"],
|
||||
@@ -163,11 +239,23 @@ class LiveTrader(Monitor):
|
||||
|
||||
for hit in fired:
|
||||
rid = hit["rule_id"]
|
||||
ok, reason = self._can_trade(rid)
|
||||
if hit["side"] == "buy" and hit["rule_id"] not in self._approved_rules:
|
||||
print(f" [{hit['side']}] {rid} @ {hit['dt']}")
|
||||
print(" skip: EV/WF 미통과 규칙")
|
||||
continue
|
||||
planned = (
|
||||
self._resolve_buy_amount_krw(hit)
|
||||
if hit["side"] == "buy"
|
||||
else float(LIVE_ORDER_KRW)
|
||||
)
|
||||
ok, reason = self._can_trade(rid, planned)
|
||||
print(f" [{hit['side']}] {rid} @ {hit['dt']}")
|
||||
if not ok:
|
||||
print(f" skip: {reason}")
|
||||
continue
|
||||
if hit["side"] == "buy" and planned <= 0:
|
||||
print(" skip: 매수금액 0")
|
||||
continue
|
||||
log = self._execute_order(hit)
|
||||
self._append_log(log)
|
||||
print(f" order: {log['message']} ok={log['ok']}")
|
||||
|
||||
@@ -28,6 +28,8 @@ from config import (
|
||||
GT_INITIAL_CASH_KRW,
|
||||
GT_MARKER_SIZE_MAX,
|
||||
GT_MARKER_SIZE_MIN,
|
||||
GT_MAX_BUY_ORDER_KRW,
|
||||
LIVE_ORDER_KRW,
|
||||
MACD_FAST,
|
||||
MACD_SIGNAL,
|
||||
MACD_SLOW,
|
||||
@@ -58,16 +60,94 @@ def interval_chart_label(interval_min: int) -> str:
|
||||
return f"{interval_min}분봉"
|
||||
|
||||
|
||||
def _marker_sizes(trades: list[dict], action: str) -> list[float]:
|
||||
"""비중(weight, 0~1)에 비례한 삼각형 크기."""
|
||||
pts = [t for t in trades if t.get("action") == action]
|
||||
def _marker_hover_text(
|
||||
label: str,
|
||||
t: dict,
|
||||
*,
|
||||
default_order_krw: float | None = None,
|
||||
extra_lines: list[str] | None = None,
|
||||
) -> str:
|
||||
"""
|
||||
차트 마커 툴팁: 체결가(price)와 체결 원화(amount_krw)를 함께 표시.
|
||||
|
||||
Args:
|
||||
label: 정답/시뮬 매수·매도 라벨.
|
||||
t: trade dict (price, amount_krw, weight, memo …).
|
||||
default_order_krw: amount_krw 없을 때 표시할 기본 원화(시뮬 고정 주문).
|
||||
extra_lines: 툴팁 하단 추가 줄.
|
||||
|
||||
Returns:
|
||||
hovertext HTML 줄바꿈 문자열.
|
||||
"""
|
||||
action = t.get("action", t.get("side", ""))
|
||||
amt_label = "매수금액" if action == "buy" else "매도금액"
|
||||
lines = [
|
||||
label,
|
||||
str(t.get("dt", ""))[:16],
|
||||
f"체결가 ₩{float(t['price']):,.0f}",
|
||||
]
|
||||
ak = t.get("amount_krw")
|
||||
if ak is not None and float(ak) > 0:
|
||||
lines.append(f"{amt_label} ₩{float(ak):,.0f}")
|
||||
elif default_order_krw is not None and action == "buy":
|
||||
lines.append(f"{amt_label} ₩{float(default_order_krw):,.0f}")
|
||||
else:
|
||||
lines.append(f"{amt_label} (미배분)")
|
||||
if t.get("weight") is not None:
|
||||
lines.append(f"비중 {float(t.get('weight', 1)) * 100:.0f}%")
|
||||
if extra_lines:
|
||||
lines.extend(extra_lines)
|
||||
memo = t.get("memo", "")
|
||||
if memo:
|
||||
lines.append(str(memo))
|
||||
rule_id = t.get("rule_id", "")
|
||||
if rule_id:
|
||||
lines.append(str(rule_id))
|
||||
return "<br>".join(lines)
|
||||
|
||||
|
||||
def _trade_amount_krw(t: dict) -> float:
|
||||
"""
|
||||
마커 크기·툴팁용 체결 원화. amount_krw 없으면 비중×상한으로 추정.
|
||||
|
||||
Args:
|
||||
t: trade dict.
|
||||
|
||||
Returns:
|
||||
원화 금액(0 이상).
|
||||
"""
|
||||
ak = t.get("amount_krw")
|
||||
if ak is not None and float(ak) > 0:
|
||||
return float(ak)
|
||||
return max(float(t.get("weight", 1.0)), 0.05) * float(GT_MAX_BUY_ORDER_KRW)
|
||||
|
||||
|
||||
def _marker_sizes(pts: list[dict]) -> list[float]:
|
||||
"""
|
||||
체결 원화(amount_krw)에 비례한 삼각형 크기.
|
||||
|
||||
같은 trace(매수 또는 매도) 안에서 최소·최대 금액으로 선형 스케일.
|
||||
|
||||
Args:
|
||||
pts: 동일 action의 trade dict 리스트.
|
||||
|
||||
Returns:
|
||||
plotly marker size(diameter) 리스트.
|
||||
"""
|
||||
if not pts:
|
||||
return []
|
||||
lo, hi = float(GT_MARKER_SIZE_MIN), float(GT_MARKER_SIZE_MAX)
|
||||
return [
|
||||
lo + (hi - lo) * min(max(float(t.get("weight", 1.0)), 0.05), 1.0)
|
||||
for t in pts
|
||||
]
|
||||
amounts = [_trade_amount_krw(t) for t in pts]
|
||||
amin, amax = min(amounts), max(amounts)
|
||||
sizes: list[float] = []
|
||||
for amount in amounts:
|
||||
if amax > amin:
|
||||
ratio = (amount - amin) / (amax - amin)
|
||||
else:
|
||||
ratio = 0.5
|
||||
ratio = max(ratio, 0.08)
|
||||
sizes.append(lo + (hi - lo) * ratio)
|
||||
return sizes
|
||||
|
||||
|
||||
def _add_sim_markers(fig, trades: list[dict], row: int = 1) -> None:
|
||||
@@ -101,9 +181,14 @@ def _add_sim_markers(fig, trades: list[dict], row: int = 1) -> None:
|
||||
opacity=0.75,
|
||||
),
|
||||
hovertext=[
|
||||
f"{label}<br>{t['dt'][:16]}<br>₩{float(t['price']):,.0f}"
|
||||
f"<br>leg_gt {float(t.get('forward_ret_pct', 0)):+.2f}%"
|
||||
f"<br>{t.get('rule_id', '')}"
|
||||
_marker_hover_text(
|
||||
label,
|
||||
t,
|
||||
default_order_krw=LIVE_ORDER_KRW,
|
||||
extra_lines=[
|
||||
f"leg_gt {float(t.get('forward_ret_pct', 0)):+.2f}%",
|
||||
],
|
||||
)
|
||||
for t in pts
|
||||
],
|
||||
hovertemplate="%{hovertext}<extra></extra>",
|
||||
@@ -114,7 +199,7 @@ def _add_sim_markers(fig, trades: list[dict], row: int = 1) -> None:
|
||||
|
||||
|
||||
def _add_truth_markers(fig, trades: list[dict], row: int = 1) -> None:
|
||||
"""정답 매수·매도 마커 (삼각형 크기 = 비중)."""
|
||||
"""정답 매수·매도 마커 (삼각형 크기 = 체결 원화 금액)."""
|
||||
for action, color, symbol, label in [
|
||||
("buy", "#16a34a", "triangle-up", "정답 매수"),
|
||||
("sell", "#dc2626", "triangle-down", "정답 매도"),
|
||||
@@ -122,7 +207,7 @@ def _add_truth_markers(fig, trades: list[dict], row: int = 1) -> None:
|
||||
pts = [t for t in trades if t.get("action") == action]
|
||||
if not pts:
|
||||
continue
|
||||
sizes = _marker_sizes(trades, action)
|
||||
sizes = _marker_sizes(pts)
|
||||
fig.add_trace(
|
||||
go.Scatter(
|
||||
x=[pd.Timestamp(t["dt"]) for t in pts],
|
||||
@@ -138,9 +223,7 @@ def _add_truth_markers(fig, trades: list[dict], row: int = 1) -> None:
|
||||
line=dict(width=1.5, color="#111"),
|
||||
),
|
||||
hovertext=[
|
||||
f"{label}<br>{t['dt'][:16]}<br>₩{t['price']:,.0f}"
|
||||
f"<br>비중 {float(t.get('weight', 1))*100:.0f}%"
|
||||
f"<br>{t.get('memo', '')}"
|
||||
_marker_hover_text(label, t)
|
||||
for t in pts
|
||||
],
|
||||
hovertemplate="%{hovertext}<extra></extra>",
|
||||
@@ -446,7 +529,7 @@ def build_chart_html(
|
||||
)
|
||||
trade_table = f"""
|
||||
<h2>정답 타점 (ground_truth)</h2>
|
||||
<p class="meta">삼각형 크기 = 비중. 매수: 저점 분할 / 매도: 고점 1~2회.
|
||||
<p class="meta">삼각형 크기 = 체결 금액(매수/매도 각각 min~max). 매수: 저점 분할 / 매도: 고점 1~2회.
|
||||
총평가 = 체결 직후 현금 + 보유×체결가.{mark_note}</p>
|
||||
<table>
|
||||
<thead><tr><th>시각</th><th>구분</th><th>비중</th><th>가격</th><th>총 평가금액</th><th>해석</th></tr></thead>
|
||||
@@ -471,8 +554,11 @@ def build_chart_html(
|
||||
)
|
||||
|
||||
default_legend = (
|
||||
"▲ <b>정답 매수</b> · ▼ <b>정답 매도</b> — 삼각형 크기 = 비중.<br>"
|
||||
"● <b>시뮬 매수</b> · ● <b>시뮬 매도</b> — 원 = monitor_rules holdout 발화."
|
||||
"▲ <b>정답 매수</b> · ▼ <b>정답 매도</b> — 크기=체결금액. "
|
||||
"매수=총자산×비중×leg티어(상위 대형). "
|
||||
"툴팁: 체결가·매수/매도금액.<br>"
|
||||
"● <b>시뮬 매수</b> · ● <b>시뮬 매도</b> — 원 = holdout 발화 "
|
||||
f"(매수 ₩{LIVE_ORDER_KRW:,.0f}/회)."
|
||||
)
|
||||
if cards_html:
|
||||
cards_inner = cards_html
|
||||
|
||||
Reference in New Issue
Block a user