Files
kis_bot/kis_trader/backtest/momentum_rr_crossval.py
Your Name fc27e726f9 feat: 새로운 안전 규칙 및 최적화 적용을 통한 트레이딩 시스템 개선
변경 사항 (Changes):

구문 오류(Syntax error) 및 토큰 낭비를 방지하기 위해 에이전트 쉘(Agent shell)과 파이썬 코드 스니펫에 다수의 신규 안전 규칙(Safety rules)을 추가함.

스키마 검증 및 적절한 SQL 포맷팅을 보장하기 위해 임시(Ad-hoc) 데이터베이스 쿼리 작성 가이드라인을 도입함.

코드 수정 후 UI 기능이 정상 작동하는지 확인하기 위해, 백테스트 웹 서비스 재시작 및 브라우저 검증에 대한 새로운 규칙을 구현함.

시스템 전반의 무결성(Integrity)을 유지하기 위해 실전 매매(Live trading), 웹 백테스팅, 파라미터 탐색(Parameter searches) 간의 일관성 검사(Consistency checks) 체계를 확립함.

기대 효과 (Impact):

이러한 개선 사항들은 트레이딩 시스템의 견고성(Robustness)과 신뢰성을 향상시키며, 에러 발생을 최소화하고 다양한 시스템 컴포넌트 간의 원활한 상호작용을 보장함.
2026-07-17 01:09:09 +09:00

467 lines
17 KiB
Python

