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>
This commit is contained in:
179
backtest_runner.py
Normal file
179
backtest_runner.py
Normal file
@@ -0,0 +1,179 @@
|
||||
"""
|
||||
실제 과거 데이터 기반 전 종목 백테스트 러너 (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()
|
||||
Reference in New Issue
Block a user