69ef9c09e8
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
96 lines
3.9 KiB
Python
96 lines
3.9 KiB
Python
"""파라미터 스윕 — 여러 튜닝을 백테스트로 동시 비교 → 순위표.
|
|
|
|
과최적화 방어: 기간을 앞(학습)/뒤(검증)로 쪼개 둘 다 측정.
|
|
- 학습기간 성적만 높고 검증기간 무너지면 = 과최적화 의심.
|
|
- 검증기간(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 중립이라 제외).
|
|
# SMA_LONG 5단계로 추세 속도 다양화 + 돌파 강도 추가. 5×3×3×3×2 = 270 조합.
|
|
GRID = {
|
|
'SMA_LONG': [10, 20, 40, 60, 120], # 추세 속도 — 빠른/느린 (다양성 핵심)
|
|
'RR_RATIO': [1.5, 2.0, 3.0], # 손익비
|
|
'STOP_ATR_MULT': [1.5, 2.0, 3.0], # 손절 폭
|
|
'PULLBACK_ATR_MULT': [0.5, 1.0, 1.5], # 눌림 깊이
|
|
'VOLUME_BREAKOUT_MULT': [1.3, 2.0], # 돌파 거래량 강도
|
|
}
|
|
|
|
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} | {'학습수익%':>8} {'검증수익%':>8} {'검증MDD%':>8} {'검증거래':>6} {'승률%':>6}")
|
|
for r in res['results'][:12]:
|
|
p, te, tr = r['params'], r['test'], r['train']
|
|
print(f"{p['RR_RATIO']:>4} {p['STOP_ATR_MULT']:>5} {p['SMA_LONG']:>4} {p['PULLBACK_ATR_MULT']:>5} | "
|
|
f"{tr['total_return_pct']:>8} {te['total_return_pct']:>8} {te['mdd_pct']:>8} "
|
|
f"{te['trades']:>6} {str(te['win_rate_pct']):>6}")
|