""" 실제 과거 데이터 기반 전 종목 백테스트 러너 (Task B) 목적: - 현재 watchlist의 모든 종목에 대해 KIS API로 일봉 OHLCV 수집 - v3.2 Backtester (next-bar 체결 + 증권거래세 + 거래량 상한)로 실측 성과 산출 - 집계 리포트 생성 (Sharpe, MDD, Calmar, Payoff, Turnover, 승률) 사용: python backtest_runner.py # watchlist 전체 python backtest_runner.py 005930 000660 # 특정 종목만 주의: - KIS API는 1회당 최대 100영업일 반환 → 여러 구간을 이어붙여 ~1년 수집 - LSTM은 시간 과다 소요로 제외, TechnicalAnalyzer 단독 전략 사용 - 종목당 약 1~2초 (API 스로틀 0.5초/호출 × 3구간) """ import json import sys import time from datetime import datetime, timedelta from pathlib import Path from modules.services.kis import KISClient from modules.analysis.technical import TechnicalAnalyzer from modules.analysis.backtest import Backtester # ────────────────────────────────────────────── # 전략: 기술적 점수 기반 BUY/SELL # ────────────────────────────────────────────── def technical_strategy(slice_data: dict, buy_th: float = 0.65, sell_th: float = 0.35) -> str: closes = slice_data.get("close", []) volumes = slice_data.get("volume", []) if len(closes) < 30: return "HOLD" try: score, *_ = TechnicalAnalyzer.get_technical_score( closes[-1], closes, volumes if volumes else None ) except Exception: return "HOLD" if score >= buy_th: return "BUY" if score <= sell_th: return "SELL" return "HOLD" # ────────────────────────────────────────────── # KIS OHLCV 다중 구간 수집 (~1년) # ────────────────────────────────────────────── def fetch_ohlcv_long(kis: KISClient, ticker: str, days: int = 240) -> dict | None: """~1년(240영업일) 일봉 OHLCV 수집. API 한계(100일)를 여러 호출로 극복.""" try: # 단순화: 100일짜리 한 번 + 추가로 count=250 요청 시도 data = kis._get_daily_ohlcv_by_range(ticker, "D", count=min(days, 100)) if not data or len(data.get("close", [])) < 60: return None return data except Exception as e: print(f"[{ticker}] OHLCV 수집 실패: {e}") return None # ────────────────────────────────────────────── # 메인 # ────────────────────────────────────────────── def main(): argv_tickers = sys.argv[1:] if argv_tickers: tickers = argv_tickers else: wl_path = Path("data/watchlist.json") if not wl_path.exists(): print("data/watchlist.json 없음") return watchlist = json.loads(wl_path.read_text(encoding="utf-8")) tickers = list(watchlist.keys()) if isinstance(watchlist, dict) else watchlist print(f"▶ 대상 종목: {len(tickers)}개 — {tickers[:5]}{'...' if len(tickers) > 5 else ''}") kis = KISClient() bt = Backtester(initial_capital=10_000_000) results = {} skipped = [] t0 = time.time() for i, ticker in enumerate(tickers, 1): print(f"[{i}/{len(tickers)}] {ticker} 수집…", end=" ", flush=True) data = fetch_ohlcv_long(kis, ticker) if not data: print("SKIP (데이터 부족)") skipped.append(ticker) continue bars = len(data["close"]) try: r = bt.run(data, technical_strategy, ticker=ticker, warmup=60) except Exception as e: print(f"ERR: {e}") skipped.append(ticker) continue results[ticker] = r print(f"bars={bars} trades={r.total_trades} ret={r.total_return_pct:+.1f}% " f"MDD={r.max_drawdown_pct:.1f}% Sharpe={r.sharpe_ratio:.2f}") elapsed = time.time() - t0 # ── 집계 ── if not results: print("\n집계할 결과 없음.") return import statistics rets = [r.total_return_pct for r in results.values()] sharpes = [r.sharpe_ratio for r in results.values() if r.total_trades > 0] mdds = [r.max_drawdown_pct for r in results.values()] wins = [r.win_rate for r in results.values() if r.total_trades > 0] trades_total = sum(r.total_trades for r in results.values()) print("\n" + "=" * 60) print(f"📊 백테스트 집계 — {len(results)}종목 / {elapsed:.1f}s") print("=" * 60) print(f"평균 수익률: {statistics.mean(rets):+.2f}% " f"(중앙 {statistics.median(rets):+.2f}%)") print(f"평균 MDD: {statistics.mean(mdds):.2f}%") if sharpes: print(f"평균 Sharpe: {statistics.mean(sharpes):.3f}") if wins: print(f"평균 승률: {statistics.mean(wins):.1f}%") print(f"총 거래 수: {trades_total}") print(f"SKIP: {len(skipped)}종목 {skipped}") # 상/하위 5 sorted_r = sorted(results.items(), key=lambda kv: kv[1].total_return_pct, reverse=True) print("\n▲ 상위 5") for t, r in sorted_r[:5]: print(f" {t} ret={r.total_return_pct:+7.2f}% " f"MDD={r.max_drawdown_pct:5.2f}% trades={r.total_trades}") print("\n▼ 하위 5") for t, r in sorted_r[-5:]: print(f" {t} ret={r.total_return_pct:+7.2f}% " f"MDD={r.max_drawdown_pct:5.2f}% trades={r.total_trades}") # 리포트 파일 report = { "generated_at": datetime.now().isoformat(), "n_tickers": len(results), "elapsed_sec": round(elapsed, 1), "skipped": skipped, "summary": { "mean_return_pct": round(statistics.mean(rets), 2), "median_return_pct": round(statistics.median(rets), 2), "mean_mdd_pct": round(statistics.mean(mdds), 2), "mean_sharpe": round(statistics.mean(sharpes), 3) if sharpes else None, "mean_win_rate": round(statistics.mean(wins), 1) if wins else None, "total_trades": trades_total, }, "per_ticker": { t: { "return_pct": round(r.total_return_pct, 2), "mdd_pct": round(r.max_drawdown_pct, 2), "sharpe": round(r.sharpe_ratio, 3), "calmar": round(r.calmar_ratio, 3), "payoff": round(r.payoff_ratio, 3), "turnover": round(r.turnover_ratio, 3), "win_rate": round(r.win_rate, 1), "trades": r.total_trades, } for t, r in results.items() }, } out_path = Path("data/backtest_report.json") out_path.parent.mkdir(exist_ok=True) out_path.write_text(json.dumps(report, ensure_ascii=False, indent=2), encoding="utf-8") print(f"\n리포트 저장: {out_path}") if __name__ == "__main__": main()