"""파라미터 스윕 — 여러 튜닝을 백테스트로 동시 비교 → 순위표. 과최적화 방어: 기간을 앞(학습)/뒤(검증)로 쪼개 둘 다 측정. - 학습기간 성적만 높고 검증기간 무너지면 = 과최적화 의심. - 검증기간(out-of-sample) 성적으로 순위. 결과: state/sim/backtest_results.json (웹 백테스트 탭이 읽음). """ from __future__ import annotations import itertools import json from datetime import datetime from . import backtest, config, universe # 백테스트에서 실제 영향 있는 파라미터만 스윕 (애널 게이트·시장필터는 backtest 중립이라 제외). # 기존 5 + 신규 4(RSI·시간손절·리스크) 탐색. 조합폭발 억제 위해 기존 그리드는 핵심값으로 축소. # base 3×3×2×2×2 = 72 × 신규 2×2×2×2 = 16 → 1152 조합. GRID = { 'SMA_LONG': [10, 20, 40], # 추세 속도 — 빠른/느린 (다양성 핵심) 'RR_RATIO': [1.5, 2.0, 3.0], # 손익비 'STOP_ATR_MULT': [1.5, 2.0], # 손절 폭 'PULLBACK_ATR_MULT': [1.0, 1.5], # 눌림 깊이 'VOLUME_BREAKOUT_MULT': [1.3, 2.0], # 돌파 거래량 강도 'RSI_OVERBOUGHT': [75, 88], # 돌파 과열 컷 (엄격/느슨) 'RSI_OVERSOLD': [20, 35], # 눌림목 과매도 컷 (느슨/엄격) 'MAX_HOLD_DAYS': [0, 20], # 시간손절 끔/20일 'RISK_PER_TRADE_PCT': [0.01, 0.02], # 종목당 리스크 1%/2% } MIN_TRADES = 10 # 검증기간 거래 이 미만이면 표본 부족 → 하위로 def _all_dates(codes: list[str]) -> list[str]: s = set() for code in codes: for c in backtest.load_history(code): s.add(c['date']) return sorted(s) def run_sweep(codes: list[str] | None = None, train_frac: float = 0.7, grid: dict | None = None) -> dict: codes = codes or [e['code'] for e in universe.build_universe()] grid = grid or GRID dates = _all_dates(codes) if len(dates) < 60: return {'error': 'insufficient_history', 'days': len(dates)} split = int(len(dates) * train_frac) split_date = dates[split] train_to, test_from, test_to = dates[split - 1], dates[split], dates[-1] train_from = dates[0] keys = list(grid.keys()) combos = list(itertools.product(*[grid[k] for k in keys])) results = [] for combo in combos: params = dict(zip(keys, combo)) tr = backtest.run(params, codes, date_from=train_from, date_to=train_to) te = backtest.run(params, codes, date_from=test_from, date_to=test_to) results.append({'params': params, 'train': tr, 'test': te}) def sort_key(r): te = r['test'] enough = (te.get('trades', 0) or 0) >= MIN_TRADES return (1 if enough else 0, te.get('total_return_pct', -999) or -999) results.sort(key=sort_key, reverse=True) out = { 'generated_at': datetime.now(config.KST).isoformat(), 'universe_size': len(codes), 'swept_params': keys, 'grid': grid, 'split': {'train': f'{train_from}~{train_to}', 'test': f'{test_from}~{test_to}', 'train_frac': train_frac}, 'min_trades': MIN_TRADES, 'flow_used': results[0]['test'].get('flow_used', False) if results else False, 'count': len(results), 'results': results, } config.STATE_DIR.mkdir(parents=True, exist_ok=True) (config.STATE_DIR / 'backtest_results.json').write_text(json.dumps(out, ensure_ascii=False, indent=2)) return out if __name__ == '__main__': import sys frac = float(sys.argv[1]) if len(sys.argv) > 1 else 0.7 res = run_sweep(train_frac=frac) if res.get('error'): print('스윕 실패:', res) else: print(f"스윕 완료 — {res['count']}개 조합 · 검증기간 {res['split']['test']}") print(f"{'RR':>4} {'STOP':>5} {'SMA':>4} {'PULL':>5} {'RSI±':>8} {'HOLD':>5} {'RISK':>5} | " f"{'검증수익%':>8} {'검증MDD%':>8} {'거래':>5} {'승률%':>6}") for r in res['results'][:12]: p, te = r['params'], r['test'] rsi = f"{p.get('RSI_OVERBOUGHT','-')}/{p.get('RSI_OVERSOLD','-')}" print(f"{p['RR_RATIO']:>4} {p['STOP_ATR_MULT']:>5} {p['SMA_LONG']:>4} {p['PULLBACK_ATR_MULT']:>5} " f"{rsi:>8} {p.get('MAX_HOLD_DAYS','-'):>5} {p.get('RISK_PER_TRADE_PCT','-'):>5} | " f"{te['total_return_pct']:>8} {te['mdd_pct']:>8} " f"{te['trades']:>5} {str(te['win_rate_pct']):>6}")