#!/usr/bin/env python3 """ us_momentum_web_backtest.py — 해외 모멘텀 웹 백테 (국내 API 와 완전 분리) ※ 국내 ``/api/backtest/momentum`` · ``api_backtest_scalping`` 에 market=US if 를 넣지 말 것. 해외는 이 모듈 + ``/api/backtest/us_momentum`` 만 사용. """ from __future__ import annotations from typing import Any, Dict, List, Optional, Tuple from kis_trader.backtest import scalping_backtest_common as sbc from kis_trader.utils.env import get_env_float def _arg(args: Dict[str, Any], key: str, default: Any = None) -> Any: v = args.get(key, default) if v is None or v == "": return default return v def _f(args: Dict[str, Any], key: str, default: float) -> float: v = _arg(args, key, None) if v is None: return float(default) return float(v) def _i(args: Dict[str, Any], key: str, default: int) -> int: v = _arg(args, key, None) if v is None: return int(default) return int(float(v)) def _b(args: Dict[str, Any], key: str, default: bool = False) -> bool: v = _arg(args, key, None) if v is None: return bool(default) return str(v).strip().lower() in ("1", "true", "t", "y", "yes", "on") def build_us_momentum_engine_params( args: Dict[str, Any], *, ui_def: Dict[str, Any], ) -> Dict[str, Any]: """폼/쿼리 → momentum_engine 파라미터 (US 전용).""" d = ui_def def pct_ratio(key: str, def_ui: float) -> float: """UI % → 엔진 비율.""" return abs(_f(args, key, def_ui)) / 100.0 sl = pct_ratio("sl_pct", float(d.get("sl_pct", 1.5))) tp = pct_ratio("tp_pct", float(d.get("tp_pct", 2.5))) tp_max = pct_ratio("tp_max_pct", float(d.get("tp_max_pct", 2.0))) time_start = _i(args, "time_start", int(d.get("mom_time_start_hm") or d.get("time_start") or 2230)) time_end = _i( args, "time_end", int(d.get("mom_time_end_hm") or d.get("time_end") or 500), ) mom_time_end = _i(args, "mom_time_end", time_end) params: Dict[str, Any] = { "market": "US", "sl_pct": sl, "tp_pct": tp, "tp_max_pct": tp_max, "shoulder_min_high": pct_ratio("shoulder_min_high", float(d.get("shoulder_min_high", 0.5))), "shoulder_cut_pct": pct_ratio("shoulder_cut_pct", float(d.get("shoulder_cut_pct", 0.3))), "trail_pct": pct_ratio("trail_pct", float(d.get("trail_pct", 0.0))), "trail_arm_pct": pct_ratio("trail_arm_pct", float(d.get("trail_arm_pct", 0.0))), "max_hold_bars": _i(args, "max_hold_bars", int(d.get("max_hold_bars") or 0)), "ratchet_tiers": str(_arg(args, "ratchet_tiers", d.get("ratchet_tiers") or "") or "").strip(), "cooldown_min": _f(args, "cooldown_min", float(d.get("cooldown_min") or 5)), "time_start_hm": time_start, "time_end_hm": time_end, "mom_time_end_hm": mom_time_end, "max_daily": _i(args, "max_daily", int(d.get("max_daily") or 5)), "high_chase_thr": _f(args, "high_chase_thr", float(d.get("high_chase_thr") or 0.96)), "max_daily_chg": _f(args, "max_daily_chg", float(d.get("max_daily_chg") or 20)), "min_price": _f(args, "min_price", float(d.get("min_price") or 1)), "max_loss_krw": _f(args, "max_loss_krw", float(d.get("max_loss_krw") or 0)), "min_margin": _f(args, "min_margin", float(d.get("min_margin") or 0.2)), "use_defense_filters": _b(args, "use_defense_filters", bool(d.get("use_defense_filters", True))), "use_high_chase_filter": _b(args, "use_high_chase_filter", False), "use_daily_range_filter": _b(args, "use_daily_range_filter", False), "use_ema_filter": _b(args, "use_ema_filter", bool(d.get("use_ema_filter", True))), "use_rsi_max_filter": _b(args, "use_rsi_max_filter", False), "pattern_breakout": _b(args, "pattern_breakout", True), "pattern_pullback": _b(args, "pattern_pullback", True), "chase_lookback_min": _i(args, "chase_lookback_min", int(d.get("chase_lookback_min") or 10)), "pullback_lookback_min": _i(args, "pullback_lookback_min", int(d.get("pullback_lookback_min") or 15)), "pullback_min_pct": _f(args, "pullback_min_pct", float(d.get("pullback_min_pct") or 0.3)), "pullback_max_pct": _f(args, "pullback_max_pct", float(d.get("pullback_max_pct") or 3.0)), "setup_vol_max_mult": _f(args, "setup_vol_max_mult", float(d.get("setup_vol_max_mult") or 0.8)), "setup_bear_bars_min": _i(args, "setup_bear_bars_min", int(d.get("setup_bear_bars_min") or 1)), "ema_fast_period": _i(args, "ema_fast_period", int(d.get("ema_fast_period") or 9)), "ema_slow_period": _i(args, "ema_slow_period", int(d.get("ema_slow_period") or 21)), "mom_rsi_min": _f(args, "mom_rsi_min", float(d.get("mom_rsi_min") or 50)), "mom_rsi_max": _f(args, "mom_rsi_max", float(d.get("mom_rsi_max") or 90)), "mom_vol_mult": _f(args, "mom_vol_mult", float(d.get("mom_vol_mult") or 1.5)), "mom_vol_win": _i(args, "mom_vol_win", int(d.get("mom_vol_win") or 5)), "mom_max_from_open_pct": _f(args, "mom_max_from_open_pct", float(d.get("mom_max_from_open_pct") or 999)), "mom_min_from_open_pct": _f(args, "mom_min_from_open_pct", float(d.get("mom_min_from_open_pct") or -999)), "eod_enabled": _b(args, "eod_enabled", bool(d.get("eod_enabled", False))), "eod_hm": str(_arg(args, "eod_hm", d.get("eod_hm") or "05:00") or "05:00").strip(), "slot_money": _f(args, "slot_money", float(d.get("slot_money") or 200000)), # US 고정 플래그 "_orderbook_filter_enabled": False, "_program_filter_enabled": False, "_session_wrap_midnight": True, "skip_hts_scan_dupes": True, "fee_rate": 0.0025, "sell_tax": 0.0000206, "fx_fee_rate": 0.0005, "scan_interval_min": 5, "backtest_skip_pre_subscribe": _b(args, "backtest_skip_pre_subscribe", False), } try: from kis_trader.engine.us_momentum_env_keys import us_momentum_trading_cost_rates _c = us_momentum_trading_cost_rates() params["fee_rate"] = float(_c["fee_rate"]) params["sell_tax"] = float(_c["sell_tax"]) params["fx_fee_rate"] = float(_c["fx_fee_rate"]) except Exception: params["fee_rate"] = float(get_env_float("US_MOMENTUM_FEE_RATE", 0.0025)) params["sell_tax"] = float(get_env_float("US_MOMENTUM_SELL_TAX", 0.0000206)) params["fx_fee_rate"] = float(get_env_float("US_MOMENTUM_FX_FEE_RATE", 0.0005)) return params def load_us_candles( db: Any, start: str, end: str, codes_filter: Optional[List[str]] = None, ) -> Tuple[Dict[str, List[Dict]], str, str, List[str]]: """ws_candles.market=US + 영구구독 US 교집합. codes_filter 있으면 해당 종목만.""" start_key = (start.replace("-", "") + "0000") if start else "20260101" end_key = (end.replace("-", "") + "2359") if end else "999912312359" rows = db.conn.execute( "SELECT DISTINCT code FROM ws_candles WHERE timeframe=1 AND market=%s " "AND candle_time >= %s AND candle_time <= %s ORDER BY code", ["US", start_key, end_key], ).fetchall() codes = [r["code"] for r in rows] try: from permanent_subs import codes_by_market as _perm_us us_perm = {str(r.get("code") or "").upper() for r in _perm_us(db, "US")} if us_perm: codes = [c for c in codes if str(c).upper() in us_perm] or codes except Exception: pass if codes_filter: want = {str(c).strip().upper() for c in codes_filter if str(c).strip()} if want: codes = [c for c in codes if str(c).upper() in want] codes_candles: Dict[str, List[Dict]] = {} for code in codes: crow = db.conn.execute( "SELECT candle_time, open, high, low, close, volume " "FROM ws_candles " "WHERE timeframe=1 AND code=%s AND market=%s " "AND candle_time >= %s AND candle_time <= %s " "AND is_confirmed=1 " "ORDER BY candle_time ASC", [code, "US", start_key, end_key], ).fetchall() if not crow: continue codes_candles[str(code)] = [dict(x) for x in crow] return codes_candles, start_key, end_key, list(codes_candles.keys()) def run_us_momentum_web_backtest( args: Dict[str, Any], *, db: Any, ui_def: Dict[str, Any], enrich_trades_fn: Optional[Any] = None, enrich_names_fn: Optional[Any] = None, trades_recent_fn: Optional[Any] = None, period_days_fn: Optional[Any] = None, ) -> Dict[str, Any]: """ 해외 모멘텀 웹 백테 실행 → jsonify 용 dict. enrich_* 는 backtest_web 헬퍼를 주입 (순환 import 방지). """ start = str(_arg(args, "start", "") or "") end = str(_arg(args, "end", "") or "") params = build_us_momentum_engine_params(args, ui_def=ui_def) # 종목 필터: symbol / code / codes (쉼표) filt: List[str] = [] for k in ("symbol", "code", "codes"): raw = _arg(args, k, None) if raw in (None, ""): continue if isinstance(raw, (list, tuple)): filt.extend(str(x).strip() for x in raw if str(x).strip()) else: filt.extend(p.strip() for p in str(raw).split(",") if p.strip()) codes_candles, start_key, end_key, codes = load_us_candles( db, start, end, codes_filter=filt or None, ) # 웹 폼이 소스 오브 트루스. # (구) 단일종목이면 stock_config 로 폼을 덮어써서 Optuna·폼 수정과 백테가 어긋남. # 종목행 → 폼 로드는 UI(usmomOnStockSelect)에서. 실매 오버레이는 전략 엔진 경로. use_stock_cfg = _b(args, "use_stock_cfg", False) or _b(args, "apply_stock_cfg", False) stock_overlay_applied = False if use_stock_cfg and len(codes) == 1: try: from kis_trader.strategies.us_momentum_stock_cfg import ( apply_us_momentum_stock_overlay, ) params = apply_us_momentum_stock_overlay(db, codes[0], params) stock_overlay_applied = bool(params.get("_us_stock_cfg")) except Exception: pass env_row: Dict[str, Any] = {} try: latest = db.get_latest_env() env_row = dict(latest["snapshot"]) if latest else {} except Exception: env_row = {} slot_money_v = float(params.get("slot_money") or 200000) max_stocks_req = _arg(args, "max_stocks", None) if max_stocks_req in (None, ""): max_stocks_req = _arg(args, "slots", None) max_stocks_req_i = ( int(float(max_stocks_req)) if max_stocks_req not in (None, "") else None ) tb_raw = _arg(args, "total_budget_krw", None) total_budget_req = float(tb_raw) if tb_raw not in (None, "") else None portfolio = sbc.resolve_scalp_portfolio_params( env_row, None, strategy="US_MOMENTUM", slot_money=slot_money_v, max_stocks=max_stocks_req_i, total_budget_krw=total_budget_req, ) max_stocks_v = int(portfolio["max_stocks"]) total_budget_v = float(portfolio["total_budget_krw"]) slot_money_v = float(portfolio["slot_money"]) fee_rate_v = float(params.get("fee_rate") or 0.0) sell_tax_v = float(params.get("sell_tax") or 0.0) bt_meta: Dict[str, Any] = { "db": db, "start_key": start_key, "end_key": end_key, "backtest_env_timeline": False, } trades = sbc.run_scalping_backtest_web_aligned( codes_candles, params, None, # permanent US — 슬롯 유니버스 없음 slot_money=slot_money_v, fee_rate=fee_rate_v, sell_tax=sell_tax_v, max_stocks=max_stocks_v, total_budget_krw=total_budget_v, mode="momentum", meta_out=bt_meta, ) period_days = 1 if callable(period_days_fn): period_days = int(period_days_fn(start, end, fallback=1) or 1) stats = sbc.summarize_scalp_trades( trades, total_budget_krw=total_budget_v, period_days=period_days, ) total = int(stats["total_trades"]) # 해외 USD — summarize_trades 의 int(total_pnl) 대신 거래 합산(소수 유지) total_pnl = float(sum(float(t.get("pnl") or 0) for t in trades)) wins_n = int(stats["wins"]) losses_n = int(stats["losses"]) avg_hold = float(stats.get("avg_hold_min") or 0) # summarize_trades 키는 pf (profit_factor 아님) — 국내 모멘텀 웹과 동일 pf = float(stats.get("pf") or stats.get("profit_factor") or 0) bot_pct = float(stats.get("bot_pct") or 0) daily_avg_pct = float(stats.get("daily_avg_pct") or 0) equity = [] peak, mdd, cum = 0.0, 0.0, 0.0 peak_cum_at = "" for t in sorted(trades, key=lambda x: str(x.get("sell_time") or "")): cum += float(t.get("pnl") or 0) if cum > peak: peak = cum peak_cum_at = str(t.get("sell_time") or "") dd = peak - cum if dd > mdd: mdd = dd st = str(t.get("sell_time") or "") day = st[:8] if len(day) == 8: day_fmt = f"{day[:4]}-{day[4:6]}-{day[6:]}" else: day_fmt = day equity.append({"date": day_fmt, "cum_pnl": round(cum, 2), "pnl": float(t.get("pnl") or 0)}) reasons: Dict[str, int] = {} for t in trades: rk = str(t.get("sell_reason") or "unknown") reasons[rk] = reasons.get(rk, 0) + 1 daily: Dict[str, float] = {} for t in trades: d8 = str(t.get("sell_time") or "")[:8] if len(d8) == 8: daily[d8] = daily.get(d8, 0.0) + float(t.get("pnl") or 0) daily_list = [ {"date": f"{d[:4]}-{d[4:6]}-{d[6:]}", "pnl": round(v, 2)} for d, v in sorted(daily.items()) ] if callable(enrich_trades_fn): enrich_trades_fn(trades, total_budget_krw=total_budget_v, with_tick_debug=False) trades_out = trades if callable(trades_recent_fn): trades_out = trades_recent_fn(trades, 200) if callable(enrich_names_fn): enrich_names_fn(db, trades_out) ts = int(params["time_start_hm"]) te = int(params["mom_time_end_hm"]) out = { "params": { "market": "US", "sl_pct": params["sl_pct"] * 100, "tp_pct": params["tp_pct"] * 100, "tp_max_pct": params["tp_max_pct"] * 100, "slot_money": slot_money_v, "max_stocks": max_stocks_v, "total_budget_krw": total_budget_v, "cooldown_min": params["cooldown_min"], "time_window": f"{ts:04d}-{te:04d}", "time_start_hm": ts, "time_end_hm": te, "mom_time_end": te, "mom_time_end_hm": te, "mom_rsi_min": params["mom_rsi_min"], "mom_rsi_max": params["mom_rsi_max"], "mom_vol_mult": params["mom_vol_mult"], "mom_vol_win": params["mom_vol_win"], "max_hold_bars": params["max_hold_bars"], "ratchet_tiers": params["ratchet_tiers"], "trail_pct": params["trail_pct"] * 100, "trail_arm_pct": params["trail_arm_pct"] * 100, "shoulder_min_high": params["shoulder_min_high"] * 100, "shoulder_cut_pct": params["shoulder_cut_pct"] * 100, "codes_analyzed": len(codes), "universe_source": "permanent_us", "universe_history_slots": 0, "universe": "all", "strategy_id": "US_MOMENTUM", "mode": "momentum", "start": start, "end": end, "stock_overlay": stock_overlay_applied, "exit_priority": "ratchet/shoulder→trail→sl→time→loss_cap→tp_max→eod", }, "summary": { "total_trades": total, "win_trades": wins_n, "loss_trades": losses_n, "win_rate": float(stats["win_rate"]), "total_pnl": total_pnl, "avg_hold_min": round(avg_hold, 1), "profit_factor": round(pf, 2), "max_drawdown": round(float(mdd), 2), "peak_cum_pnl": round(float(peak), 2), "peak_cum_at": peak_cum_at[:19] if peak_cum_at else "", "bot_pct": bot_pct, "daily_avg_pct": daily_avg_pct, "backtest_days": period_days, }, "equity": equity, "daily": daily_list, "reasons": reasons, "trades": trades_out, } # 표시용: USD 금액 + 청산일(또는 매수일) 환율로 원 환산 try: from kis_trader.utils.usd_krw_fx import enrich_payload_with_usd_krw_fx enrich_payload_with_usd_krw_fx(out) except Exception: out["currency"] = "USD" return out