Files
Bithumb/deepcoin/matching/simulation_html.py
dsyoon 2cb67c42b3 GT MTF 프로필·캘리브레이션과 04 매칭/시뮬/실거래 파이프라인을 추가한다.
3분~일봉 GT 타점 분석(03c), leg 체결 순서 수정, 총자산 90% 검증 루프,
walk-forward Go/No-Go 시뮬, monitor·live_trader 및 reference 문서를 포함한다.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-31 11:27:50 +09:00

461 lines
15 KiB
Python

"""
1단계 시뮬레이션 HTML — ground_truth 차트와 동일 스타일·타점·수익률·규칙 기준.
"""
from __future__ import annotations
import json
from pathlib import Path
from typing import Any
import pandas as pd
from config import (
CHART_LOOKBACK_DAYS,
COIN_NAME,
GT_INITIAL_CASH_KRW,
LIVE_ORDER_KRW,
SYMBOL,
TRADING_FEE_RATE,
)
from deepcoin.ground_truth.ground_truth import (
load_ground_truth,
simulate_truth_portfolio,
simulate_truth_portfolio_steps,
)
from deepcoin.matching.portfolio_sim import (
fires_to_trade_list,
select_capped_fires,
simulate_fixed_order_portfolio,
simulate_fixed_order_portfolio_steps,
)
from deepcoin.matching.select_rules import _split_train_valid_holdout
from deepcoin.ops.chart_report import (
card_html,
go_no_go_table_html,
market_cards_html,
pnl_cards_html,
rule_criteria_html,
wrap_chart_report_page,
)
from deepcoin.ops.simulation import build_chart_html, load_chart_frames, _frames_to_mtf
from deepcoin.common.indicators import apply_bar_indicators, get_trend
from deepcoin.paths import (
MATCHING_FIRE_OUTCOMES,
MATCHING_MATCHED_RULES,
MATCHING_SIMULATION_HTML,
resolve_ground_truth_file,
)
def _fires_to_chart_trades(fires: pd.DataFrame) -> list[dict[str, Any]]:
"""
fire_outcomes 행을 차트 마커용 dict 리스트로 변환.
Args:
fires: monitor holdout 발화.
Returns:
build_chart_html sim_trades 인자.
"""
rows: list[dict[str, Any]] = []
for _, r in fires.iterrows():
rows.append(
{
"dt": str(r["dt"]),
"action": r["side"],
"price": float(r["close"]),
"forward_ret_pct": float(r["forward_ret_pct"]),
"rule_id": r["rule_id"],
}
)
return rows
def _sim_fire_table_rows(
fires: pd.DataFrame,
rules_by_id: dict[str, dict],
steps: list[dict[str, Any]],
) -> str:
"""
시뮬 발화 테이블 tbody (GT와 동일하게 총 평가금액 포함).
Args:
fires: holdout 체결 발화.
rules_by_id: rule_id → rule dict.
steps: simulate_fixed_order_portfolio_steps 결과.
Returns:
tr HTML.
"""
if fires.empty:
return "<tr><td colspan='8'>발화 없음</td></tr>"
sorted_fires = fires.sort_values("dt").reset_index(drop=True)
lines: list[str] = []
lines.append(
f"""
<tr class="initial-row">
<td>시작</td><td>-</td><td>-</td><td>-</td><td>-</td>
<td><b>₩{GT_INITIAL_CASH_KRW:,.0f}</b></td>
<td>-</td><td>초기 현금 (1회 ₩{LIVE_ORDER_KRW:,.0f} 가정)</td>
</tr>"""
)
for i in range(len(sorted_fires)):
r = sorted_fires.iloc[i]
side = r["side"]
cls = "buy" if side == "buy" else "sell"
mark = "매수" if side == "buy" else "매도"
ret = float(r["forward_ret_pct"])
ret_s = f" (+{ret:.2f}%)" if ret > 0 else f" ({ret:.2f}%)"
win = "" if int(r.get("win", 0)) else ""
win_cls = "pass" if int(r.get("win", 0)) else "fail"
kind = rules_by_id.get(r["rule_id"], {}).get("kind", "")
step = steps[i] if i < len(steps) else None
if step:
total_s = f"{step['total_asset_krw']:,.0f}"
hold_s = (
f" (현금 ₩{step['cash_krw']:,.0f} + 코인 {step['holding_qty']:,.2f}개)"
)
else:
total_s = "-"
hold_s = ""
lines.append(
f"<tr>"
f"<td>{str(r['dt'])[:16]}</td>"
f'<td class="{cls}">{mark}</td>'
f"<td>{r['rule_id']}</td>"
f"<td>{kind}</td>"
f'<td class="num">₩{float(r["close"]):,.0f}{ret_s}</td>'
f"<td><b>{total_s}</b>{hold_s}</td>"
f'<td class="{win_cls}">{win}</td>'
f"<td>leg_gt 구간 수익</td>"
f"</tr>"
)
return "\n".join(lines)
def _gt_table_rows(trades: list[dict[str, Any]], steps: list[dict[str, Any]]) -> str:
"""
GT 타점 테이블 tbody (ground_truth 차트와 동일).
Args:
trades: ground_truth trades.
steps: simulate_truth_portfolio_steps.
Returns:
tr HTML.
"""
if not trades:
return "<tr><td colspan='6'>타점 없음</td></tr>"
step_key = {
(s["dt"], s["action"], float(s["price"]), float(s["weight"])): s
for s in steps
}
lines: list[str] = []
lines.append(
f"""
<tr class="initial-row">
<td>시작</td><td>-</td><td>-</td><td>-</td>
<td><b>₩{GT_INITIAL_CASH_KRW:,.0f}</b></td>
<td>초기 현금 (보유 0)</td>
</tr>"""
)
for t in sorted(trades, key=lambda x: x["dt"]):
cls = "buy" if t["action"] == "buy" else "sell"
mark = "매수" if t["action"] == "buy" else "매도"
ret = t.get("forward_return_pct")
ret_s = f" (+{ret}%)" if ret is not None else ""
w = float(t.get("weight", 1.0))
key = (t["dt"], t["action"], float(t["price"]), w)
step = step_key.get(key)
if step:
total_s = f"{step['total_asset_krw']:,.0f}"
hold_s = (
f" (현금 ₩{step['cash_krw']:,.0f} + 코인 {step['holding_qty']:,.2f}개)"
)
else:
total_s = "-"
hold_s = ""
lines.append(
f"<tr>"
f"<td>{t['dt'][:16]}</td>"
f'<td class="{cls}">{mark}</td>'
f"<td>{w*100:.0f}%</td>"
f"<td>₩{t['price']:,.0f}{ret_s}</td>"
f"<td><b>{total_s}</b>{hold_s}</td>"
f"<td>{t.get('memo', '')}</td>"
f"</tr>"
)
return "\n".join(lines)
def _summary_cards_html(
close_last: float,
bb_txt: str,
gt_trades: list[dict[str, Any]],
gt_pnl: dict[str, Any],
sim_pnl: dict[str, Any],
sim_trade_count: int,
go_flag: bool,
) -> str:
"""
ground_truth HTML과 동일 구성의 상단 카드 (GT + 시뮬 2줄).
Args:
close_last: 종가.
bb_txt: BB %B.
gt_trades: GT trades.
gt_pnl: GT 포트폴리오 요약.
sim_pnl: 시뮬 포트폴리오 요약.
sim_trade_count: 체결 가정 발화 수.
go_flag: Go/No-Go.
Returns:
cards HTML.
"""
go_cls = "go-pass" if go_flag else "go-fail"
gt_row = (
'<p class="cards-group-title">정답 (ground_truth) — 분할 비중·leg 체결</p>'
+ market_cards_html(close_last, bb_txt)
+ pnl_cards_html(gt_pnl, "정답 타점", len(gt_trades))
)
sim_row = (
'<p class="cards-group-title">시뮬 (monitor_rules · holdout · '
f"1회 ₩{LIVE_ORDER_KRW:,.0f}·일한도) — "
f'<span class="{go_cls}">{"GO" if go_flag else "NO-GO"}</span></p>'
+ pnl_cards_html(sim_pnl, "시뮬 체결", sim_trade_count)
)
return gt_row + sim_row
def build_simulation_page_html(
report: dict[str, Any],
outcomes_path: Path | None = None,
matched_path: Path | None = None,
gt_path: Path | None = None,
close_last: float | None = None,
) -> str:
"""
시뮬 리포트 전체 HTML (차트 + Go/No-Go + 규칙 기준 + 타점 테이블).
Args:
report: build_simulation_report 결과.
outcomes_path: fire_outcomes.csv.
matched_path: matched_rules.json.
gt_path: ground_truth JSON.
close_last: 미청산 평가 종가 (None이면 DB 종가).
Returns:
HTML 문자열.
"""
op = outcomes_path or MATCHING_FIRE_OUTCOMES
mp = matched_path or MATCHING_MATCHED_RULES
matched: dict[str, Any] = {}
if mp.is_file():
matched = json.loads(mp.read_text(encoding="utf-8"))
monitor_rules = matched.get("monitor_rules") or report.get("monitor_rules") or []
monitor_ids = {r["rule_id"] for r in monitor_rules}
rules_by_id = {r["rule_id"]: r for r in monitor_rules}
holdout_fires = pd.DataFrame()
if op.is_file():
outcomes = pd.read_csv(op)
outcomes["split"] = _split_train_valid_holdout(outcomes)
holdout_fires = outcomes[
(outcomes["rule_id"].isin(monitor_ids)) & (outcomes["split"] == "holdout")
].copy()
capped = select_capped_fires(holdout_fires)
gt_data = load_ground_truth(gt_path or resolve_ground_truth_file()) or {}
gt_trades = gt_data.get("trades") or []
gt_summary = gt_data.get("summary") or {}
go = report.get("go_no_go", {})
go_flag = bool(go.get("go"))
label_mode = report.get("label_mode", "leg_gt")
frames = load_chart_frames()
bb_txt = "-"
trend = "-"
close_val = float(close_last or 0)
if frames is not None:
df_1d, df_1h, df_3m = _frames_to_mtf(frames)
trend = get_trend(df_1d, df_1h)
df_chart = apply_bar_indicators(df_3m)
close_val = float(df_chart["Close"].iloc[-1])
bb_pos = (
float(df_chart["bb_pos"].iloc[-1])
if "bb_pos" in df_chart.columns and pd.notna(df_chart["bb_pos"].iloc[-1])
else None
)
bb_txt = f"{bb_pos:.2f}" if bb_pos is not None else "-"
elif gt_summary.get("mark_price"):
close_val = float(gt_summary["mark_price"])
sim_trades = fires_to_trade_list(capped)
gt_pnl = {}
gt_summary_pnl = gt_data.get("summary") or {}
if gt_summary_pnl.get("pnl_krw") is not None and gt_summary_pnl.get(
"execution_order"
) == "leg_block":
gt_pnl = {
k: gt_summary_pnl[k]
for k in (
"initial_cash_krw",
"final_asset_krw",
"pnl_pct",
"total_fees_krw",
"holding_qty",
"holding_value_krw",
"mark_price",
"cash_krw",
)
if k in gt_summary_pnl
}
elif gt_trades:
gt_pnl = simulate_truth_portfolio(
gt_trades,
initial_cash=GT_INITIAL_CASH_KRW,
fee_rate=TRADING_FEE_RATE,
last_price=close_val if close_val else None,
)
sim_pnl = simulate_fixed_order_portfolio(
sim_trades,
order_krw=LIVE_ORDER_KRW,
initial_cash=GT_INITIAL_CASH_KRW,
fee_rate=TRADING_FEE_RATE,
last_price=close_val if close_val else None,
)
sim_steps = simulate_fixed_order_portfolio_steps(
sim_trades,
order_krw=LIVE_ORDER_KRW,
initial_cash=GT_INITIAL_CASH_KRW,
fee_rate=TRADING_FEE_RATE,
)
gt_steps = (
simulate_truth_portfolio_steps(
gt_trades,
initial_cash=GT_INITIAL_CASH_KRW,
fee_rate=TRADING_FEE_RATE,
)
if gt_trades
else []
)
criteria_blocks = "".join(rule_criteria_html(r) for r in monitor_rules)
go_table = go_no_go_table_html(go.get("checks", []), go_flag)
def _mark_note(price: float) -> str:
if price > 0:
return f" 총보유자산(미청산 포함)은 종가 ₩{price:,.0f} 평가."
return ""
sim_table = f"""
<h2>시뮬 타점 (holdout {len(holdout_fires)}건 → 체결 가정 {len(capped)}건)</h2>
<p class="meta">1회 ₩{LIVE_ORDER_KRW:,.0f}·일한도·최대 거래수 적용 후 체결 순 포트폴리오.
가격 열 (+/-) = <b>{label_mode}</b> 구간 수익%.{_mark_note(close_val)}</p>
<div class="table-scroll">
<table>
<thead><tr><th>시각</th><th>구분</th><th>규칙</th><th>유형</th><th>가격</th>
<th>총 평가금액</th><th>승/패</th><th>비고</th></tr></thead>
<tbody>{_sim_fire_table_rows(capped, rules_by_id, sim_steps)}</tbody>
</table>
</div>"""
gt_table = f"""
<h2>정답 타점 (ground_truth)</h2>
<p class="meta">삼각형 = GT. 매수 분할 비중·매도 leg 반영.{_mark_note(close_val)}</p>
<div class="table-scroll">
<table>
<thead><tr><th>시각</th><th>구분</th><th>비중</th><th>가격</th><th>총 평가금액</th><th>해석</th></tr></thead>
<tbody>{_gt_table_rows(gt_trades, gt_steps)}</tbody>
</table>
</div>"""
sections = f"""
{go_table}
<h2>매수·매도 판단 기준 (monitor_rules)</h2>
<p class="meta">04 GT 프로필 + 전구간 EV 선별. 조건 <b>모두</b> 충족 시 3분봉 종가에 신호.</p>
{criteria_blocks}
{sim_table}
{gt_table}
"""
note = (
f"1단계 시뮬 · holdout {report.get('holdout_ratio', 0.15)} · "
f"발화 {len(holdout_fires)}건 / 체결가정 {len(capped)}건. "
"상단 카드: 초기 금액·총보유자산·초기 대비 증감율·수수료."
)
legend = (
"▲ <b>정답 매수</b> · ▼ <b>정답 매도</b> — 삼각형 = GT 비중.<br>"
"● <b>시뮬</b> — 원 = holdout 발화 (차트). 테이블 = 일한도 적용 체결 순서."
)
if frames is not None:
meta_line = (
f"추세(참고): {trend} | 기간: {df_chart.index[0]} ~ {df_chart.index[-1]} "
f"| 봉 {len(df_chart)}"
)
else:
meta_line = (
f"추세·{SYMBOL} | lookback {CHART_LOOKBACK_DAYS}일 | "
f"초기 ₩{GT_INITIAL_CASH_KRW:,.0f}"
)
cards = _summary_cards_html(
close_val, bb_txt, gt_trades, gt_pnl, sim_pnl, len(capped), go_flag
)
if frames is not None:
return build_chart_html(
df_chart,
trend,
note=note,
truth_trades=gt_trades,
sim_trades=_fires_to_chart_trades(holdout_fires),
title_suffix="1단계 시뮬레이션 (monitor · holdout)",
legend_html=legend,
footer_sections=sections,
cards_html=cards,
)
return wrap_chart_report_page(
page_title=f"{SYMBOL} 시뮬레이션",
heading=f"{COIN_NAME} ({SYMBOL}) 1단계 시뮬레이션",
meta_line=meta_line,
note_html=f"<p class='note'>{note}</p>",
legend_html=legend,
cards_html=cards,
chart_html=(
"<p class='note'>차트 데이터 없음 — "
"<code>python scripts/01_download.py</code> 후 재생성.</p>"
),
sections_html=sections,
)
def write_simulation_report_html(
report: dict[str, Any],
out_path: Path,
outcomes_path: Path | None = None,
matched_path: Path | None = None,
) -> Path:
"""
simulation_report.html 저장 (ground_truth 동일 스타일).
Args:
report: build_simulation_report 결과.
out_path: HTML 경로.
outcomes_path: fire_outcomes.csv.
matched_path: matched_rules.json.
Returns:
out_path.
"""
html = build_simulation_page_html(report, outcomes_path, matched_path)
out_path.parent.mkdir(parents=True, exist_ok=True)
out_path.write_text(html, encoding="utf-8")
return out_path