Files
cointrader/docs/plans/2026-03-21-ml-validation-pipeline.md
21in7 b5a5510499 feat(backtest): add --compare-ml for ML on/off walk-forward comparison
Runs WalkForwardBacktester twice (use_ml=True/False), prints side-by-side
comparison of PF, win rate, MDD, Sharpe, and auto-judges ML filter value.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-21 19:58:24 +09:00

9.9 KiB

ML Validation Pipeline Implementation Plan

For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (- [ ]) syntax for tracking.

Goal: ML 필터의 실전 가치를 검증하는 --compare-ml CLI를 추가하여, 완화된 임계값에서 ML on/off Walk-Forward 백테스트를 자동 비교하고 PF/승률/MDD 개선폭을 리포트한다.

Architecture: scripts/run_backtest.py--compare-ml 플래그를 추가한다. 이 플래그가 활성화되면 WalkForwardBacktester를 use_ml=Trueuse_ml=False로 각각 실행하고, 결과를 나란히 비교하는 리포트를 출력한다. 기존 Backtester/WalkForwardBacktester 코드는 변경하지 않는다.

Tech Stack: Python, LightGBM, src/backtester.py (기존 모듈 재사용)

선행 완료 항목 (이미 구현됨):

  • 학습 전용 상수 (TRAIN_SIGNAL_THRESHOLD=2, TRAIN_ADX_THRESHOLD=15, etc.)
  • Purged gap (embargo=LOOKAHEAD) in all walk-forward functions
  • Ablation A/B/C CLI (--ablation)
  • BacktestConfig.use_ml 플래그
  • run_backtest.py --no-ml 지원

판단 기준 (합의됨):

  • ML on vs ML off의 상대 PF 개선폭으로 판단 (절대 기준 아님)
  • PF 개선 + 승률 개선 + MDD 감소 → 투입 가치 있음
  • PF 변화 미미 → ML 기여 낮음

File Structure

파일 변경 유형 역할
scripts/run_backtest.py Modify --compare-ml CLI + 비교 리포트
CLAUDE.md Modify plan history 업데이트

Task 1: --compare-ml CLI 추가

Files:

  • Modify: scripts/run_backtest.py:29-55, 151-211

  • Step 1: argparse에 --compare-ml 추가

scripts/run_backtest.pyparse_args() 함수에:

p.add_argument("--compare-ml", action="store_true",
               help="ML on vs off Walk-Forward 비교 (--walk-forward 자동 활성화)")
  • Step 2: compare_ml 함수 작성

scripts/run_backtest.pycompare_ml() 함수 추가:

def compare_ml(symbols: list[str], args):
    """ML on vs ML off Walk-Forward 백테스트 비교.

    완화된 임계값(threshold=2)에서 ML 필터의 실질적 가치를 검증한다.
    판단 기준: 상대 PF 개선폭 (절대 기준 아님).
    """
    base_kwargs = dict(
        symbols=symbols,
        start=args.start,
        end=args.end,
        initial_balance=args.balance,
        leverage=args.leverage,
        fee_pct=args.fee,
        slippage_pct=args.slippage,
        ml_threshold=args.ml_threshold,
        atr_sl_mult=args.sl_atr,
        atr_tp_mult=args.tp_atr,
        signal_threshold=args.signal_threshold,
        adx_threshold=args.adx_threshold,
        volume_multiplier=args.vol_multiplier,
        train_months=args.train_months,
        test_months=args.test_months,
    )

    results = {}
    for label, use_ml in [("ML OFF", False), ("ML ON", True)]:
        print(f"\n{'='*60}")
        print(f"  Walk-Forward 백테스트: {label}")
        print(f"{'='*60}")

        cfg = WalkForwardConfig(**base_kwargs, use_ml=use_ml)
        wf = WalkForwardBacktester(cfg)
        result = wf.run()
        results[label] = result
        print_summary(result["summary"], cfg, mode="walk_forward")
        if result.get("folds"):
            print_fold_table(result["folds"])

    # 비교 리포트
    _print_comparison(results, symbols)

    # 결과 저장
    ts = datetime.now().strftime("%Y%m%d_%H%M%S")
    if len(symbols) == 1:
        out_dir = Path(f"results/{symbols[0].lower()}")
    else:
        out_dir = Path("results/combined")
    out_dir.mkdir(parents=True, exist_ok=True)
    path = out_dir / f"ml_comparison_{ts}.json"

    comparison = {
        "timestamp": datetime.now().isoformat(),
        "symbols": symbols,
        "ml_off": results["ML OFF"]["summary"],
        "ml_on": results["ML ON"]["summary"],
    }

    def sanitize(obj):
        if isinstance(obj, bool):
            return obj
        if isinstance(obj, (int, float)):
            if isinstance(obj, float) and obj == float("inf"):
                return "Infinity"
            return obj
        if isinstance(obj, dict):
            return {k: sanitize(v) for k, v in obj.items()}
        if isinstance(obj, list):
            return [sanitize(v) for v in obj]
        return obj

    with open(path, "w") as f:
        json.dump(sanitize(comparison), f, indent=2, ensure_ascii=False)
    print(f"\n비교 결과 저장: {path}")


