""" WLD 3분 BB 시뮬레이션. 기본: 하단 상향 돌파 매수, 상단 상향 돌파 매도. 수수료 반영, 레짐/필터 조합 비교 지원. python simulation_1h.py # discovered_rules HTML 차트 (기본) python simulation_1h.py discover # 모든 봉 특징 탐색 → discovered_rules.json python simulation_1h.py compare # 9종 조합 수익률 순위 python simulation_1h.py mtf # 봉별 BB 비교 + MTF 시뮬 """ from __future__ import annotations import sys from dataclasses import dataclass import pandas as pd import plotly.graph_objs as go import plotly.io as pio from datetime import datetime from plotly import subplots pio.renderers.default = "browser" 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 @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_3m: pd.DataFrame, trend: str, result: SimResult) -> None: cfg = strategy.ACTIVE_CONFIG.name summary = ( f"[{cfg}] 시작 {result.initial_cash:,.0f} | 최종 {result.final_asset:,.0f} | " f"{result.total_return_pct:+.2f}% | 수수료 {result.total_fees:,.0f}" ) fig = subplots.make_subplots( rows=3, cols=1, subplot_titles=( f"{COIN_NAME} 3분 BB — {trend}", "RSI / BB폭(%)", summary, ), shared_xaxes=False, vertical_spacing=0.06, row_heights=[0.5, 0.18, 0.32], specs=[[{"type": "xy"}], [{"type": "xy"}], [{"type": "table"}]], ) fig.add_trace( go.Candlestick( x=df_3m.index, open=df_3m["Open"], high=df_3m["High"], low=df_3m["Low"], close=df_3m["Close"], name="캔들", showlegend=False, ), row=1, col=1, ) for col, color in [("MA", "blue"), ("Upper", "gray"), ("Lower", "gray")]: if col in df_3m.columns: fig.add_trace( go.Scatter( x=df_3m.index, y=df_3m[col], name=col, line=dict(color=color, dash="dot" if col != "MA" else "solid"), showlegend=False, ), row=1, col=1, ) buy_trades = [t for t in result.trades if t.action == "매수"] sell_trades = [t for t in result.trades if t.action == "매도"] fig.add_trace( go.Scatter( x=[t.dt for t in buy_trades], y=[t.price for t in buy_trades], mode="markers", name="매수", legendgroup="trades", showlegend=True, marker=dict( color="#22c55e", size=11, symbol="triangle-up", line=dict(width=1, color="#166534"), ), ), row=1, col=1, ) fig.add_trace( go.Scatter( x=[t.dt for t in sell_trades], y=[t.price for t in sell_trades], mode="markers", name="매도", legendgroup="trades", showlegend=True, marker=dict( color="#ef4444", size=11, symbol="triangle-down", line=dict(width=1, color="#991b1b"), ), ), row=1, col=1, ) if "RSI" in df_3m.columns: fig.add_trace( go.Scatter( x=df_3m.index, y=df_3m["RSI"], name="RSI", showlegend=False, ), row=2, col=1, ) if "BB_Width" in df_3m.columns: fig.add_trace( go.Scatter( x=df_3m.index, y=df_3m["BB_Width"], name="BB폭%", showlegend=False, ), row=2, col=1, ) if result.trades: cells = [ [t.dt.strftime("%Y-%m-%d %H:%M") for t in result.trades], [t.action for t in result.trades], [t.signal for t in result.trades], [f"{t.price:,.2f}" for t in result.trades], [f"{t.krw:,.0f}" for t in result.trades], [f"{t.fee:,.0f}" for t in result.trades], [f"{t.pnl:+,.0f}" if t.pnl is not None else "-" for t in result.trades], [f"{t.total_asset:,.0f}" for t in result.trades], ] else: cells = [["-"] * 8] fig.add_trace( go.Table( header=dict( values=[ "일시", "구분", "신호", "가격", "금액", "수수료", "수익", "총자산", ], fill_color="#e8e8e8", ), cells=dict(values=cells), ), row=3, col=1, ) fig.update_layout( height=1100, title=f"{SYMBOL} BB 타이밍 시뮬 (범례 클릭: 매수/매도 표시 토글)", margin=dict(l=50, r=140, t=80, b=40), dragmode="zoom", legend=dict( orientation="v", yanchor="top", y=0.99, xanchor="left", x=1.01, bgcolor="rgba(255,255,255,0.9)", bordercolor="#cccccc", borderwidth=1, font=dict(size=12), title=dict(text="체결 (클릭 토글)", side="top"), itemclick="toggle", itemdoubleclick="toggleothers", ), ) # Y축 고정·rangeslider 해제 → 세로 드래그/박스줌·휠 줌 가능 fig.update_xaxes( rangeslider_visible=False, fixedrange=False, row=1, col=1, ) fig.update_xaxes(fixedrange=False, row=2, col=1) fig.update_yaxes( title_text="가격 (KRW)", fixedrange=False, scaleanchor=None, scaleratio=None, row=1, col=1, ) fig.update_yaxes( fixedrange=False, scaleanchor=None, scaleratio=None, row=2, col=1, ) fig.show( config={ "scrollZoom": True, "displaylogo": False, "doubleClick": "reset", "modeBarButtonsToAdd": ["zoom2d", "pan2d", "resetScale2d"], } ) 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 run(self, config: strategy.StrategyConfig | None = None) -> SimResult: """기본 BB vs 탐색 규칙 중 수익률·신호가 있는 쪽을 HTML에 표시.""" df_1d, df_1h, df_3m = self.load_mtf(SYMBOL) 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)}봉)") cfg_base = strategy.StrategyConfig( name="01_기본_BB만", use_discovered_rules=False, use_regime_switch=False, use_rsi_filter=False, use_volume_filter=False, use_squeeze_filter=False, use_stop_loss=False, ) df_base, res_base, n_base = self._run_one_strategy( cfg_base.name, df_1d, df_1h, df_3m, cfg_base ) print(f"\n[기본 BB] 신호 {n_base} | 수익 {res_base.total_return_pct:+.2f}% | 거래 {res_base.trade_count}") candidates: list[tuple[str, pd.DataFrame, SimResult, int]] = [ (cfg_base.name, df_base, res_base, n_base), ] try: from rule_discovery import load_rules rules = load_rules() frames = self.load_all_frames() if rules and frames: cfg_disc = strategy.StrategyConfig( name=rules.name, use_discovered_rules=True, use_regime_switch=False, use_rsi_filter=False, use_volume_filter=False, use_squeeze_filter=False, use_stop_loss=False, ) df_disc, res_disc, n_disc = self._run_one_strategy( cfg_disc.name, df_1d, df_1h, df_3m, cfg_disc, frames=frames ) print( f"[탐색 규칙] 신호 {n_disc} | 수익 {res_disc.total_return_pct:+.2f}% " f"| 거래 {res_disc.trade_count}" ) print(f" 매수: {rules.buy_all} | OR: {rules.buy_any}") print(f" 매도: {rules.sell_all} | 손절: {rules.sell_stop}") if n_disc > 0 and res_disc.trade_count > 0: candidates.append((cfg_disc.name, df_disc, res_disc, n_disc)) except Exception as e: print(f"[탐색 규칙] 스킵: {e}") # 신호·거래 있는 후보 중 수익률 최대 valid = [c for c in candidates if c[3] > 0 and c[2].trade_count > 0] if not valid: valid = candidates name, df_plot, result, n_sig = max(valid, key=lambda c: c[2].total_return_pct) print(f"\n>>> HTML 적용: {name} (신호 {n_sig}, 거래 {result.trade_count}, {result.total_return_pct:+.2f}%)") sigs = df_plot[df_plot["point"] == 1] if len(sigs): print(sigs["action"].value_counts().to_string()) print_backtest_report(result) self.render_plotly(df_plot, trend, result) 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) def run_discover() -> None: """모든 봉·캔들 특징으로 최적 규칙 탐색 후 JSON 저장.""" from rule_discovery import discover_rules, load_frames, save_rules monitor = Monitor(cooldown_file=None) frames = load_frames(monitor) rules = discover_rules(frames) save_rules(rules) print(f"\n저장: discovered_rules.json") print("HTML 차트: python simulation_1h.py") def main() -> None: sim = Simulation() 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 df_1d, df_1h, df_3m = sim.load_mtf(SYMBOL) if len(sys.argv) > 1 and sys.argv[1] == "compare": run_comparison(df_1d, df_1h, df_3m) return sim.run() if __name__ == "__main__": main()