타점·비중을 gt_model로 일반화하고, amount_krw 시각순 배분·EV/WF·상위 leg 대형 매수를 position_sizing과 시뮬 HTML(고정 ₩/회 비교)에 반영한다. Co-authored-by: Cursor <cursoragent@cursor.com>
309 lines
10 KiB
Python
309 lines
10 KiB
Python
"""
|
|
ground_truth·시뮬 등 차트 HTML 공통 레이아웃·스타일.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
from typing import Any
|
|
|
|
CHART_REPORT_CSS = """
|
|
body { font-family: "Malgun Gothic", Arial, sans-serif; margin: 24px; background: #f8fafc; }
|
|
h1 { font-size: 1.35rem; }
|
|
h2 { font-size: 1.1rem; margin-top: 28px; }
|
|
.meta { color: #475569; font-size: 0.9rem; }
|
|
.note { background: #f1f5f9; border: 1px solid #cbd5e1; padding: 10px; border-radius: 6px;
|
|
color: #334155; font-size: 0.9rem; line-height: 1.5; }
|
|
.go { font-size: 1.25rem; font-weight: 700; }
|
|
.go-pass { color: #16a34a; }
|
|
.go-fail { color: #dc2626; }
|
|
.cards { display: flex; flex-wrap: wrap; gap: 10px; margin: 16px 0; }
|
|
.card { background: #fff; border: 1px solid #e2e8f0; border-radius: 8px; padding: 10px 14px; }
|
|
.card span { font-size: 0.75rem; color: #64748b; display: block; }
|
|
.card b { font-size: 1.05rem; }
|
|
.chart-wrap { background:#fff; border:1px solid #e2e8f0; border-radius:8px; padding:8px; }
|
|
.legend-box { font-size:0.85rem; color:#475569; margin-bottom:10px; line-height: 1.6; }
|
|
table { width:100%; border-collapse:collapse; background:#fff; font-size:0.85rem; }
|
|
th, td { border:1px solid #e2e8f0; padding:8px; text-align:left; }
|
|
th { background:#f1f5f9; }
|
|
td.buy { color:#16a34a; font-weight:600; }
|
|
td.sell { color:#dc2626; font-weight:600; }
|
|
td.num { text-align: right; font-variant-numeric: tabular-nums; }
|
|
.criteria { background:#fff; border:1px solid #e2e8f0; border-radius:8px;
|
|
padding:12px 16px; margin:12px 0; }
|
|
.criteria h3 { margin: 0 0 8px; font-size: 1rem; }
|
|
.criteria ul { margin: 6px 0 0 18px; padding: 0; }
|
|
.criteria li { margin: 4px 0; color: #334155; }
|
|
.criteria .kind { color: #64748b; font-size: 0.85rem; }
|
|
.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; }
|
|
.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;
|
|
}
|
|
"""
|
|
|
|
|
|
def initial_change_pct(pnl: dict[str, Any]) -> float:
|
|
"""
|
|
초기 금액 대비 총보유자산 증감율(%)을 계산합니다.
|
|
|
|
Args:
|
|
pnl: initial_cash_krw, final_asset_krw (또는 pnl_pct) 포함 dict.
|
|
|
|
Returns:
|
|
증감율 %.
|
|
"""
|
|
if pnl.get("pnl_pct") is not None:
|
|
return float(pnl["pnl_pct"])
|
|
initial = float(pnl.get("initial_cash_krw") or 0)
|
|
final = float(pnl.get("final_asset_krw") or 0)
|
|
if initial <= 0:
|
|
return 0.0
|
|
return (final - initial) / initial * 100.0
|
|
|
|
|
|
def pnl_cards_html(pnl: dict[str, Any], trade_label: str, trade_count: int) -> str:
|
|
"""
|
|
GT·시뮬 HTML 공통 자산 요약 카드 (총보유자산·초기 대비 증감율).
|
|
|
|
Args:
|
|
pnl: simulate_truth_portfolio 또는 simulate_fixed_order_portfolio 결과.
|
|
trade_label: 타점 라벨(예: 정답 타점, 시뮬 체결).
|
|
trade_count: 타점 건수.
|
|
|
|
Returns:
|
|
card div HTML 연속 문자열.
|
|
"""
|
|
if pnl.get("initial_cash_krw") is None:
|
|
return card_html(trade_label, f"{trade_count}건")
|
|
change_pct = initial_change_pct(pnl)
|
|
out = card_html(trade_label, f"{trade_count}건")
|
|
out += card_html("초기 금액", f"₩{pnl['initial_cash_krw']:,.0f}")
|
|
out += card_html("총보유자산", f"₩{pnl['final_asset_krw']:,.0f}")
|
|
out += card_html("초기 대비 증감율", f"{change_pct:+.2f}%")
|
|
out += card_html("수수료", f"₩{pnl['total_fees_krw']:,.0f}")
|
|
if pnl.get("holding_qty", 0) > 0:
|
|
out += card_html(
|
|
"미청산",
|
|
f"{pnl['holding_qty']}개 (₩{pnl['holding_value_krw']:,.0f})",
|
|
)
|
|
return out
|
|
|
|
|
|
def market_cards_html(close_last: float, bb_pos_txt: str) -> str:
|
|
"""
|
|
종가·BB %B 카드.
|
|
|
|
Args:
|
|
close_last: 종가.
|
|
bb_pos_txt: BB %B 표시 문자열.
|
|
|
|
Returns:
|
|
card HTML.
|
|
"""
|
|
return card_html("종가", f"₩{close_last:,.2f}") + card_html("BB %B", bb_pos_txt)
|
|
|
|
|
|
def card_html(label: str, value: str) -> str:
|
|
"""
|
|
요약 카드 HTML 한 칸.
|
|
|
|
Args:
|
|
label: 라벨.
|
|
value: 값(HTML 허용).
|
|
|
|
Returns:
|
|
div.card 문자열.
|
|
"""
|
|
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,
|
|
meta_line: str,
|
|
note_html: str,
|
|
legend_html: str,
|
|
cards_html: str,
|
|
chart_html: str,
|
|
sections_html: str,
|
|
) -> str:
|
|
"""
|
|
Plotly 차트·테이블을 ground_truth와 동일 스타일 페이지로 감쌉니다.
|
|
|
|
Args:
|
|
page_title: document title.
|
|
heading: h1.
|
|
meta_line: 기간·추세 등.
|
|
note_html: 안내 박스.
|
|
legend_html: 차트 범례 설명.
|
|
cards_html: .cards 내부 HTML 또는 .summary-cards 블록 전체.
|
|
chart_html: plotly embed.
|
|
sections_html: h2·테이블·criteria 등 본문 하단.
|
|
|
|
Returns:
|
|
전체 HTML 문서.
|
|
"""
|
|
return f"""<!DOCTYPE html>
|
|
<html lang="ko">
|
|
<head>
|
|
<meta charset="utf-8"/>
|
|
<title>{page_title}</title>
|
|
<style>{CHART_REPORT_CSS}</style>
|
|
</head>
|
|
<body>
|
|
<h1>{heading}</h1>
|
|
<p class="meta">{meta_line}</p>
|
|
{note_html}
|
|
<div class="legend-box">{legend_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>
|
|
</html>"""
|
|
|
|
|
|
_COL_LABELS: dict[str, str] = {
|
|
"m3_bb_pos": "3분 BB %B",
|
|
"m15_bb_pos": "15분 BB %B",
|
|
"m30_bb_pos": "30분 BB %B",
|
|
"m3_RSI": "3분 RSI",
|
|
"m15_RSI": "15분 RSI",
|
|
"m30_RSI": "30분 RSI",
|
|
"m3_stoch_k": "3분 Stoch %K",
|
|
"m15_stoch_k": "15분 Stoch %K",
|
|
}
|
|
|
|
|
|
def _col_label(col: str) -> str:
|
|
"""지표 컬럼 한글 라벨."""
|
|
return _COL_LABELS.get(col, col)
|
|
|
|
|
|
def _format_condition(cond: dict[str, Any]) -> str:
|
|
"""
|
|
규칙 조건 dict를 읽기 쉬운 문자열로 변환.
|
|
|
|
Args:
|
|
cond: {col, op, lo, hi, value}.
|
|
|
|
Returns:
|
|
한 줄 설명.
|
|
"""
|
|
col = _col_label(str(cond.get("col", "")))
|
|
op = cond.get("op", "")
|
|
if op == "between":
|
|
return f"{col} ∈ [{cond.get('lo'):.4g}, {cond.get('hi'):.4g}]"
|
|
if op == "lte":
|
|
return f"{col} ≤ {cond.get('value'):.4g}"
|
|
if op == "gte":
|
|
return f"{col} ≥ {cond.get('value'):.4g}"
|
|
if op == "lt":
|
|
return f"{col} < {cond.get('value'):.4g}"
|
|
if op == "gt":
|
|
return f"{col} > {cond.get('value'):.4g}"
|
|
return f"{col} {op} {cond}"
|
|
|
|
|
|
_KIND_LABELS: dict[str, str] = {
|
|
"contrast": "대조 — GT 매수·매도 프로필 대비 반대 구간 (m3 중심)",
|
|
"compound_tight": "복합 타이트 — GT 프로필 상위 3지표 AND 동시 충족",
|
|
"compound": "복합 TOP3 — GT 프로필 상위 3지표 AND",
|
|
"atomic": "단일 — GT 프로필 1지표",
|
|
"wide": "완화 — 프로필 외곽 구간",
|
|
}
|
|
|
|
|
|
def format_rule_kind(kind: str) -> str:
|
|
"""규칙 kind 한글 설명."""
|
|
return _KIND_LABELS.get(kind, kind)
|
|
|
|
|
|
def rule_criteria_html(rule: dict[str, Any]) -> str:
|
|
"""
|
|
monitor_rule 1개의 매칭 기준 블록 HTML.
|
|
|
|
Args:
|
|
rule: matched_rules 내 rule dict.
|
|
|
|
Returns:
|
|
.criteria 블록 HTML.
|
|
"""
|
|
rid = rule.get("rule_id", "")
|
|
side = "매수" if rule.get("side") == "buy" else "매도"
|
|
kind = rule.get("kind", "")
|
|
conds = rule.get("conditions") or []
|
|
items = "".join(f"<li>{_format_condition(c)}</li>" for c in conds)
|
|
m = rule.get("metrics", {}).get("all", {})
|
|
hold = rule.get("metrics", {}).get("holdout", {})
|
|
return f"""
|
|
<div class="criteria">
|
|
<h3>{rid} <span class="kind">({side} · {format_rule_kind(kind)})</span></h3>
|
|
<p class="meta">발화 시 3분봉 종가·8TF 지표가 아래를 <b>모두</b> 만족하면 {side} 신호.</p>
|
|
<ul>{items or '<li>조건 없음</li>'}</ul>
|
|
<p class="meta">전구간 EV {m.get('ev_pct', '-')}%% · holdout EV {hold.get('ev_pct', '-')}%% ·
|
|
holdout PF {hold.get('profit_factor', '-')}</p>
|
|
</div>"""
|
|
|
|
|
|
def go_no_go_table_html(checks: list[dict[str, Any]], go: bool) -> str:
|
|
"""
|
|
Go/No-Go 검증 테이블 HTML.
|
|
|
|
Args:
|
|
checks: go_no_go.checks.
|
|
go: 종합 판정.
|
|
|
|
Returns:
|
|
section HTML.
|
|
"""
|
|
flag = "go-pass" if go else "go-fail"
|
|
label = "GO" if go else "NO-GO"
|
|
rows = []
|
|
for c in checks:
|
|
mark = "PASS" if c.get("pass") else "FAIL"
|
|
cls = "pass" if c.get("pass") else "fail"
|
|
rows.append(
|
|
f"<tr><td>{c.get('rule_id')}</td><td>{c.get('side')}</td>"
|
|
f'<td class="{cls}">{mark}</td>'
|
|
f'<td class="num">{c.get("holdout_ev")}</td>'
|
|
f'<td class="num">{c.get("holdout_pf")}</td>'
|
|
f'<td class="num">{c.get("wf_positive_ratio")}</td>'
|
|
f'<td class="num">{c.get("fee_stress_ev")}</td></tr>'
|
|
)
|
|
body = "\n".join(rows) if rows else "<tr><td colspan='7'>없음</td></tr>"
|
|
return f"""
|
|
<h2>Go/No-Go (monitor_rules)</h2>
|
|
<p class="go {flag}">종합 판정: {label}</p>
|
|
<table>
|
|
<thead><tr><th>규칙</th><th>side</th><th>판정</th><th>holdout EV%</th>
|
|
<th>holdout PF</th><th>WF 양수월</th><th>수수료 2x EV%</th></tr></thead>
|
|
<tbody>{body}</tbody>
|
|
</table>"""
|