def _print_comparison(results: dict, symbols: list[str]):
    """ML on vs off 비교 리포트 출력."""
    off = results["ML OFF"]["summary"]
    on = results["ML ON"]["summary"]

    print(f"\n{'='*64}")
    print(f"  ML ON vs OFF 비교 ({', '.join(symbols)})")
    print(f"{'='*64}")
    print(f"  {'지표':<20} {'ML OFF':>12} {'ML ON':>12} {'Delta':>12}")
    print(f"{'─'*64}")

    metrics = [
        ("총 거래", "total_trades", "d"),
        ("총 PnL (USDT)", "total_pnl", ".2f"),
        ("수익률 (%)", "return_pct", ".2f"),
        ("승률 (%)", "win_rate", ".1f"),
        ("Profit Factor", "profit_factor", ".2f"),
        ("MDD (%)", "max_drawdown_pct", ".2f"),
        ("Sharpe", "sharpe_ratio", ".2f"),
    ]

    for label, key, fmt in metrics:
        v_off = off.get(key, 0)
        v_on = on.get(key, 0)
        # inf 처리
        if v_off == float("inf"):
            v_off_str = "INF"
        else:
            v_off_str = f"{v_off:{fmt}}"
        if v_on == float("inf"):
            v_on_str = "INF"
        else:
            v_on_str = f"{v_on:{fmt}}"

        if isinstance(v_off, (int, float)) and isinstance(v_on, (int, float)) \
                and v_off != float("inf") and v_on != float("inf"):
            delta = v_on - v_off
            sign = "+" if delta > 0 else ""
            delta_str = f"{sign}{delta:{fmt}}"
        else:
            delta_str = "N/A"

        print(f"  {label:<20} {v_off_str:>12} {v_on_str:>12} {delta_str:>12}")

    # 판정
    pf_off = off.get("profit_factor", 0)
    pf_on = on.get("profit_factor", 0)
    wr_off = off.get("win_rate", 0)
    wr_on = on.get("win_rate", 0)
    mdd_off = off.get("max_drawdown_pct", 0)
    mdd_on = on.get("max_drawdown_pct", 0)

    print(f"{'─'*64}")

    if pf_off == float("inf") or pf_on == float("inf"):
        print(f"  판정: PF=INF — 한쪽 모드에서 손실 거래 없음 (거래 수 부족 가능), 판단 보류")
    elif pf_off == 0:
        print(f"  판정: ML OFF PF=0 — baseline 거래 없음, 판단 불가")
    else:
        pf_improvement = pf_on - pf_off
        wr_improvement = wr_on - wr_off
        mdd_improvement = mdd_off - mdd_on  # MDD는 낮을수록 좋음

        # 판정 임계값 (초기값 — 실제 백테스트 결과를 보고 조정 가능)
        improvements = []
        if pf_improvement > 0.1:
            improvements.append(f"PF +{pf_improvement:.2f}")
        if wr_improvement > 2.0:
            improvements.append(f"승률 +{wr_improvement:.1f}%p")
        if mdd_improvement > 1.0:
            improvements.append(f"MDD -{mdd_improvement:.1f}%p")

        if len(improvements) >= 2:
            verdict = f"✅ ML 필터 투입 가치 있음 ({', '.join(improvements)})"
        elif len(improvements) == 1:
            verdict = f"⚠️ ML 필터 조건부 투입 ({improvements[0]}, 다른 지표 변화 미미)"
        else:
            verdict = f"❌ ML 필터 기여 미미 (PF {pf_improvement:+.2f}, 승률 {wr_improvement:+.1f}%p)"
        print(f"  판정: {verdict}")

    print(f"{'='*64}\n")
  • Step 3: main()에 --compare-ml 분기 추가

