""" WLD 볼린저 밴드 차트. python scripts/05_chart_bb.py python scripts/05_chart_truth.py python scripts/02_ground_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, 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.paths import CHART_BB_HTML, CHART_TRUTH_HTML, resolve_ground_truth_file OUTPUT_HTML = CHART_BB_HTML TRUTH_HTML = CHART_TRUTH_HTML GROUND_TRUTH_PATH = resolve_ground_truth_file() REPORT_DIR = CHART_BB_HTML.parent def interval_chart_label(interval_min: int) -> str: """차트 제목용 봉 라벨.""" if interval_min >= 1440: return "일봉" return f"{interval_min}분봉" def _marker_sizes(trades: list[dict], action: str) -> list[float]: """비중(weight, 0~1)에 비례한 삼각형 크기.""" pts = [t for t in trades if t.get("action") == action] if not pts: return [] lo, hi = float(GT_MARKER_SIZE_MIN), float(GT_MARKER_SIZE_MAX) return [ lo + (hi - lo) * min(max(float(t.get("weight", 1.0)), 0.05), 1.0) for t in pts ] 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(trades, action) 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=[ f"{label}
{t['dt'][:16]}
₩{t['price']:,.0f}" f"
비중 {float(t.get('weight', 1))*100:.0f}%" f"
{t.get('memo', '')}" for t in pts ], hovertemplate="%{hovertext}", ), 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, title_suffix: str = "BB 차트", pnl_summary: dict | 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) 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"

{note}

" 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 = "" if 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""" 시작 - - - ₩{GT_INITIAL_CASH_KRW:,.0f} 초기 현금 (보유 0) """ 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""" {t['dt'][:16]} {mark} {w*100:.0f}% ₩{t['price']:,.0f}{ret_s} {total_s}{hold_s} {t.get('memo', '')} """ trade_table = "" if truth_trades: if not trade_rows: trade_rows = "타점 없음" mark_note = "" if pnl.get("mark_price"): mark_note = ( f" 상단 최종 자산은 미청산 포함 종가 ₩{pnl['mark_price']:,.0f} 평가." ) trade_table = f"""

정답 타점 (ground_truth)

삼각형 크기 = 비중. 매수: 저점 분할 / 매도: 고점 1~2회. 총평가 = 체결 직후 현금 + 보유×체결가.{mark_note}

{trade_rows}
시각구분비중가격총 평가금액해석
""" pnl_cards = "" if truth_trades and pnl.get("initial_cash_krw") is not None: pnl_cards = f"""
시작₩{pnl['initial_cash_krw']:,.0f}
최종 자산₩{pnl['final_asset_krw']:,.0f}
수익금₩{pnl['pnl_krw']:+,.0f}
수익률{pnl['pnl_pct']:+.2f}%
수수료₩{pnl['total_fees_krw']:,.0f}
""" if pnl.get("holding_qty", 0) > 0: pnl_cards += f"""
미청산{pnl['holding_qty']}개 (₩{pnl['holding_value_krw']:,.0f})
""" return f""" {SYMBOL} {title_suffix}

{COIN_NAME} ({SYMBOL}) {title_suffix}

추세(참고): {trend} | 기간: {df.index[0]} ~ {df.index[-1]} | 봉 수: {len(df)}

{note_html}
▲ 매수 · ▼ 매도 — 삼각형이 클수록 비중이 큽니다.
종가₩{close_last:,.2f}
BB %B{bb_pos_txt}
정답 타점{len(truth_trades) if truth_trades else 0}건
{pnl_cards}
{chart_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) -> Path: """ 정답 타점을 생성·저장하고 마커가 포함된 HTML 차트를 만듭니다. Args: open_browser: True면 브라우저로 HTML을 엽니다. Returns: HTML 파일 경로. """ from deepcoin.ground_truth.ground_truth import run_from_db 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 run_chart(open_browser: bool = True) -> Path: """ 3분봉 BB 차트 HTML을 생성합니다. Args: open_browser: True면 기본 브라우저로 HTML을 엽니다. Returns: 저장된 HTML 경로. """ 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) print(f"\n추세(참고): {trend}") print(f"3분: {df_chart.index[0]} ~ {df_chart.index[-1]} ({len(df_chart)}봉)") html = build_chart_html( df_chart, trend, note="자동 매수·매도 전략은 사용하지 않습니다.", ) REPORT_DIR.mkdir(parents=True, exist_ok=True) OUTPUT_HTML.write_text(html, encoding="utf-8") print(f"HTML: {OUTPUT_HTML}") if open_browser: webbrowser.open(OUTPUT_HTML.resolve().as_uri()) return OUTPUT_HTML def print_usage() -> None: print( """ DeepCoin simulation.py python simulation.py WLD 3분봉 BB 차트 → docs/charts/wld_bb_chart.html python simulation.py truth 정답 타점 생성 → ground_truth_trades.json 차트 → docs/02_ground_truth/wld_ground_truth_chart.html """ ) def main() -> None: if len(sys.argv) > 1 and sys.argv[1] in ("-h", "--help", "help"): print_usage() return if len(sys.argv) > 1 and sys.argv[1] in ("truth", "ground-truth", "gt"): print("=" * 60) print("정답 타점 생성 + 차트") print("=" * 60) run_ground_truth_chart() print("\n완료.") return if len(sys.argv) > 1: print(f"알 수 없는 옵션: {sys.argv[1]}\n") print_usage() return print("=" * 60) print("WLD BB 차트 (매매 전략 없음)") print("=" * 60) run_chart() print("\n완료.") if __name__ == "__main__": main()