GT 총자산 비율 매수·leg 티어 배분과 시뮬/실거래 포지션 사이징을 통합한다.

타점·비중을 gt_model로 일반화하고, amount_krw 시각순 배분·EV/WF·상위 leg 대형 매수를
position_sizing과 시뮬 HTML(고정 ₩/회 비교)에 반영한다.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-05-31 16:11:49 +09:00
parent 2cb67c42b3
commit 5842cc9fa3
14 changed files with 2073 additions and 182 deletions

View File

@@ -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>

View File

@@ -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']}")

View File

@@ -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