#!/usr/bin/env python3 """RSI DCA 백테스트 — DB 15m 캔들로 최근 N일 신호·매수·평가 산출. python scripts/3_run_rsi_dca_backtest.py --days 90 python scripts/3_run_rsi_dca_backtest.py --days 90 --levels 30:20000,35:10000 --daily-max 60000 """ from __future__ import annotations import argparse import json import sys from pathlib import Path ROOT = Path(__file__).resolve().parents[1] SRC = ROOT / "src" if str(SRC) not in sys.path: sys.path.insert(0, str(SRC)) from bithumb.config import load_settings # noqa: E402 from bithumb.data.candle_loader import load_candles # noqa: E402 from bithumb.operations.rsi_dca_engine import ( # noqa: E402 RsiDcaConfig, backtest_rsi_dca, parse_levels, ) from bithumb.operations.rsi_dca_runner import config_from_settings # noqa: E402 def main() -> int: parser = argparse.ArgumentParser(description="RSI DCA 백테스트") parser.add_argument("--days", type=int, default=90) parser.add_argument("--symbols", default=None, help="쉼표 구분 (기본 RSI_DCA_SYMBOLS)") parser.add_argument("--levels", default=None, help="예 30:20000,35:10000") parser.add_argument("--daily-max", type=float, default=None) parser.add_argument("--out", default="docs/spot/3_operations/rsi_dca_backtest.json") args = parser.parse_args() settings = load_settings() base = config_from_settings(settings) cfg = RsiDcaConfig( symbols=[s.strip().upper() for s in args.symbols.split(",")] if args.symbols else base.symbols, interval_min=base.interval_min, period=base.period, levels=parse_levels(args.levels) if args.levels else base.levels, daily_max_krw=args.daily_max if args.daily_max is not None else base.daily_max_krw, lookback_days=base.lookback_days, max_bars_per_tick=base.max_bars_per_tick, max_signal_age_min=base.max_signal_age_min, min_order_krw=base.min_order_krw, fee_rate=base.fee_rate, slippage_rate=base.slippage_rate, fee_lock_rate=base.fee_lock_rate, ) candles = {} for sym in cfg.symbols: # RSI 워밍업을 위해 여유 있게 로드 df = load_candles(settings.db_path, sym, cfg.interval_min, lookback_days=args.days + 10) candles[sym] = df rep = backtest_rsi_dca(candles, cfg, days=args.days) levels_txt = ", ".join(f"RSI {lv:g}↑ {krw:,.0f}원" for lv, krw in cfg.levels) print(f"RSI DCA 백테스트 · 최근 {args.days}일 · {cfg.interval_min}m RSI({cfg.period}) · {levels_txt} · 일 상한 {cfg.daily_max_krw:,.0f}원") print(f"수수료 {cfg.fee_rate*100:.3f}% · 슬리피지 {cfg.slippage_rate*100:.3f}% · 매도 없음(마지막 종가 평가)") print() print(f"{'종목':5s} {'신호':>5s} {'매수':>5s} {'투입(원)':>12s} {'평가(원)':>12s} {'손익%':>7s}") for sym in cfg.symbols: v = rep["per_symbol"].get(sym) if not v: print(f"{sym:5s} {'데이터없음':>5s}") continue print(f"{sym:5s} {int(v['signals']):5d} {int(v['buys']):5d} {v['spent_krw']:12,.0f} {v['value_krw']:12,.0f} {v['pnl_pct']:7.2f}") print("-" * 52) print(f"{'합계':5s} {rep['signals']:5d} {rep['buys']:5d} {rep['total_spent_krw']:12,.0f} {rep['total_value_krw']:12,.0f} {rep['total_pnl_pct']:7.2f}") print(f"일 상한으로 스킵된 신호: {rep['skipped_daily_cap']}건 · 일평균 투입 {rep['avg_daily_spent_krw']:,.0f}원 (신호 구간 {rep['span_days']}일)") out = ROOT / args.out out.parent.mkdir(parents=True, exist_ok=True) out.write_text(json.dumps(rep, ensure_ascii=False, indent=2, default=str), encoding="utf-8") print(f"\n저장: {out}") return 0 if __name__ == "__main__": raise SystemExit(main())