Files
ai-trade/backtest_runner.py
gahusb 42b91d03cf feat(v3.2): DailyLedger + RiskGate + news_snapshot + backtest_runner
- DailyLedger: 당일 매수 회계 + 연속 손절 카운터 + 매수 신호 점수 한 객체로 집약 (bot.py 정리)
- RiskGate: 테마당 동시 보유 + 노출 비율 상한 검증 (포트폴리오 레벨)
- news_snapshot: 뉴스 SQLite 영구 저장 + 사후 감성 재검증 인프라
- backtest_runner: 전 종목 KIS 일봉 기반 백테스트 (Sharpe/MDD/Calmar)
- bot.py 274 line 정리 (DailyLedger 분리)
- backtest.py 173 line 재작성 (v3.2 next-bar 체결 + 거래세)
- daily_launcher.py 폐기 (warmup_and_restart 통합)
- .gitignore: .claude/ 제외

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-16 02:57:26 +09:00

180 lines
7.2 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.
"""
실제 과거 데이터 기반 전 종목 백테스트 러너 (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()