변경 사항 (Changes): 구문 오류(Syntax error) 및 토큰 낭비를 방지하기 위해 에이전트 쉘(Agent shell)과 파이썬 코드 스니펫에 다수의 신규 안전 규칙(Safety rules)을 추가함. 스키마 검증 및 적절한 SQL 포맷팅을 보장하기 위해 임시(Ad-hoc) 데이터베이스 쿼리 작성 가이드라인을 도입함. 코드 수정 후 UI 기능이 정상 작동하는지 확인하기 위해, 백테스트 웹 서비스 재시작 및 브라우저 검증에 대한 새로운 규칙을 구현함. 시스템 전반의 무결성(Integrity)을 유지하기 위해 실전 매매(Live trading), 웹 백테스팅, 파라미터 탐색(Parameter searches) 간의 일관성 검사(Consistency checks) 체계를 확립함. 기대 효과 (Impact): 이러한 개선 사항들은 트레이딩 시스템의 견고성(Robustness)과 신뢰성을 향상시키며, 에러 발생을 최소화하고 다양한 시스템 컴포넌트 간의 원활한 상호작용을 보장함.
467 lines
17 KiB
Python
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()
|