Files
kis_bot/kis_trader/backtest/us_momentum_web_backtest.py
2026-07-30 18:05:07 +09:00

401 lines
16 KiB
Python

#!/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