""" WLD 3분 BB 시뮬레이션. 기본: 하단 상향 돌파 매수, 상단 상향 돌파 매도. 수수료 반영, 레짐/필터 조합 비교 지원. python simulation.py # analyze → discover → HTML (탐색 규칙 매수·매도) python simulation.py analyze # (고급) 조합 분석만 python simulation.py discover # (고급) 규칙 탐색만 python simulation.py compare # (고급) 9종 프리셋 비교 python simulation.py mtf # (고급) 구 MTF BB 정책 """ from __future__ import annotations import sys import webbrowser from dataclasses import dataclass from pathlib import Path import pandas as pd import plotly.graph_objs as go from plotly.subplots import make_subplots from config import ( BUY_COOLDOWN_SEC, COIN_NAME, ENTRY_INTERVAL, SELL_COOLDOWN_SEC, SIM_INITIAL_CASH_KRW, SIM_MIN_ORDER_KRW, SYMBOL, TRADING_FEE_RATE, TREND_INTERVAL_1D, TREND_INTERVAL_1H, ) from monitor import Monitor import strategy REPORT_DIR = Path(__file__).resolve().parent / "reports" OUTPUT_HTML = REPORT_DIR / "wld_bb_simulation.html" def interval_chart_label(interval_min: int) -> str: """차트 제목용 봉 라벨.""" if interval_min >= 1440: return "일봉" return f"{interval_min}분봉" def _add_trade_markers(fig, trades: list["SimTrade"], row: int = 1) -> None: """ 매수·매도 마커·라벨 (Scatter trace만 사용, 범례와 함께 토글). simulate_mtf.py 와 동일 스타일. """ for action, color, symbol, label, text_pos in [ ("매수", "#16a34a", "triangle-up", "매수", "top center"), ("매도", "#dc2626", "triangle-down", "매도", "bottom center"), ]: pts = [t for t in trades if t.action == action] if not pts: continue fig.add_trace( go.Scatter( x=[t.dt for t in pts], y=[t.price for t in pts], mode="markers+text", name=label, legendgroup=label, text=[label] * len(pts), textposition=text_pos, textfont=dict( size=12, color=color, family="Malgun Gothic, Arial, sans-serif", ), marker=dict( symbol=symbol, size=16, color=color, line=dict(width=2, color="#111"), ), hovertext=[ f"{label} 체결
{t.signal}
₩{t.price:,.2f}
₩{t.krw:,.0f}" for t in pts ], hovertemplate="%{hovertext}", ), row=row, col=1, ) def build_simulation_html( df: pd.DataFrame, result: SimResult, trend: str, interval_min: int = ENTRY_INTERVAL, note: str = "", ) -> str: """simulate_mtf.py 와 동일 레이아웃의 HTML 리포트.""" df = strategy.prepare_entry_df(df.copy()) iv_label = interval_chart_label(interval_min) buy_n = sum(1 for t in result.trades if t.action == "매수") sell_n = sum(1 for t in result.trades if t.action == "매도") pnl_krw = result.final_asset - result.initial_cash summary = { "config_name": result.config_name, "period_start": str(df.index[0]), "period_end": str(df.index[-1]), "interval_label": iv_label, "trend": trend, "signal_count": len(result.trades), "buy_signal_count": buy_n, "sell_signal_count": sell_n, "total_trades": result.trade_count, "pnl_krw": round(pnl_krw, 0), "pnl_pct": round(result.total_return_pct, 2), "total_fees": round(result.total_fees, 0), "win_count": result.win_count, "note": note, } fig = make_subplots( rows=3, cols=1, shared_xaxes=True, vertical_spacing=0.05, row_heights=[0.58, 0.2, 0.22], subplot_titles=( f"{COIN_NAME} ({SYMBOL}) {iv_label} — {result.config_name}", "RSI (14)", "거래량", ), ) 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, ) _add_trade_markers(fig, result.trades, row=1) if not result.trades: fig.add_annotation( text=( f"이 기간에는 체결 신호가 없습니다.
" f"전략: {result.config_name} | 추세: {trend}" ), xref="paper", yref="paper", x=0.5, y=0.88, showarrow=False, font=dict(size=14, color="#b45309"), bgcolor="#fffbeb", bordercolor="#f59e0b", borderwidth=1, ) if "RSI" in df.columns: fig.add_trace( go.Scatter( x=df.index, y=df["RSI"], name="RSI", line=dict(color="#7c3aed"), ), row=2, col=1, ) fig.add_hline(y=70, line_dash="dot", line_color="#9ca3af", row=2, col=1) fig.add_hline(y=50, line_dash="dot", line_color="#d1d5db", row=2, col=1) fig.add_trace( go.Bar( x=df.index, y=df["Volume"], name="Volume", marker_color="#cbd5e1", ), row=3, col=1, ) fig.update_layout( height=920, 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="RSI", row=2, col=1, range=[0, 100]) chart_html = fig.to_html(full_html=False, include_plotlyjs="cdn") trade_rows = "" for t in result.trades: cls = "buy" if t.action == "매수" else "sell" pnl = f"{t.pnl:+,.0f}" if t.pnl is not None else "-" trade_rows += f""" {t.dt} {t.action} 체결 ₩{t.price:,.2f} {t.signal} ₩{t.krw:,.0f} ₩{t.fee:,.0f} {pnl} """ if not trade_rows: trade_rows = '신호 없음' note_html = f"

{summary['note']}

" if summary.get("note") else "" sells = summary["sell_signal_count"] win_rate = ( summary["win_count"] / sells * 100 if sells else 0.0 ) return f""" {SYMBOL} BB 시뮬레이션

{COIN_NAME} ({SYMBOL}) BB 시뮬레이션

전략: {summary['config_name']} | 추세: {summary['trend']} | 기간: {summary['period_start']} ~ {summary['period_end']}

{note_html}
▲ 매수 범례 클릭 시 마커·라벨 함께 숨김 ▼ 매도 동일
체결{summary['total_trades']} (매수 {summary['buy_signal_count']} / 매도 {summary['sell_signal_count']})
손익₩{summary['pnl_krw']:+,.0f} ({summary['pnl_pct']:+.2f}%)
수수료₩{summary['total_fees']:,.0f}
승률(매도 기준){win_rate:.1f}%
{chart_html}

신호·체결 내역

{trade_rows}
시각구분상태가격신호금액수수료손익
""" @dataclass class SimTrade: dt: pd.Timestamp action: str signal: str price: float krw: float fee: float quantity: float pnl: float | None cash_after: float total_asset: float @dataclass class SimResult: config_name: str trades: list[SimTrade] initial_cash: float final_cash: float final_coin_qty: float final_price: float realized_pnl: float total_fees: float final_asset: float total_return_pct: float trade_count: int win_count: int def run_backtest( df_3m: pd.DataFrame, df_1d: pd.DataFrame, df_1h: pd.DataFrame, config_name: str = "", initial_cash: float = SIM_INITIAL_CASH_KRW, min_order_krw: float = SIM_MIN_ORDER_KRW, fee_rate: float = TRADING_FEE_RATE, ) -> SimResult: """신호 순서대로 현물 매수/매도 시뮬레이션 (수수료 차감).""" cash = float(initial_cash) coin_qty = 0.0 cost_basis = 0.0 realized_pnl = 0.0 total_fees = 0.0 win_count = 0 trades: list[SimTrade] = [] last_buy_ts: pd.Timestamp | None = None last_sell_ts: pd.Timestamp | None = None signals = df_3m[df_3m["point"] == 1].sort_index() for ts, row in signals.iterrows(): price = float(row["Close"]) action = str(row.get("action", "")) signal_name = str(row.get("signal", "")) if price <= 0: continue trend_at = str(row.get("trend", "")) or strategy.get_trend_at(df_1d, df_1h, ts) if trend_at not in ("up", "down", "range"): trend_at = strategy.get_trend_at(df_1d, df_1h, ts) if action == "buy": if last_buy_ts is not None: if (ts - last_buy_ts).total_seconds() < BUY_COOLDOWN_SEC: continue buy_krw = float( strategy.get_buy_amount(SYMBOL, signal_name, price, trend_at) ) buy_krw = max(min_order_krw, min(buy_krw, cash)) fee = buy_krw * fee_rate total_cost = buy_krw + fee if buy_krw < min_order_krw or cash < total_cost: continue qty = buy_krw / price cash -= total_cost total_fees += fee cost_basis += buy_krw coin_qty += qty last_buy_ts = ts trades.append( SimTrade( dt=ts, action="매수", signal=signal_name, price=price, krw=buy_krw, fee=fee, quantity=qty, pnl=None, cash_after=cash, total_asset=cash + coin_qty * price, ) ) continue if action == "sell": if coin_qty <= 0: continue if last_sell_ts is not None: if (ts - last_sell_ts).total_seconds() < SELL_COOLDOWN_SEC: continue ratio = strategy.get_sell_ratio(SYMBOL, signal_name) sell_qty = min(coin_qty * ratio, coin_qty) sell_krw = sell_qty * price if sell_krw < min_order_krw: if coin_qty * price < min_order_krw: continue sell_qty = coin_qty sell_krw = sell_qty * price fee = sell_krw * fee_rate net = sell_krw - fee avg_cost = cost_basis / coin_qty sold_cost = avg_cost * sell_qty pnl = net - sold_cost cash += net total_fees += fee cost_basis -= sold_cost coin_qty -= sell_qty realized_pnl += pnl if pnl > 0: win_count += 1 if coin_qty < 1e-12: coin_qty = 0.0 cost_basis = 0.0 last_sell_ts = ts trades.append( SimTrade( dt=ts, action="매도", signal=signal_name, price=price, krw=sell_krw, fee=fee, quantity=sell_qty, pnl=pnl, cash_after=cash, total_asset=cash + coin_qty * price, ) ) final_price = float(df_3m["Close"].iloc[-1]) final_asset = cash + coin_qty * final_price sell_trades = sum(1 for t in trades if t.action == "매도") return SimResult( config_name=config_name, trades=trades, initial_cash=initial_cash, final_cash=cash, final_coin_qty=coin_qty, final_price=final_price, realized_pnl=realized_pnl, total_fees=total_fees, final_asset=final_asset, total_return_pct=(final_asset - initial_cash) / initial_cash * 100 if initial_cash > 0 else 0.0, trade_count=len(trades), win_count=win_count if sell_trades else 0, ) def print_backtest_report(result: SimResult) -> None: fee_pct = TRADING_FEE_RATE * 100 print("\n" + "=" * 80) print( f"[{result.config_name}] 시작 {result.initial_cash:,.0f}원 | " f"최소주문 {SIM_MIN_ORDER_KRW:,.0f}원 | 수수료 {fee_pct:.3f}%/쪽" ) print("=" * 80) if not result.trades: print("체결 없음") else: print( f"{'일시':<18} {'구분':<4} {'신호':<22} {'가격':>9} {'금액':>10} " f"{'수수료':>8} {'수익':>10}" ) print("-" * 80) for t in result.trades: pnl_s = f"{t.pnl:+,.0f}" if t.pnl is not None else "-" print( f"{t.dt.strftime('%Y-%m-%d %H:%M'):<18} {t.action:<4} {t.signal:<22} " f"{t.price:>9,.2f} {t.krw:>10,.0f} {t.fee:>8,.0f} {pnl_s:>10}" ) print("-" * 80) sells = sum(1 for t in result.trades if t.action == "매도") win_rate = result.win_count / sells * 100 if sells else 0.0 print(f"거래 횟수: {result.trade_count} (매도 {sells}회) | 승률: {win_rate:.1f}%") print(f"수수료 합계: {result.total_fees:,.0f}원") print(f"실현 손익(수수료 반영): {result.realized_pnl:+,.0f}원") print( f"최종 자산: {result.final_asset:,.0f}원 | " f"총수익: {result.final_asset - result.initial_cash:+,.0f}원 " f"({result.total_return_pct:+.2f}%)" ) print("=" * 80) def run_comparison(df_1d: pd.DataFrame, df_1h: pd.DataFrame, df_3m: pd.DataFrame) -> None: """기법 조합별 수익률 비교 (수수료 포함).""" print(f"\n{'='*80}") print(f"전략 조합 비교 — {SYMBOL} 3분 | {df_3m.index[0]} ~ {df_3m.index[-1]}") print(f"시작 {SIM_INITIAL_CASH_KRW:,}원 | 수수료 {TRADING_FEE_RATE*100:.3f}%/매수·매도") print(f"{'='*80}") print( f"{'순위':<4} {'조합':<22} {'수익률':>9} {'최종자산':>12} " f"{'거래':>6} {'승률':>7} {'수수료':>10}" ) print("-" * 80) rows: list[tuple[SimResult, strategy.StrategyConfig]] = [] for cfg in strategy.comparison_presets(): df_sig = strategy.annotate_signals( SYMBOL, df_3m.copy(), simulation=True, df_1h=df_1h, df_1d=df_1d, config=cfg, ) res = run_backtest(df_sig, df_1d, df_1h, config_name=cfg.name) rows.append((res, cfg)) rows.sort(key=lambda x: x[0].total_return_pct, reverse=True) for rank, (res, cfg) in enumerate(rows, 1): sells = sum(1 for t in res.trades if t.action == "매도") wr = res.win_count / sells * 100 if sells else 0.0 print( f"{rank:<4} {res.config_name:<22} {res.total_return_pct:>+8.2f}% " f"{res.final_asset:>12,.0f} {res.trade_count:>6} {wr:>6.1f}% " f"{res.total_fees:>10,.0f}" ) best_res, best_cfg = rows[0] print("-" * 80) print(f"1위: {best_cfg.name} ({best_res.total_return_pct:+.2f}%)") print( "실거래 적용: strategy.ACTIVE_CONFIG 를 1위 조합으로 맞추세요 " "(현재 ACTIVE_CONFIG.name=%s)" % strategy.ACTIVE_CONFIG.name ) print(f"{'='*80}\n") class Simulation: def __init__(self) -> None: self.monitor = Monitor(cooldown_file=None) def load_mtf(self, symbol: str): df_1d = self.monitor.get_coin_some_data(symbol, TREND_INTERVAL_1D) df_1h = self.monitor.get_coin_some_data(symbol, TREND_INTERVAL_1H) df_3m = self.monitor.get_coin_some_data(symbol, ENTRY_INTERVAL) if df_1d is None or df_1d.empty: df_1d = self.monitor.get_coin_more_data(symbol, TREND_INTERVAL_1D, bong_count=500) if df_1h is None or df_1h.empty: df_1h = self.monitor.get_coin_more_data(symbol, TREND_INTERVAL_1H, bong_count=5000) if df_3m is None or df_3m.empty: df_3m = self.monitor.get_coin_more_data( symbol, ENTRY_INTERVAL, bong_count=90000, verbose=True ) df_1d = self.monitor.calculate_technical_indicators(df_1d) df_1h = self.monitor.calculate_technical_indicators(df_1h) df_3m = self.monitor.calculate_technical_indicators(df_3m) return df_1d, df_1h, df_3m def render_plotly( self, df_plot: pd.DataFrame, trend: str, result: SimResult, interval_min: int = ENTRY_INTERVAL, note: str = "", open_browser: bool = True, ) -> Path: """HTML 리포트 저장 (simulate_mtf.py 동일 스타일).""" html = build_simulation_html( df_plot, result, trend, interval_min=interval_min, note=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 load_all_frames(self) -> dict[int, pd.DataFrame]: """discovered 규칙용 전 간격 로드.""" from mtf_bb import load_frames_from_db return load_frames_from_db(self.monitor, SYMBOL) def _run_one_strategy( self, name: str, df_1d: pd.DataFrame, df_1h: pd.DataFrame, df_3m: pd.DataFrame, cfg: strategy.StrategyConfig, frames: dict | None = None, ) -> tuple[pd.DataFrame, SimResult, int]: """한 전략으로 신호·백테스트. 반환: (df, result, 신호수).""" df_sig = strategy.annotate_signals( SYMBOL, df_3m.copy(), simulation=True, df_1h=df_1h, df_1d=df_1d, config=cfg, frames=frames, ) n_sig = int((df_sig["point"] == 1).sum()) res = run_backtest(df_sig, df_1d, df_1h, config_name=name) return df_sig, res, n_sig def _frames_to_mtf( self, 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 run_discovered_chart( self, frames: dict[int, pd.DataFrame], rules=None, ) -> SimResult: """ discovered_rules 매수·매도 규칙만 백테스트하고 HTML에 표시합니다. 차트 마커 = 해당 규칙으로 발생한 매수·매도 체결. """ from rule_discovery import DiscoveredRules, load_rules, rules_have_buy rule_set = rules or load_rules() if rule_set is None or not rules_have_buy(rule_set): raise FileNotFoundError( "discovered_rules.json 이 없거나 매수 규칙이 비어 있습니다." ) df_1d, df_1h, df_3m = self._frames_to_mtf(frames) trend = strategy.get_trend(df_1d, df_1h) print(f"추세(최신): {trend}") print(f"3분: {df_3m.index[0]} ~ {df_3m.index[-1]} ({len(df_3m)}봉)") print(f"\n[적용 규칙] {rule_set.name}") print(f" 매수 AND: {rule_set.buy_all}") if rule_set.buy_any: print(f" 매수 OR: {rule_set.buy_any}") print(f" 매도 AND: {rule_set.sell_all}") if rule_set.sell_stop: print(f" 손절: {rule_set.sell_stop}") df_sig = strategy.annotate_discovered_signals( SYMBOL, frames, df_1d, df_1h, rules=rule_set, data=df_3m ) n_sig = int((df_sig["point"] == 1).sum()) buy_sig = int((df_sig["action"] == "buy").sum()) sell_sig = int((df_sig["action"] == "sell").sum()) print(f"\n규칙 신호: {n_sig} (매수 {buy_sig} / 매도 {sell_sig})") result = run_backtest(df_sig, df_1d, df_1h, config_name=rule_set.name) print_backtest_report(result) note = ( f"매수 규칙: {rule_set.buy_all}" + (f" | OR {rule_set.buy_any}" if rule_set.buy_any else "") + f" | 매도: {rule_set.sell_all}" ) self.render_plotly(df_sig, trend, result, note=note) return result def run_mtf_analysis() -> None: """봉별 BB 백테스트 비교, 정책 저장, MTF 시뮬 차트.""" from mtf_bb import apply_policy, load_frames_from_db, run_interval_comparison, save_policy monitor = Monitor() policy, _ = run_interval_comparison(monitor) save_policy(policy) apply_policy(policy) frames = load_frames_from_db(monitor, SYMBOL) df_1d = frames.get(TREND_INTERVAL_1D) if df_1d is None or df_1d.empty: df_1d = frames[ENTRY_INTERVAL] df_1h = frames.get(TREND_INTERVAL_1H) if df_1h is None or df_1h.empty: df_1h = frames[ENTRY_INTERVAL] cfg = strategy.StrategyConfig( name="MTF_BB", use_mtf=True, use_regime_switch=strategy.ACTIVE_CONFIG.use_regime_switch, use_rsi_filter=False, use_volume_filter=False, use_squeeze_filter=False, use_stop_loss=True, ) df_sig = strategy.annotate_mtf_signals(SYMBOL, frames, df_1d, df_1h, policy, cfg) trend = strategy.get_trend(df_1d, df_1h) print(f"\nMTF 시뮬 ({policy.name}) | 추세: {trend}") result = run_backtest(df_sig, df_1d, df_1h, config_name=policy.name) print_backtest_report(result) Simulation().render_plotly( df_sig, trend, result, interval_min=policy.buy_interval, note=f"MTF 정책: 매수 {policy.buy_interval}분 / 확인 {policy.buy_confirm_intervals}", ) def _load_all_frames_or_exit() -> dict[int, pd.DataFrame] | None: """coins.db 전 간격 로드. 부족 시 None.""" from rule_discovery import load_frames monitor = Monitor(cooldown_file=None) frames = load_frames(monitor) if len(frames) < 3: print("coins.db 데이터 부족. python downloader.py 실행 후 재시도.") return None return frames def run_analyze(frames: dict[int, pd.DataFrame] | None = None) -> None: """전 봉 BB·일목 위치 조합 분석.""" from combination_analyzer import analyze_combinations, save_report if frames is None: print("=== 전 봉 BB·일목 조합 분석 ===") frames = _load_all_frames_or_exit() if frames is None: return report = analyze_combinations(frames) save_report(report) def run_discover(frames: dict[int, pd.DataFrame] | None = None): """모든 봉·BB·일목 특징으로 최적 규칙 탐색 후 JSON 저장.""" from rule_discovery import discover_rules, save_rules if frames is None: print("=== 규칙 탐색 (discover) ===") frames = _load_all_frames_or_exit() if frames is None: return None rules = discover_rules(frames) save_rules(rules) print(f"\n저장: discovered_rules.json") return rules def run_full_pipeline() -> None: """ 일반 사용자용 일괄 실행: analyze → discover → HTML. DB 로드는 한 번만 수행합니다. """ print("=" * 60) print("전체 파이프라인: analyze → discover → HTML") print("=" * 60) frames = _load_all_frames_or_exit() if frames is None: return print("\n[1/3] 조합 분석 (analyze)") run_analyze(frames) print("\n[2/3] 규칙 탐색 (discover)") run_discover(frames) print("\n[3/3] 백테스트·HTML 차트 (탐색 규칙 매수·매도)") if rules is None: print("규칙 탐색 실패 — HTML 생략") return Simulation().run_discovered_chart(frames, rules=rules) print("\n완료.") def print_usage() -> None: print( """ DeepCoin simulation.py python simulation.py analyze + discover + HTML (차트 = discovered_rules 매수·매도) (고급) analyze | discover | compare | mtf """ ) def main() -> None: sim = Simulation() if len(sys.argv) > 1 and sys.argv[1] in ("-h", "--help", "help"): print_usage() return if len(sys.argv) == 1 or (len(sys.argv) > 1 and sys.argv[1] in ("all", "chart", "html")): if len(sys.argv) > 1 and sys.argv[1] in ("chart", "html"): print("참고: chart/html 옵션은 제거되었습니다. python simulation.py 만 사용하세요.\n") run_full_pipeline() return if len(sys.argv) > 1 and sys.argv[1] == "analyze": run_analyze() return if len(sys.argv) > 1 and sys.argv[1] == "discover": run_discover() return if len(sys.argv) > 1 and sys.argv[1] == "mtf": run_mtf_analysis() return if len(sys.argv) > 1 and sys.argv[1] == "compare": df_1d, df_1h, df_3m = sim.load_mtf(SYMBOL) run_comparison(df_1d, df_1h, df_3m) return print(f"알 수 없는 옵션: {sys.argv[1]}\n") print_usage() if __name__ == "__main__": main()