""" 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'
{label}{value}
' 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 ( '
' f'

{title}

' f'
{cards_inner}
' "
" ) 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""" {page_title}

{heading}

{meta_line}

{note_html}
{legend_html}
{cards_html if "summary-cards" in cards_html else f'
{cards_html}
'}
{chart_html}
{sections_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"
  • {_format_condition(c)}
  • " for c in conds) m = rule.get("metrics", {}).get("all", {}) hold = rule.get("metrics", {}).get("holdout", {}) return f"""

    {rid} ({side} · {format_rule_kind(kind)})

    발화 시 3분봉 종가·8TF 지표가 아래를 모두 만족하면 {side} 신호.

    전구간 EV {m.get('ev_pct', '-')}%% · holdout EV {hold.get('ev_pct', '-')}%% · holdout PF {hold.get('profit_factor', '-')}

    """ 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"{c.get('rule_id')}{c.get('side')}" f'{mark}' f'{c.get("holdout_ev")}' f'{c.get("holdout_pf")}' f'{c.get("wf_positive_ratio")}' f'{c.get("fee_stress_ev")}' ) body = "\n".join(rows) if rows else "없음" return f"""

    Go/No-Go (monitor_rules)

    종합 판정: {label}

    {body}
    규칙side판정holdout EV% holdout PFWF 양수월수수료 2x EV%
    """