Files
openclaw/agents/stock/workspace/sim/sweep.py
T
hyowons 81e8847a8e auto: 일일 백업 2026-06-11 02:00
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-11 02:00:01 +09:00

142 lines
6.6 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""파라미터 스윕 — 여러 튜닝을 백테스트로 동시 비교 → 순위표.
과최적화 방어: 기간을 앞(학습)/뒤(검증)로 쪼개 둘 다 측정.
- 학습기간 성적만 높고 검증기간 무너지면 = 과최적화 의심.
- 검증기간(out-of-sample) 성적으로 순위.
결과: state/sim/backtest_results.json (웹 백테스트 탭이 읽음).
"""
from __future__ import annotations
import itertools
import json
import os
from datetime import datetime
from multiprocessing import get_context
from . import backtest, config, universe
def _eval_combo(args):
"""워커 1개가 조합 1개를 평가 — 학습/검증 + 전체기간 3분할 구간(꾸준함 측정).
파라미터는 학습(fit)이 없으므로 3분할은 '한 구간 운빨'을 거르는 일관성 검증."""
keys, combo, codes, trf, trt, tef, tet, windows = args
params = dict(zip(keys, combo))
tr = backtest.run(params, codes, date_from=trf, date_to=trt)
te = backtest.run(params, codes, date_from=tef, date_to=tet)
wins = []
for wf, wt in windows:
w = backtest.run(params, codes, date_from=wf, date_to=wt)
wins.append({'from': wf, 'to': wt,
'ret': w.get('total_return_pct'), 'trades': w.get('trades', 0)})
rets = [w['ret'] for w in wins if w['ret'] is not None]
return {'params': params, 'train': tr, 'test': te, 'windows': wins,
'worst_ret': min(rets) if rets else None}
# 백테스트에서 실제 영향 있는 파라미터만 스윕 (애널 게이트·시장필터는 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]
# 전체기간 3분할 — 구간별 수익으로 '꾸준함'(최악 구간) 측정
third = len(dates) // 3
windows = [(dates[0], dates[third - 1]),
(dates[third], dates[2 * third - 1]),
(dates[2 * third], dates[-1])]
keys = list(grid.keys())
combos = list(itertools.product(*[grid[k] for k in keys]))
args = [(keys, combo, codes, train_from, train_to, test_from, test_to, windows) for combo in combos]
# 멀티프로세싱 — 조합을 코어에 분배. macOS는 fork가 데드락 위험이라 spawn 사용
# (워커가 메인 재import → sim/__main__.py의 `if __name__=='__main__'` 가드로 재귀 방지).
# chunksize 크게 → 워커당 연속 조합 처리로 종목 일봉 메모이즈 재활용 극대화. pool.map은 순서 유지(결정론).
nproc = max(1, (os.cpu_count() or 4) - 2)
if len(args) <= 1 or nproc == 1:
results = [_eval_combo(a) for a in args]
else:
chunk = max(1, len(args) // nproc)
with get_context('spawn').Pool(nproc) as pool:
results = pool.map(_eval_combo, args, chunksize=chunk)
def sort_key(r):
te = r['test']
enough = (te.get('trades', 0) or 0) >= MIN_TRADES
worst = r.get('worst_ret')
# 1순위 최악 구간 수익(꾸준함 — 한 구간 운빨 배제), 2순위 검증수익
return (1 if enough else 0,
worst if worst is not None else -999,
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))
# ⚠️ 비교군 자동 동기화는 2026-06-10 관리자님 지시로 끔 — sweep은 결과 파일만 갱신.
# 비교군 변경은 명시 요청 시에만: `python3 -m sim variants sync` 또는 variants.sync_label_tops().
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} {'검증수익%':>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"{str(r.get('worst_ret')):>8} {te['total_return_pct']:>8} "
f"{te['trades']:>5} {str(te['win_rate_pct']):>6}")