scripts/run_backtest.pymain() 함수에서 if args.walk_forward: 블록 앞에 추가:

if args.compare_ml:
    if args.no_ml:
        logger.warning("--no-ml is ignored when using --compare-ml")
    compare_ml(symbols, args)
    return
  • Step 4: 전체 테스트 통과 확인

Run: bash scripts/run_tests.sh Expected: ALL PASS (기존 테스트 영향 없음)

  • Step 5: 커밋
git add scripts/run_backtest.py
git commit -m "feat(backtest): add --compare-ml for ML on/off walk-forward comparison"

Task 2: CLAUDE.md 업데이트

Files:

  • Modify: CLAUDE.md

  • Step 1: plan history 업데이트

| 2026-03-21 | `ml-validation-pipeline` (plan) | Completed |
  • Step 2: 커밋
git add CLAUDE.md
git commit -m "docs: update plan history with ml-validation-pipeline"

구현 후 실행 가이드

Phase 1: Ablation 진단 (이미 구현됨)

# 심볼별 ablation 실행
python scripts/train_model.py --symbol XRPUSDT --ablation
python scripts/train_model.py --symbol SOLUSDT --ablation
python scripts/train_model.py --symbol DOGEUSDT --ablation

판단:

  • A→C 드롭 ≤ 0.05 → Phase 2로 진행
  • A→C 드롭 ≥ 0.10 → ML 재설계 필요 (중단)

Phase 2: ML on/off 비교 (이 플랜에서 구현)

# 완화된 임계값(threshold=2)로 ML 비교
python scripts/run_backtest.py --symbol XRPUSDT --compare-ml \
  --signal-threshold 2 --adx-threshold 15 --vol-multiplier 1.5 --walk-forward

python scripts/run_backtest.py --symbol SOLUSDT --compare-ml \
  --signal-threshold 2 --adx-threshold 15 --vol-multiplier 1.5 --walk-forward

python scripts/run_backtest.py --symbol DOGEUSDT --compare-ml \
  --signal-threshold 2 --adx-threshold 15 --vol-multiplier 1.5 --walk-forward

판단: 상대 PF 개선폭으로 ML 가치 평가

Phase 3: 실전 점진적 전환 (코드 변경 불필요)

Phase 1, 2 모두 긍정적이면 .env로 1심볼부터 적용:

# .env에 추가 (1심볼만 먼저)
SIGNAL_THRESHOLD_XRPUSDT=2
ADX_THRESHOLD_XRPUSDT=15
VOL_MULTIPLIER_XRPUSDT=1.5

# 나머지 심볼은 기존 값 유지
# SIGNAL_THRESHOLD_SOLUSDT=3  (기본값)
# SIGNAL_THRESHOLD_DOGEUSDT=3 (기본값)

1~2주 운영 후 kill switch 미발동 + PnL 양호하면 나머지 심볼도 전환.