#!/usr/bin/env python3
"""
momentum_rr_crossval.py — 모멘텀 손익비(어깨·손절·익절) 스윕 → 파라서치 → 교차검증
1) 고정 진입(RSI·거래량=현 운영값) + 청산 축만 여러 프리셋 스윕 (학습 구간)
2) 1위 R:R 프리셋을 base 로 param_search (mode=rr) 실행
3) 탐색 상위 N개를 검증 구간(기본 2026-06-01)에서 재평가
실행:
cd /home/hoon/kis_bot
python3 kis_trader/backtest/momentum_rr_crossval.py
python3 kis_trader/backtest/momentum_rr_crossval.py --train-start 2026-05-11 --train-end 2026-05-30 \\
--oos-start 2026-06-01 --oos-end 2026-06-01 --search-top 5 --skip-search
"""
from __future__ import annotations
import argparse
import json
import os
import sys
import time
from datetime import datetime
from itertools import product
from typing import Any, Dict, List, Optional, Tuple
HERE = os.path.dirname(os.path.abspath(__file__))
ROOT = os.path.dirname(os.path.dirname(HERE))
if ROOT not in sys.path:
sys.path.insert(0, ROOT)
import logging
logging.getLogger("TradeDB").setLevel(logging.WARNING)
from database import TradeDB
from kis_trader.backtest import scalping_backtest_common as sbc
from kis_trader.backtest.backtest_portfolio_common import load_portfolio_env_row
from kis_trader.backtest.param_search_momentum import (
_evaluate_momentum_chunk,
_load_candles_for_search,
_mom_fixed_defaults,
_momentum_grids,
_ui_to_engine_params,
run_search,
)
from kis_trader.backtest.param_search_cli_common import apply_session_to_fixed
from kis_trader.engine import momentum_engine as me
from kis_trader.utils.env import get_env_float, get_env_from_db, get_env_int
def _results_dir() -> str:
d = os.path.join(HERE, "results")
os.makedirs(d, exist_ok=True)
return d
def _load_universe(start: str, end: str) -> Tuple[Optional[Dict[str, List[str]]], str]:
start_ymd = start.replace("-", "")
end_ymd = end.replace("-", "")
universe, source, _, _ = sbc.resolve_scalp_universe(
start_ymd, end_ymd, use_saved_history=True, strategy_id="MOMENTUM",
)
if universe:
return universe, source
start_key = start_ymd + "0000"
end_key = end_ymd + "2359"
db = TradeDB()
try:
from kis_trader.backtest import momentum_backtest_common as mbc
candles, _ = mbc.load_momentum_candles_by_code(db, start_key, end_key)
finally:
db.close()
sim = me.build_universe_simulation_momentum(candles)
return sim, "sim"
def _base_entry_ui(fixed: Dict[str, Any]) -> Dict[str, Any]:
"""현 운영 진입 축 고정 — 청산(R:R)만 스윕."""
return {
"mom_rsi_min": int(get_env_int("MOMENTUM_RSI_MIN", 55)),
"mom_rsi_max": int(get_env_int("MOMENTUM_RSI_MAX", 90)),
"mom_vol_mult": float(get_env_float("MOMENTUM_VOL_MULT", 1.5)),
"mom_vol_win": float(fixed.get("mom_vol_win", 5)),
"mom_time_end_hm": int(fixed.get("mom_time_end_hm", 1430)),
"mom_max_from_open_pct": float(get_env_float("MOMENTUM_MAX_FROM_OPEN_PCT", 30.0)),
"cooldown_min": float(fixed.get("cooldown_min", 1)),
"max_daily": float(fixed.get("max_daily", 10)),
"fee_rate": float(fixed.get("fee_rate", 0.015)),
"sell_tax": float(fixed.get("sell_tax", 0.18)),
"time_start_hm": int(fixed.get("time_start_hm", 830)),
"time_end_hm": int(fixed.get("time_end_hm", 1530)),
"min_price": int(fixed.get("min_price", 1000)),
"max_loss_krw": float(fixed.get("max_loss_krw", 200_000)),
"min_margin": float(fixed.get("min_margin", 0.2)),
"use_defense_filters": bool(fixed.get("use_defense_filters", True)),
"slot_money": float(get_env_float("MOMENTUM_SLOT_MONEY", 200_000)),
}
def _rr_presets() -> List[Dict[str, Any]]:
"""
청산 프리셋 (UI % 단위).
shoulder_min_high >= 50 → 사실상 어깨 OFF (일반 장중 +50% 미도달).
"""
raw = str(get_env_from_db("MOMENTUM_RR_PRESET_GRID_JSON", "") or "").strip()
if raw:
try:
data = json.loads(raw)
if isinstance(data, list) and data:
return data
except json.JSONDecodeError:
pass
return [
{
"name": "baseline_live",
"sl_pct": 2.0, "tp_pct": 2.0, "tp_max_pct": 1.8,
"shoulder_min_high": 0.5, "shoulder_cut_pct": 0.02,
},
{
"name": "shoulder_off_tp25",
"sl_pct": 2.0, "tp_pct": 2.5, "tp_max_pct": 2.5,
"shoulder_min_high": 50.0, "shoulder_cut_pct": 0.3,
},
{
"name": "wide_shoulder_sl15",
"sl_pct": 1.5, "tp_pct": 2.5, "tp_max_pct": 2.5,
"shoulder_min_high": 1.0, "shoulder_cut_pct": 0.25,
},
{
"name": "delayed_shoulder_sl12",
"sl_pct": 1.2, "tp_pct": 3.0, "tp_max_pct": 3.0,
"shoulder_min_high": 1.2, "shoulder_cut_pct": 0.30,
},
{
"name": "tp_only_sl15",
"sl_pct": 1.5, "tp_pct": 2.0, "tp_max_pct": 2.0,
"shoulder_min_high": 50.0, "shoulder_cut_pct": 0.2,
},
{
"name": "loose_shoulder_sl18",
"sl_pct": 1.8, "tp_pct": 2.2, "tp_max_pct": 2.2,
"shoulder_min_high": 0.8, "shoulder_cut_pct": 0.20,
},
]
def _run_backtest_period(
candles: Dict[str, List[Dict]],
universe: Optional[Dict[str, List[str]]],
ui_params: Dict[str, Any],
*,
slot_money: float,
max_stocks: int,
total_budget: float,
fee_rate: float,
sell_tax: float,
) -> Dict[str, Any]:
engine = _ui_to_engine_params(ui_params)
engine["slot_money"] = float(slot_money)
meta: Dict[str, Any] = {}
trades = sbc.run_scalping_backtest_web_aligned(
candles,
engine,
universe,
slot_money=slot_money,
fee_rate=fee_rate,
sell_tax=sell_tax,
max_stocks=max_stocks,
total_budget_krw=total_budget,
meta_out=meta,
mode="momentum",
)
stats = sbc.summarize_trades(trades, total_budget_krw=total_budget)
by_reason: Dict[str, Dict[str, float]] = {}
for t in trades:
r = str(t.get("sell_reason") or t.get("reason") or "?").split("(")[0].strip()
pnl = float(t.get("pnl") or t.get("realized_pnl") or 0)
bucket = by_reason.setdefault(r, {"n": 0, "pnl": 0.0})
bucket["n"] += 1
bucket["pnl"] += pnl
wins = [t for t in trades if float(t.get("pnl") or t.get("realized_pnl") or 0) > 0]
losses = [t for t in trades if float(t.get("pnl") or t.get("realized_pnl") or 0) <= 0]
avg_win = (
sum(float(t.get("pnl") or t.get("realized_pnl") or 0) for t in wins) / len(wins)
if wins else 0.0
)
avg_loss = (
sum(float(t.get("pnl") or t.get("realized_pnl") or 0) for t in losses) / len(losses)
if losses else 0.0
)
return {
"trades": len(trades),
"total_pnl": round(stats.get("total_pnl", 0)),
"win_rate": round(stats.get("win_rate", 0), 2),
"pf": round(float(stats.get("pf") or 0), 3),
"bot_pct": round(stats.get("bot_pct", 0), 3),
"avg_win": round(avg_win),
"avg_loss": round(avg_loss),
"by_reason": by_reason,
"params_ui": ui_params,
}
def phase_rr_sweep(
train_start: str,
train_end: str,
*,
slot_money: float,
max_stocks: int,
total_budget: float,
) -> Tuple[List[Dict[str, Any]], Dict[str, Any]]:
fixed = _mom_fixed_defaults()
apply_session_to_fixed(fixed, time_start_hm=830, time_end_hm=1530)
entry_base = _base_entry_ui(fixed)
print("\n" + "=" * 70)
print(f"[Phase 1] R:R 프리셋 스윕 | 학습 {train_start} ~ {train_end}")
print("=" * 70)
candles = _load_candles_for_search(train_start, train_end, int(fixed["rsi_period"]))
universe, src = _load_universe(train_start, train_end)
print(f" 캔들 종목 {len(candles)} | 유니버스={src} 슬롯={len(universe or {})}")
env_row = load_portfolio_env_row()
fee_rate, sell_tax, _ = sbc.fee_and_slot_from_env(env_row, strategy="MOMENTUM")
results: List[Dict[str, Any]] = []
for preset in _rr_presets():
ui = dict(entry_base)
ui.update({k: v for k, v in preset.items() if k != "name"})
ui["preset_name"] = preset.get("name", "unnamed")
row = _run_backtest_period(
candles, universe, ui,
slot_money=slot_money, max_stocks=max_stocks, total_budget=total_budget,
fee_rate=fee_rate, sell_tax=sell_tax,
)
row["preset_name"] = ui["preset_name"]
results.append(row)
print(
f" {ui['preset_name']:<22} "
f"trades={row['trades']:3d} wr={row['win_rate']:5.1f}% "
f"pnl={row['total_pnl']:>10,} pf={row['pf']:.2f} "
f"avgW={row['avg_win']:>6,} avgL={row['avg_loss']:>7,}"
)
results.sort(key=lambda x: (x["total_pnl"], x["pf"]), reverse=True)
best = results[0] if results else {}
print(f"\n ▶ 학습 1위 프리셋: {best.get('preset_name')} pnl={best.get('total_pnl'):,}")
return results, best
def phase_crossval_presets(
presets_results: List[Dict[str, Any]],
oos_start: str,
oos_end: str,
top_k: int,
*,
slot_money: float,
max_stocks: int,
total_budget: float,
) -> List[Dict[str, Any]]:
print("\n" + "=" * 70)
print(f"[Phase 1b] R:R 프리셋 교차검증 (OOS) {oos_start} ~ {oos_end}")
print("=" * 70)
fixed = _mom_fixed_defaults()
entry_base = _base_entry_ui(fixed)
candles = _load_candles_for_search(oos_start, oos_end, int(fixed["rsi_period"]))
universe, src = _load_universe(oos_start, oos_end)
print(f" 캔들 종목 {len(candles)} | 유니버스={src}")
env_row = load_portfolio_env_row()
fee_rate, sell_tax, _ = sbc.fee_and_slot_from_env(env_row, strategy="MOMENTUM")
oos_rows: List[Dict[str, Any]] = []
for train_row in presets_results[:top_k]:
ui = dict(entry_base)
p = train_row.get("params_ui") or {}
for k in ("sl_pct", "tp_pct", "tp_max_pct", "shoulder_min_high", "shoulder_cut_pct"):
if k in p:
ui[k] = p[k]
ui["preset_name"] = train_row.get("preset_name", "?")
row = _run_backtest_period(
candles, universe, ui,
slot_money=slot_money, max_stocks=max_stocks, total_budget=total_budget,
fee_rate=fee_rate, sell_tax=sell_tax,
)
row["preset_name"] = ui["preset_name"]
row["train_pnl"] = train_row.get("total_pnl")
oos_rows.append(row)
print(
f" {ui['preset_name']:<22} train={train_row.get('total_pnl'):>9,} "
f"oos={row['total_pnl']:>9,} wr={row['win_rate']:.1f}% pf={row['pf']:.2f}"
)
return oos_rows
def _apply_rr_base_to_fixed(fixed: Dict[str, Any], best_ui: Dict[str, Any]) -> None:
for k in ("sl_pct", "tp_pct", "tp_max_pct", "shoulder_min_high", "shoulder_cut_pct"):
if k in best_ui:
fixed[k] = best_ui[k]
def phase_param_search(
train_start: str,
train_end: str,
mode: str,
best_rr_ui: Dict[str, Any],
*,
slot_money: float,
max_stocks: int,
total_budget: float,
top_n: int,
) -> Optional[str]:
print("\n" + "=" * 70)
print(f"[Phase 2] 파라서치 mode={mode} (R:R 1위 청산축 고정 폴백 반영)")
print("=" * 70)
os.environ["MOMENTUM_RR_SEARCH_BASE_JSON"] = json.dumps({
k: best_rr_ui[k]
for k in ("sl_pct", "tp_pct", "tp_max_pct", "shoulder_min_high", "shoulder_cut_pct")
if k in best_rr_ui
})
run_search(
train_start, train_end, mode, top_n,
min_trades=5,
min_win_rate=0.0,
apply_rank=None,
use_fallback_universe=False,
slot_money=slot_money,
max_stocks=max_stocks,
total_budget_krw=total_budget,
time_start_hm=830,
time_end_hm=1530,
)
from kis_trader.backtest.param_search_momentum import _latest_json
return _latest_json("search_momentum_")
def phase_crossval_search_json(
json_path: Optional[str],
oos_start: str,
oos_end: str,
top_k: int,
*,
slot_money: float,
max_stocks: int,
total_budget: float,
) -> List[Dict[str, Any]]:
from kis_trader.backtest.param_search_momentum import _latest_json
path = json_path or _latest_json("search_momentum_")
if not path or not os.path.isfile(path):
print(" ⚠️ search_momentum JSON 없음 — Phase 3 스킵")
return []
print("\n" + "=" * 70)
print(f"[Phase 3] 파라서치 상위 {top_k} → OOS {oos_start}~{oos_end}")
print(f" JSON: {path}")
print("=" * 70)
with open(path, "r", encoding="utf-8") as f:
doc = json.load(f)
ranked = doc.get("top") or doc.get("ranked") or doc.get("results") or []
if not ranked:
print(" top/ranked 비어 있음")
return []
fixed = _mom_fixed_defaults()
apply_session_to_fixed(fixed, time_start_hm=830, time_end_hm=1530)
candles = _load_candles_for_search(oos_start, oos_end, int(fixed["rsi_period"]))
universe, _ = _load_universe(oos_start, oos_end)
env_row = load_portfolio_env_row()
fee_rate, sell_tax, _ = sbc.fee_and_slot_from_env(env_row, strategy="MOMENTUM")
oos_rows: List[Dict[str, Any]] = []
for i, item in enumerate(ranked[:top_k], 1):
merged = dict(item.get("merged_params") or item.get("params") or {})
ui = {**fixed, **merged}
row = _run_backtest_period(
candles, universe, ui,
slot_money=slot_money, max_stocks=max_stocks, total_budget=total_budget,
fee_rate=fee_rate, sell_tax=sell_tax,
)
row["rank"] = i
row["train_pnl"] = item.get("total_pnl")
row["train_pf"] = item.get("pf")
oos_rows.append(row)
print(
f" #{i} train_pnl={item.get('total_pnl'):>9,} "
f"oos={row['total_pnl']:>9,} trades={row['trades']} wr={row['win_rate']:.1f}% "
f"sl={merged.get('sl_pct')} tp={merged.get('tp_pct')} "
f"sh={merged.get('shoulder_min_high')}/{merged.get('shoulder_cut_pct')}"
)
return oos_rows
def main() -> None:
ap = argparse.ArgumentParser(description="모멘텀 R:R 스윕 + 파라서치 + 교차검증")
ap.add_argument("--train-start", default="2026-05-11")
ap.add_argument("--train-end", default="2026-05-30")
ap.add_argument("--oos-start", default="2026-06-01")
ap.add_argument("--oos-end", default="2026-06-01")
ap.add_argument("--slot-money", type=float, default=None)
ap.add_argument("--max-stocks", type=int, default=20)
ap.add_argument("--total-budget", type=float, default=2_000_000)
ap.add_argument("--search-mode", default="rr", choices=["rr", "fast", "coarse"])
ap.add_argument("--search-top", type=int, default=30)
ap.add_argument("--cross-top", type=int, default=5)
ap.add_argument("--skip-search", action="store_true")
ap.add_argument("--skip-sweep", action="store_true")
args = ap.parse_args()
slot = float(args.slot_money or get_env_float("MOMENTUM_SLOT_MONEY", 200_000))
t0 = time.time()
report: Dict[str, Any] = {
"generated_at": datetime.now().isoformat(timespec="seconds"),
"train": [args.train_start, args.train_end],
"oos": [args.oos_start, args.oos_end],
"portfolio": {
"slot_money": slot,
"max_stocks": args.max_stocks,
"total_budget_krw": args.total_budget,
},
}
sweep_results: List[Dict[str, Any]] = []
best_rr: Dict[str, Any] = {}
if not args.skip_sweep:
sweep_results, best_rr = phase_rr_sweep(
args.train_start, args.train_end,
slot_money=slot, max_stocks=args.max_stocks, total_budget=args.total_budget,
)
report["rr_sweep_train"] = sweep_results
report["rr_sweep_oos"] = phase_crossval_presets(
sweep_results, args.oos_start, args.oos_end, args.cross_top,
slot_money=slot, max_stocks=args.max_stocks, total_budget=args.total_budget,
)
best_ui = (best_rr.get("params_ui") or {}) if best_rr else {}
json_path: Optional[str] = None
if not args.skip_search and best_ui:
json_path = phase_param_search(
args.train_start, args.train_end, args.search_mode, best_ui,
slot_money=slot, max_stocks=args.max_stocks, total_budget=args.total_budget,
top_n=args.search_top,
)
report["search_json"] = json_path
report["search_oos"] = phase_crossval_search_json(
json_path, args.oos_start, args.oos_end, args.cross_top,
slot_money=slot, max_stocks=args.max_stocks, total_budget=args.total_budget,
)
out_path = os.path.join(
_results_dir(),
f"momentum_rr_crossval_{datetime.now().strftime('%Y%m%d_%H%M%S')}.json",
)
with open(out_path, "w", encoding="utf-8") as f:
json.dump(report, f, ensure_ascii=False, indent=2)
print(f"\n✅ 리포트 저장: {out_path}")
print(f"⏱ 총 {time.time() - t0:.0f}")
if __name__ == "__main__":
main()