Files
Bithumb/deepcoin/ops/simulation.py
xavis d7848df6f7 refactor: GT·시뮬·운영 3축 정리 및 hybrid 실거래 정합
Phase C/dry-run·미사용 모듈·재생성 HTML을 제거하고, 운영 체결을
sim_causal_hybrid와 동일한 hybrid 로직으로 통합한다.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-03 23:50:28 +09:00

670 lines
21 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""
Ground Truth 차트 HTML (05_chart_truth).
python scripts/05_chart_truth.py
"""
from __future__ import annotations
import sys
import webbrowser
from pathlib import Path
import numpy as np
import pandas as pd
import plotly.graph_objs as go
from plotly.subplots import make_subplots
from config import (
CHART_LOOKBACK_DAYS,
COIN_NAME,
DISPARITY_OVERBOUGHT,
DISPARITY_OVERSOLD,
DISPARITY_PERIODS,
ENTRY_INTERVAL,
GROUND_TRUTH_FILE,
GT_INITIAL_CASH_KRW,
GT_MARKER_SIZE_MAX,
GT_MARKER_SIZE_MIN,
LIVE_ORDER_KRW,
MACD_FAST,
MACD_SIGNAL,
MACD_SLOW,
STOCH_D_PERIOD,
STOCH_K_PERIOD,
SYMBOL,
TRADING_FEE_RATE,
TREND_INTERVAL_1D,
TREND_INTERVAL_1H,
)
from deepcoin.common.indicators import apply_bar_indicators, disparity_column, get_trend
from deepcoin.ops.monitor import Monitor
from deepcoin.data.mtf_bb import interval_label, load_frames_from_db
from deepcoin.ops.chart_report import wrap_chart_report_page
from deepcoin.paths import CHART_TRUTH_HTML, resolve_ground_truth_file
TRUTH_HTML = CHART_TRUTH_HTML
GROUND_TRUTH_PATH = resolve_ground_truth_file()
REPORT_DIR = CHART_TRUTH_HTML.parent
def interval_chart_label(interval_min: int) -> str:
"""차트 제목용 봉 라벨."""
if interval_min >= 1440:
return "일봉"
return f"{interval_min}분봉"
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_INITIAL_CASH_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)
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:
"""
시뮬(규칙) 매수·매도 마커 — 원형, GT 삼각형과 구분.
Args:
fig: plotly Figure.
trades: dt, action, price, forward_ret_pct, rule_id 키.
row: subplot row.
"""
for action, color, label in [
("buy", "#059669", "시뮬 매수"),
("sell", "#b91c1c", "시뮬 매도"),
]:
pts = [t for t in trades if t.get("action") == action]
if not pts:
continue
fig.add_trace(
go.Scatter(
x=[pd.Timestamp(t["dt"]) for t in pts],
y=[float(t["price"]) for t in pts],
mode="markers",
name=label,
legendgroup=label,
marker=dict(
symbol="circle",
size=9,
color=color,
line=dict(width=1, color="#fff"),
opacity=0.75,
),
hovertext=[
_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>",
),
row=row,
col=1,
)
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", "정답 매도"),
]:
pts = [t for t in trades if t.get("action") == action]
if not pts:
continue
sizes = _marker_sizes(pts)
fig.add_trace(
go.Scatter(
x=[pd.Timestamp(t["dt"]) for t in pts],
y=[t["price"] for t in pts],
mode="markers",
name=label,
legendgroup=label,
marker=dict(
symbol=symbol,
size=sizes,
sizemode="diameter",
color=color,
line=dict(width=1.5, color="#111"),
),
hovertext=[
_marker_hover_text(label, t)
for t in pts
],
hovertemplate="%{hovertext}<extra></extra>",
),
row=row,
col=1,
)
def build_chart_html(
df: pd.DataFrame,
trend: str,
interval_min: int = ENTRY_INTERVAL,
note: str = "",
truth_trades: list[dict] | None = None,
sim_trades: list[dict] | None = None,
title_suffix: str = "BB 차트",
pnl_summary: dict | None = None,
legend_html: str | None = None,
footer_sections: str | None = None,
cards_html: str | None = None,
) -> str:
"""BB·이격도·RSI·MACD·스토캐스틱·거래량 차트 HTML."""
df = apply_bar_indicators(df.copy())
iv_label = interval_chart_label(interval_min)
close_last = float(df["Close"].iloc[-1])
bb_pos = None
if "bb_pos" in df.columns and pd.notna(df["bb_pos"].iloc[-1]):
bb_pos = float(df["bb_pos"].iloc[-1])
disp_title = "이격도 " + ",".join(str(p) for p in DISPARITY_PERIODS)
fig = make_subplots(
rows=6,
cols=1,
shared_xaxes=True,
vertical_spacing=0.03,
row_heights=[0.42, 0.11, 0.11, 0.11, 0.13, 0.12],
subplot_titles=(
f"{COIN_NAME} ({SYMBOL}) {iv_label}",
disp_title,
f"Stochastic ({STOCH_K_PERIOD},{STOCH_D_PERIOD})",
"RSI (14)",
f"MACD ({MACD_FAST},{MACD_SLOW},{MACD_SIGNAL})",
"거래량",
),
)
disp_colors = ("#0d9488", "#7c3aed", "#ca8a04")
fig.add_trace(
go.Candlestick(
x=df.index,
open=df["Open"],
high=df["High"],
low=df["Low"],
close=df["Close"],
name=f"{iv_label} 캔들",
increasing_line_color="#ef4444",
decreasing_line_color="#3b82f6",
),
row=1,
col=1,
)
if "MA" in df.columns:
fig.add_trace(
go.Scatter(
x=df.index,
y=df["MA"],
name="BB 중심",
line=dict(color="#64748b", width=1, dash="dot"),
),
row=1,
col=1,
)
if "Upper" in df.columns:
fig.add_trace(
go.Scatter(
x=df.index,
y=df["Upper"],
name="BB 상단",
line=dict(color="#94a3b8", width=1),
),
row=1,
col=1,
)
if "Lower" in df.columns:
fig.add_trace(
go.Scatter(
x=df.index,
y=df["Lower"],
name="BB 하단",
line=dict(color="#94a3b8", width=1),
),
row=1,
col=1,
)
if truth_trades:
_add_truth_markers(fig, truth_trades, row=1)
if sim_trades:
_add_sim_markers(fig, sim_trades, row=1)
disp_row = 2
for i, p in enumerate(DISPARITY_PERIODS):
col = disparity_column(p)
if col not in df.columns:
continue
color = disp_colors[i % len(disp_colors)]
fig.add_trace(
go.Scatter(
x=df.index,
y=df[col],
name=f"D.I. {p}",
line=dict(color=color, width=1),
),
row=disp_row,
col=1,
)
if any(disparity_column(p) in df.columns for p in DISPARITY_PERIODS):
fig.add_hline(
y=100, line_dash="solid", line_color="#64748b", row=disp_row, col=1
)
fig.add_hline(
y=DISPARITY_OVERBOUGHT,
line_dash="dot",
line_color="#ef4444",
row=disp_row,
col=1,
)
fig.add_hline(
y=DISPARITY_OVERSOLD,
line_dash="dot",
line_color="#16a34a",
row=disp_row,
col=1,
)
stoch_row = 3
if "stoch_k" in df.columns:
fig.add_trace(
go.Scatter(
x=df.index,
y=df["stoch_k"],
name="Stoch %K",
line=dict(color="#0ea5e9", width=1),
),
row=stoch_row,
col=1,
)
fig.add_trace(
go.Scatter(
x=df.index,
y=df["stoch_d"],
name="Stoch %D",
line=dict(color="#f97316", width=1),
),
row=stoch_row,
col=1,
)
fig.add_hline(y=80, line_dash="dot", line_color="#9ca3af", row=stoch_row, col=1)
fig.add_hline(y=20, line_dash="dot", line_color="#9ca3af", row=stoch_row, col=1)
rsi_row = 4
if "RSI" in df.columns:
fig.add_trace(
go.Scatter(
x=df.index,
y=df["RSI"],
name="RSI",
line=dict(color="#7c3aed"),
),
row=rsi_row,
col=1,
)
fig.add_hline(y=70, line_dash="dot", line_color="#9ca3af", row=rsi_row, col=1)
fig.add_hline(y=30, line_dash="dot", line_color="#9ca3af", row=rsi_row, col=1)
macd_row = 5
vol_row = 6
if "macd_hist" in df.columns:
colors = np.where(df["macd_hist"].astype(float) >= 0, "#ef4444", "#3b82f6")
fig.add_trace(
go.Bar(
x=df.index,
y=df["macd_hist"],
name="MACD Hist",
marker_color=colors,
),
row=macd_row,
col=1,
)
fig.add_trace(
go.Scatter(
x=df.index,
y=df["macd_line"],
name="MACD",
line=dict(color="#2563eb", width=1),
),
row=macd_row,
col=1,
)
fig.add_trace(
go.Scatter(
x=df.index,
y=df["macd_signal"],
name="Signal",
line=dict(color="#ea580c", width=1, dash="dot"),
),
row=macd_row,
col=1,
)
fig.add_trace(
go.Bar(
x=df.index,
y=df["Volume"],
name="Volume",
marker_color="#cbd5e1",
),
row=vol_row,
col=1,
)
fig.update_layout(
height=1180,
template="plotly_white",
xaxis_rangeslider_visible=False,
legend=dict(orientation="h", y=1.05, x=0),
margin=dict(l=60, r=30, t=90, b=40),
)
fig.update_yaxes(title_text="가격 (KRW)", row=1, col=1)
fig.update_yaxes(title_text="이격도", row=2, col=1)
fig.update_yaxes(title_text="Stoch", row=3, col=1, range=[0, 100])
fig.update_yaxes(title_text="RSI", row=4, col=1, range=[0, 100])
fig.update_yaxes(title_text="MACD", row=5, col=1)
chart_html = fig.to_html(full_html=False, include_plotlyjs="cdn")
note_html = f"<p class='note'>{note}</p>" if note else ""
bb_pos_txt = f"{bb_pos:.2f}" if bb_pos is not None else "-"
pnl = pnl_summary or {}
if truth_trades and not pnl:
from deepcoin.ground_truth.ground_truth import simulate_truth_portfolio
pnl = simulate_truth_portfolio(
truth_trades,
initial_cash=GT_INITIAL_CASH_KRW,
fee_rate=TRADING_FEE_RATE,
last_price=close_last,
)
trade_rows = ""
trade_table = footer_sections or ""
if footer_sections is None and truth_trades:
from deepcoin.ground_truth.ground_truth import simulate_truth_portfolio_steps
steps = simulate_truth_portfolio_steps(
truth_trades,
initial_cash=GT_INITIAL_CASH_KRW,
fee_rate=TRADING_FEE_RATE,
)
step_key = {
(s["dt"], s["action"], float(s["price"]), float(s["weight"])): s
for s in steps
}
sorted_trades = sorted(truth_trades, key=lambda x: x["dt"])
trade_rows += 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:
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 = ""
trade_rows += f"""
<tr>
<td>{t['dt'][:16]}</td>
<td class="{cls}">{mark}</td>
<td>{w*100:.0f}%</td>
<td>₩{t['price']:,.0f}{ret_s}</td>
<td><b>{total_s}</b>{hold_s}</td>
<td>{t.get('memo', '')}</td>
</tr>"""
if not trade_table and truth_trades:
if not trade_rows:
trade_rows = "<tr><td colspan='6'>타점 없음</td></tr>"
mark_note = ""
if pnl.get("mark_price"):
mark_note = (
f" 총보유자산(미청산 포함)은 종가 ₩{pnl['mark_price']:,.0f} 평가."
)
trade_table = f"""
<h2>정답 타점 (ground_truth)</h2>
<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>
<tbody>{trade_rows}</tbody>
</table>"""
pnl_cards = ""
if truth_trades and pnl.get("initial_cash_krw") is not None:
from deepcoin.ops.chart_report import card_html, initial_change_pct
change_pct = initial_change_pct(pnl)
pnl_cards = (
card_html("초기 금액", f"{pnl['initial_cash_krw']:,.0f}")
+ card_html("총보유자산", f"{pnl['final_asset_krw']:,.0f}")
+ card_html("초기 대비 증감율", f"{change_pct:+.2f}%")
+ card_html("수수료", f"{pnl['total_fees_krw']:,.0f}")
)
if pnl.get("holding_qty", 0) > 0:
pnl_cards += card_html(
"미청산",
f"{pnl['holding_qty']}개 (₩{pnl['holding_value_krw']:,.0f})",
)
default_legend = (
"▲ <b>정답 매수</b> · ▼ <b>정답 매도</b> — 크기=체결금액. "
"매수=총자산×비중×leg티어(상위 대형). "
"툴팁: 체결가·매수/매도금액.<br>"
"● <b>시뮬 매수</b> · ● <b>시뮬 매도</b> — 원 = holdout 발화 "
f"(매수 ₩{LIVE_ORDER_KRW:,.0f}/회)."
)
if cards_html:
cards_inner = cards_html
else:
cards_inner = f"""
<div class="card"><span>종가</span><b>₩{close_last:,.2f}</b></div>
<div class="card"><span>BB %B</span><b>{bb_pos_txt}</b></div>
<div class="card"><span>정답 타점</span><b>{len(truth_trades) if truth_trades else 0}건</b></div>
{pnl_cards}"""
return wrap_chart_report_page(
page_title=f"{SYMBOL} {title_suffix}",
heading=f"{COIN_NAME} ({SYMBOL}) {title_suffix}",
meta_line=f"추세(참고): {trend} | 기간: {df.index[0]} ~ {df.index[-1]} | 봉 수: {len(df)}",
note_html=note_html,
legend_html=legend_html or default_legend,
cards_html=cards_inner,
chart_html=chart_html,
sections_html=trade_table,
)
def _frames_to_mtf(
frames: dict[int, pd.DataFrame],
) -> tuple[pd.DataFrame, pd.DataFrame, pd.DataFrame]:
"""전 간격 frames에서 1d/1h/3m 추출."""
df_3m = frames.get(ENTRY_INTERVAL)
if df_3m is None or df_3m.empty:
raise ValueError(f"{ENTRY_INTERVAL}분봉 데이터 없음")
df_1d = frames.get(TREND_INTERVAL_1D)
if df_1d is None or df_1d.empty:
df_1d = df_3m
df_1h = frames.get(TREND_INTERVAL_1H)
if df_1h is None or df_1h.empty:
df_1h = df_3m
return df_1d, df_1h, df_3m
def load_chart_frames() -> dict[int, pd.DataFrame] | None:
"""coins.db 전 간격 로드. 부족 시 None."""
monitor = Monitor(cooldown_file=None)
print(f"DB 조회: 최근 {CHART_LOOKBACK_DAYS}일 (CHART_LOOKBACK_DAYS)")
frames = load_frames_from_db(monitor, SYMBOL, lookback_days=CHART_LOOKBACK_DAYS)
if ENTRY_INTERVAL not in frames:
print("coins.db 데이터 부족. python scripts/01_download.py 실행 후 재시도.")
return None
return frames
def run_ground_truth_chart(
open_browser: bool = True,
*,
from_json: bool = True,
) -> Path:
"""
정답 타점 마커가 포함된 HTML 차트를 만듭니다.
Args:
open_browser: True면 브라우저로 HTML을 엽니다.
from_json: True면 기존 ground_truth_trades.json 을 사용합니다.
False면 DB에서 GT를 재생성합니다.
Returns:
HTML 파일 경로.
"""
from deepcoin.ground_truth.ground_truth import load_ground_truth, run_from_db
gt_path = resolve_ground_truth_file()
if from_json:
data = load_ground_truth(gt_path)
if not data:
print(f"GT JSON 없음({gt_path}) — DB에서 재생성합니다.")
data = run_from_db()
else:
print(f"GT JSON 로드: {gt_path}")
else:
data = run_from_db()
frames = load_chart_frames()
if frames is None:
raise RuntimeError("차트 데이터 로드 실패")
df_1d, df_1h, df_3m = _frames_to_mtf(frames)
trend = get_trend(df_1d, df_1h)
df_chart = apply_bar_indicators(df_3m)
trades = data.get("trades") or []
summary = data.get("summary") or {}
html = build_chart_html(
df_chart,
trend,
note=data.get("note", ""),
truth_trades=trades,
title_suffix=f"정답 타점 ({CHART_LOOKBACK_DAYS}일)",
pnl_summary=summary if summary.get("pnl_krw") is not None else None,
)
REPORT_DIR.mkdir(parents=True, exist_ok=True)
TRUTH_HTML.write_text(html, encoding="utf-8")
print(f"HTML: {TRUTH_HTML}")
if open_browser:
webbrowser.open(TRUTH_HTML.resolve().as_uri())
return TRUTH_HTML
def main() -> None:
"""05_chart_truth CLI 진입 (미사용 시 no-op)."""
if len(sys.argv) > 1 and sys.argv[1] in ("-h", "--help", "help"):
print("GT 차트: python scripts/05_chart_truth.py")
return
run_ground_truth_chart(open_browser=False)
if __name__ == "__main__":
main()