3분~일봉 GT 타점 분석(03c), leg 체결 순서 수정, 총자산 90% 검증 루프, walk-forward Go/No-Go 시뮬, monitor·live_trader 및 reference 문서를 포함한다. Co-authored-by: Cursor <cursoragent@cursor.com>
461 lines
15 KiB
Python
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
|