From 02e41881ac9de042679b40216766884c5e596085 Mon Sep 17 00:00:00 2001 From: 21in7 Date: Fri, 6 Mar 2026 23:39:43 +0900 Subject: [PATCH] feat: strategy parameter sweep and production param optimization MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add independent backtest engine (backtester.py) with walk-forward support - Add backtest sanity check validator (backtest_validator.py) - Add CLI tools: run_backtest.py, strategy_sweep.py (with --combined mode) - Fix train-serve skew: unify feature z-score normalization (ml_features.py) - Add strategy params (SL/TP ATR mult, ADX filter, volume multiplier) to config.py, indicators.py, dataset_builder.py, bot.py, backtester.py - Fix WalkForwardBacktester not propagating strategy params to test folds - Update production defaults: SL=2.0x, TP=2.0x, ADX=25, Vol=2.5 (3-symbol combined PF: 0.71 → 1.24, MDD: 65.9% → 17.1%) - Retrain ML models with new strategy parameters Co-Authored-By: Claude Opus 4.6 --- CLAUDE.md | 1 + ...026-03-06-strategy-parameter-sweep-plan.md | 80 ++ models/dogeusdt/lgbm_filter.pkl | Bin 14036 -> 33636 bytes models/dogeusdt/training_log.json | 75 ++ models/trxusdt/lgbm_filter.pkl | Bin 10740 -> 30836 bytes models/trxusdt/training_log.json | 75 ++ models/xrpusdt/lgbm_filter.pkl | Bin 174292 -> 13764 bytes models/xrpusdt/training_log.json | 75 ++ scripts/run_backtest.py | 211 +++++ scripts/strategy_sweep.py | 317 +++++++ src/backtest_validator.py | 228 +++++ src/backtester.py | 837 ++++++++++++++++++ src/bot.py | 19 +- src/config.py | 10 + src/dataset_builder.py | 34 +- src/indicators.py | 31 +- src/ml_features.py | 173 +++- tests/test_bot.py | 2 +- tests/test_dataset_builder.py | 6 +- tests/test_indicators.py | 12 +- 20 files changed, 2153 insertions(+), 33 deletions(-) create mode 100644 docs/plans/2026-03-06-strategy-parameter-sweep-plan.md create mode 100644 scripts/run_backtest.py create mode 100644 scripts/strategy_sweep.py create mode 100644 src/backtest_validator.py create mode 100644 src/backtester.py diff --git a/CLAUDE.md b/CLAUDE.md index 16f9c4d..fe83ffc 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -130,3 +130,4 @@ All design documents and implementation plans are stored in `docs/plans/` with t | 2026-03-04 | `oi-derived-features` (design + plan) | Completed | | 2026-03-05 | `multi-symbol-trading` (design + plan) | Completed | | 2026-03-06 | `multi-symbol-dashboard` (design + plan) | Completed | +| 2026-03-06 | `strategy-parameter-sweep` (plan) | Completed | diff --git a/docs/plans/2026-03-06-strategy-parameter-sweep-plan.md b/docs/plans/2026-03-06-strategy-parameter-sweep-plan.md new file mode 100644 index 0000000..4f8d691 --- /dev/null +++ b/docs/plans/2026-03-06-strategy-parameter-sweep-plan.md @@ -0,0 +1,80 @@ +# Strategy Parameter Sweep Plan + +**Date**: 2026-03-06 +**Status**: Completed + +## Goal + +Find profitable parameter combinations for the base technical indicator strategy (ML OFF) using walk-forward backtesting, targeting PF >= 1.0 as foundation for ML redesign. + +## Background + +Walk-forward backtest revealed the current XRP strategy is unprofitable (PF 0.71, -641 PnL). The strategy parameter sweep systematically tests 324 combinations of 5 parameters to find profitable regimes. + +## Parameters Swept + +| Parameter | Values | Description | +|-----------|--------|-------------| +| `atr_sl_mult` | 1.0, 1.5, 2.0 | Stop-loss ATR multiplier | +| `atr_tp_mult` | 2.0, 3.0, 4.0 | Take-profit ATR multiplier | +| `signal_threshold` | 3, 4, 5 | Min weighted indicator score for entry | +| `adx_threshold` | 0, 20, 25, 30 | ADX filter (0=disabled, N=require ADX>=N) | +| `volume_multiplier` | 1.5, 2.0, 2.5 | Volume surge detection multiplier | + +Total combinations: 3 x 3 x 3 x 4 x 3 = **324** + +## Implementation + +### Files Modified +- `src/indicators.py` — `get_signal()` accepts `signal_threshold`, `adx_threshold`, `volume_multiplier` params +- `src/dataset_builder.py` — `_calc_signals()` accepts same params for vectorized computation +- `src/backtester.py` — `BacktestConfig` includes strategy params; `WalkForwardBacktester` propagates them to test folds + +### Files Created +- `scripts/strategy_sweep.py` — CLI tool for parameter grid sweep + +### Bug Fix +- `WalkForwardBacktester` was not passing `signal_threshold`, `adx_threshold`, `volume_multiplier`, or `use_ml` to fold `BacktestConfig`. All signal params were silently using defaults, making ADX/volume/threshold sweeps have zero effect. + +## Results (XRPUSDT, Walk-Forward 3/1) + +### Top 10 Combinations + +| Rank | SL×ATR | TP×ATR | Signal | ADX | Vol | Trades | WinRate | PF | MDD | PnL | Sharpe | +|------|--------|--------|--------|-----|-----|--------|---------|-----|-----|------|--------| +| 1 | 1.5 | 4.0 | 3 | 30 | 2.5 | 19 | 52.6% | 2.39 | 7.0% | +469 | 61.0 | +| 2 | 1.5 | 2.0 | 3 | 30 | 2.5 | 19 | 68.4% | 2.23 | 6.5% | +282 | 61.2 | +| 3 | 1.0 | 2.0 | 3 | 30 | 2.5 | 19 | 57.9% | 1.98 | 5.0% | +213 | 50.8 | +| 4 | 1.0 | 4.0 | 3 | 30 | 2.5 | 19 | 36.8% | 1.80 | 7.7% | +248 | 37.1 | +| 5 | 1.5 | 3.0 | 3 | 30 | 2.5 | 19 | 52.6% | 1.76 | 10.1% | +258 | 40.9 | +| 6 | 1.5 | 4.0 | 3 | 25 | 2.5 | 28 | 42.9% | 1.75 | 13.1% | +381 | 36.8 | +| 7 | 2.0 | 4.0 | 3 | 30 | 1.5 | 39 | 48.7% | 1.67 | 16.9% | +572 | 35.3 | +| 8 | 1.0 | 2.0 | 3 | 25 | 2.5 | 28 | 50.0% | 1.64 | 5.8% | +205 | 35.7 | +| 9 | 1.5 | 2.0 | 3 | 25 | 2.5 | 28 | 57.1% | 1.62 | 10.3% | +229 | 35.7 | +| 10 | 2.0 | 2.0 | 3 | 25 | 2.5 | 27 | 66.7% | 1.57 | 12.0% | +217 | 33.3 | + +### Current Production (Rank 93/324) +| SL×ATR | TP×ATR | Signal | ADX | Vol | Trades | WinRate | PF | MDD | PnL | +|--------|--------|--------|-----|-----|--------|---------|-----|-----|------| +| 1.5 | 3.0 | 3 | 0 | 1.5 | 118 | 30.5% | 0.71 | 65.9% | -641 | + +### Key Findings + +1. **ADX filter is the single most impactful parameter.** All top 10 results use ADX >= 25, with ADX=30 dominating the top 5. This filters out sideways/ranging markets where signals are noise. + +2. **Volume multiplier 2.5 dominates.** Higher volume thresholds ensure entries only on strong conviction (genuine breakouts vs. noise). + +3. **Signal threshold 3 is optimal.** Higher thresholds (4, 5) produced too few trades or zero trades in most ADX-filtered regimes. + +4. **SL/TP ratios matter less than entry filters.** The top results span all SL/TP combos, but all share ADX=25-30 + Vol=2.5. + +5. **Trade count drops significantly with filters.** Top combos have 19-39 trades vs. 118 for current. Fewer but higher quality entries. + +6. **41 combinations achieved PF >= 1.0** out of 324 total (12.7%). + +## Recommended Next Steps + +1. **Update production defaults**: ADX=25, volume_multiplier=2.0 as a conservative choice (more trades than ADX=30) +2. **Validate on TRXUSDT and DOGEUSDT** to confirm ADX filter is not XRP-specific +3. **Retrain ML models** with updated strategy params — the ML filter should now have a profitable base to improve upon +4. **Fine-tune sweep** around the profitable zone: ADX [25-35], Vol [2.0-3.0] diff --git a/models/dogeusdt/lgbm_filter.pkl b/models/dogeusdt/lgbm_filter.pkl index 97dc77fd0847b27cac4d22d1eb5a185dd018eae9..12a098884893bf42d4d15132016ab6b09bfc53f6 100644 GIT binary patch literal 33636 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 AfdBvi literal 14036 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 AhX4Qo diff --git a/models/dogeusdt/training_log.json b/models/dogeusdt/training_log.json index 7ba1e43..9afea85 100644 --- a/models/dogeusdt/training_log.json +++ b/models/dogeusdt/training_log.json @@ -23,5 +23,80 @@ "reg_lambda": 0.000157 }, "weight_scale": 1.783105 + }, + { + "date": "2026-03-06T02:00:56.287381", + "backend": "lgbm", + "auc": 0.9555, + "best_threshold": 0.4012, + "best_precision": 0.577, + "best_recall": 0.319, + "samples": 3330, + "features": 26, + "time_weight_decay": 2.0, + "model_path": "models/dogeusdt/lgbm_filter.pkl", + "tuned_params_path": null, + "lgbm_params": { + "n_estimators": 434, + "learning_rate": 0.123659, + "max_depth": 6, + "num_leaves": 14, + "min_child_samples": 10, + "subsample": 0.929062, + "colsample_bytree": 0.94633, + "reg_alpha": 0.573971, + "reg_lambda": 0.000157 + }, + "weight_scale": 1.783105 + }, + { + "date": "2026-03-06T22:37:26.751875", + "backend": "lgbm", + "auc": 0.9565, + "best_threshold": 0.4047, + "best_precision": 0.65, + "best_recall": 0.277, + "samples": 3336, + "features": 26, + "time_weight_decay": 2.0, + "model_path": "models/dogeusdt/lgbm_filter.pkl", + "tuned_params_path": null, + "lgbm_params": { + "n_estimators": 434, + "learning_rate": 0.123659, + "max_depth": 6, + "num_leaves": 14, + "min_child_samples": 10, + "subsample": 0.929062, + "colsample_bytree": 0.94633, + "reg_alpha": 0.573971, + "reg_lambda": 0.000157 + }, + "weight_scale": 1.783105 + }, + { + "date": "2026-03-06T23:35:19.306197", + "backend": "lgbm", + "auc": 0.9552, + "best_threshold": 0.8009, + "best_precision": 0.75, + "best_recall": 0.2, + "samples": 744, + "features": 26, + "time_weight_decay": 2.0, + "model_path": "models/dogeusdt/lgbm_filter.pkl", + "tuned_params_path": null, + "lgbm_params": { + "n_estimators": 434, + "learning_rate": 0.123659, + "max_depth": 6, + "num_leaves": 14, + "min_child_samples": 10, + "subsample": 0.929062, + "colsample_bytree": 0.94633, + "reg_alpha": 0.573971, + "reg_lambda": 0.000157 + }, + "weight_scale": 1.783105 } ] \ No newline at end of file diff --git a/models/trxusdt/lgbm_filter.pkl b/models/trxusdt/lgbm_filter.pkl index c457fa1d2793b6dead6f8ef59b1a86a7b1cfac4e..1740b7b8a0a49678c3e522725823e6487549741f 100644 GIT binary patch literal 30836 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 diff --git a/models/trxusdt/training_log.json b/models/trxusdt/training_log.json index 4d52670..97aa2f8 100644 --- a/models/trxusdt/training_log.json +++ b/models/trxusdt/training_log.json @@ -23,5 +23,80 @@ "reg_lambda": 0.000157 }, "weight_scale": 1.783105 + }, + { + "date": "2026-03-06T02:00:40.471987", + "backend": "lgbm", + "auc": 0.9433, + "best_threshold": 0.2433, + "best_precision": 0.439, + "best_recall": 0.947, + "samples": 2940, + "features": 26, + "time_weight_decay": 2.0, + "model_path": "models/trxusdt/lgbm_filter.pkl", + "tuned_params_path": null, + "lgbm_params": { + "n_estimators": 434, + "learning_rate": 0.123659, + "max_depth": 6, + "num_leaves": 14, + "min_child_samples": 10, + "subsample": 0.929062, + "colsample_bytree": 0.94633, + "reg_alpha": 0.573971, + "reg_lambda": 0.000157 + }, + "weight_scale": 1.783105 + }, + { + "date": "2026-03-06T22:37:17.762061", + "backend": "lgbm", + "auc": 0.9493, + "best_threshold": 0.2613, + "best_precision": 0.448, + "best_recall": 0.975, + "samples": 2952, + "features": 26, + "time_weight_decay": 2.0, + "model_path": "models/trxusdt/lgbm_filter.pkl", + "tuned_params_path": null, + "lgbm_params": { + "n_estimators": 434, + "learning_rate": 0.123659, + "max_depth": 6, + "num_leaves": 14, + "min_child_samples": 10, + "subsample": 0.929062, + "colsample_bytree": 0.94633, + "reg_alpha": 0.573971, + "reg_lambda": 0.000157 + }, + "weight_scale": 1.783105 + }, + { + "date": "2026-03-06T23:35:11.188338", + "backend": "lgbm", + "auc": 0.96, + "best_threshold": 0.6121, + "best_precision": 0.75, + "best_recall": 0.6, + "samples": 648, + "features": 26, + "time_weight_decay": 2.0, + "model_path": "models/trxusdt/lgbm_filter.pkl", + "tuned_params_path": null, + "lgbm_params": { + "n_estimators": 434, + "learning_rate": 0.123659, + "max_depth": 6, + "num_leaves": 14, + "min_child_samples": 10, + "subsample": 0.929062, + "colsample_bytree": 0.94633, + "reg_alpha": 0.573971, + "reg_lambda": 0.000157 + }, + "weight_scale": 1.783105 } ] \ No newline at end of file diff --git a/models/xrpusdt/lgbm_filter.pkl b/models/xrpusdt/lgbm_filter.pkl index c04877623d4e4f1ed46f1441b1d3beff8443aa72..8c64a165c180434f74b97e6bf1767520ee54db90 100644 GIT binary patch literal 13764 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.xOV`*-n*S-~xX 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 0 else 0 + print(f" {reason:20s} {count:4d}건 ({pct:.1f}%)") + print("=" * 60) + + +def print_fold_table(folds: list[dict]): + print("\n" + "=" * 90) + print(" FOLD DETAILS") + print("=" * 90) + print(f" {'Fold':>4} {'Test Period':>25} {'Trades':>6} {'PnL':>10} {'WinRate':>7} {'PF':>6} {'MDD':>6}") + print("-" * 90) + for f in folds: + s = f["summary"] + pf = s["profit_factor"] + pf_str = f"{pf:.2f}" if pf != float("inf") else "INF" + print(f" {f['fold']:>4} {f['test_period']:>25} {s['total_trades']:>6} " + f"{s['total_pnl']:>+10.2f} {s['win_rate']:>6.1f}% {pf_str:>6} {s['max_drawdown_pct']:>5.1f}%") + print("=" * 90) + + +def save_result(result: dict, cfg): + ts = datetime.now().strftime("%Y%m%d_%H%M%S") + mode = result.get("mode", "standard") + prefix = "wf_backtest" if mode == "walk_forward" else "backtest" + + for sym in cfg.symbols: + out_dir = Path(f"results/{sym.lower()}") + out_dir.mkdir(parents=True, exist_ok=True) + path = out_dir / f"{prefix}_{ts}.json" + + if len(cfg.symbols) > 1: + out_dir = Path("results/combined") + out_dir.mkdir(parents=True, exist_ok=True) + path = out_dir / f"{prefix}_{ts}.json" + + def sanitize(obj): + if isinstance(obj, bool): + return obj + if isinstance(obj, (int, float)): + if isinstance(obj, float): + if obj == float("inf"): + return "Infinity" + if 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] + if isinstance(obj, (np.integer,)): + return int(obj) + if isinstance(obj, (np.floating,)): + return float(obj) + if isinstance(obj, np.bool_): + return bool(obj) + return obj + + with open(path, "w") as f: + json.dump(sanitize(result), f, indent=2, ensure_ascii=False) + print(f"결과 저장: {path}") + return path + + +def main(): + args = parse_args() + + if args.symbol: + symbols = [args.symbol.upper()] + else: + symbols = [s.strip().upper() for s in args.symbols.split(",") if s.strip()] + + if args.walk_forward: + cfg = WalkForwardConfig( + symbols=symbols, + start=args.start, + end=args.end, + initial_balance=args.balance, + leverage=args.leverage, + fee_pct=args.fee, + slippage_pct=args.slippage, + use_ml=not args.no_ml, + 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, + ) + logger.info(f"Walk-Forward 백테스트 시작: {', '.join(symbols)} " + f"(학습 {cfg.train_months}개월, 검증 {cfg.test_months}개월)") + wf = WalkForwardBacktester(cfg) + result = wf.run() + print_summary(result["summary"], cfg, mode="walk_forward") + if result.get("folds"): + print_fold_table(result["folds"]) + save_result(result, cfg) + else: + cfg = BacktestConfig( + symbols=symbols, + start=args.start, + end=args.end, + initial_balance=args.balance, + leverage=args.leverage, + fee_pct=args.fee, + slippage_pct=args.slippage, + use_ml=not args.no_ml, + 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, + ) + logger.info(f"백테스트 시작: {', '.join(symbols)}") + bt = Backtester(cfg) + result = bt.run() + print_summary(result["summary"], cfg) + save_result(result, cfg) + + +if __name__ == "__main__": + main() diff --git a/scripts/strategy_sweep.py b/scripts/strategy_sweep.py new file mode 100644 index 0000000..b7f3647 --- /dev/null +++ b/scripts/strategy_sweep.py @@ -0,0 +1,317 @@ +#!/usr/bin/env python3 +""" +전략 파라미터 스윕: 기존 백테스터를 활용하여 파라미터 조합별 성능을 비교한다. +ML 필터 OFF 상태에서 순수 전략 성능만 측정한다. + +사용법: + python scripts/strategy_sweep.py --symbol XRPUSDT + python scripts/strategy_sweep.py --symbol XRPUSDT --train-months 3 --test-months 1 + python scripts/strategy_sweep.py --symbols XRPUSDT,TRXUSDT,DOGEUSDT + python scripts/strategy_sweep.py --symbols XRPUSDT,TRXUSDT,DOGEUSDT --combined +""" +import sys +from pathlib import Path +sys.path.insert(0, str(Path(__file__).parent.parent)) + +import argparse +import json +import itertools +from datetime import datetime + +import numpy as np +from loguru import logger + +from src.backtester import Backtester, BacktestConfig, WalkForwardBacktester, WalkForwardConfig + + +# ── 스윕 파라미터 정의 ──────────────────────────────────────────────── +PARAM_GRID = { + "atr_sl_mult": [1.0, 1.5, 2.0], + "atr_tp_mult": [2.0, 3.0, 4.0], + "signal_threshold": [3, 4, 5], + "adx_threshold": [0, 20, 25, 30], + "volume_multiplier": [1.5, 2.0, 2.5], +} + +# 현재 프로덕션 파라미터 +CURRENT_PARAMS = { + "atr_sl_mult": 2.0, + "atr_tp_mult": 2.0, + "signal_threshold": 3, + "adx_threshold": 25, + "volume_multiplier": 2.5, +} + +EMPTY_SUMMARY = { + "total_trades": 0, "total_pnl": 0, "return_pct": 0, "win_rate": 0, + "avg_win": 0, "avg_loss": 0, "profit_factor": 0, + "max_drawdown_pct": 0, "sharpe_ratio": 0, "total_fees": 0, "close_reasons": {}, +} + + +def generate_combinations(grid: dict) -> list[dict]: + keys = list(grid.keys()) + values = list(grid.values()) + combos = [] + for combo in itertools.product(*values): + combos.append(dict(zip(keys, combo))) + return combos + + +def run_single_backtest(symbols: list[str], params: dict, train_months: int, test_months: int) -> dict: + """단일 파라미터 조합으로 walk-forward 백테스트 실행.""" + cfg = WalkForwardConfig( + symbols=symbols, + use_ml=False, + train_months=train_months, + test_months=test_months, + atr_sl_mult=params["atr_sl_mult"], + atr_tp_mult=params["atr_tp_mult"], + signal_threshold=params["signal_threshold"], + adx_threshold=params["adx_threshold"], + volume_multiplier=params["volume_multiplier"], + ) + wf = WalkForwardBacktester(cfg) + result = wf.run() + return result["summary"] + + +def run_combined_backtest(symbols: list[str], params: dict, train_months: int, test_months: int) -> dict: + """심볼별 독립 walk-forward 실행 후 합산 결과 반환.""" + per_symbol = {} + total_gross_profit = 0.0 + total_gross_loss = 0.0 + total_trades = 0 + total_pnl = 0.0 + + for sym in symbols: + try: + summary = run_single_backtest([sym], params, train_months, test_months) + except Exception as e: + logger.warning(f" {sym} 실패: {e}") + summary = EMPTY_SUMMARY.copy() + + per_symbol[sym] = summary + + # gross profit/loss 역산 + n = summary["total_trades"] + if n > 0: + wr = summary["win_rate"] / 100.0 + n_wins = round(wr * n) + n_losses = n - n_wins + gp = summary["avg_win"] * n_wins if n_wins > 0 else 0.0 + gl = abs(summary["avg_loss"]) * n_losses if n_losses > 0 else 0.0 + total_gross_profit += gp + total_gross_loss += gl + total_trades += n + total_pnl += summary["total_pnl"] + + combined_pf = (total_gross_profit / total_gross_loss) if total_gross_loss > 0 else float("inf") + + return { + "params": params, + "combined_pf": round(combined_pf, 2), + "combined_trades": total_trades, + "combined_pnl": round(total_pnl, 2), + "per_symbol": per_symbol, + } + + +def print_results_table(results: list[dict], symbols: list[str], train_months: int, test_months: int): + sym_str = ",".join(symbols) + print(f"\n{'=' * 100}") + print(f" Strategy Parameter Sweep Results ({sym_str}, Walk-Forward {train_months}/{test_months})") + print(f"{'=' * 100}") + print(f" {'Rank':>4} {'SL×ATR':>6} {'TP×ATR':>6} {'Signal':>6} {'ADX':>4} {'Vol':>4} " + f"{'Trades':>6} {'WinRate':>7} {'PF':>6} {'MDD':>5} {'PnL':>10} {'Sharpe':>6}") + print(f" {'-' * 94}") + + for i, r in enumerate(results): + p = r["params"] + s = r["summary"] + pf = s["profit_factor"] + pf_str = f"{pf:.2f}" if pf != float("inf") else "INF" + + is_current = all(p[k] == CURRENT_PARAMS[k] for k in CURRENT_PARAMS) + marker = " ← CURRENT" if is_current else "" + + print(f" {i+1:>4} {p['atr_sl_mult']:>6.1f} {p['atr_tp_mult']:>6.1f} " + f"{p['signal_threshold']:>6} {p['adx_threshold']:>4.0f} {p['volume_multiplier']:>4.1f} " + f"{s['total_trades']:>6} {s['win_rate']:>6.1f}% {pf_str:>6} {s['max_drawdown_pct']:>4.1f}% " + f"{s['total_pnl']:>+10.2f} {s['sharpe_ratio']:>6.1f}{marker}") + + print(f"{'=' * 100}") + + +def print_combined_results_table(results: list[dict], symbols: list[str], + train_months: int, test_months: int, + min_pf_count: int = 2, min_pf: float = 0.9): + sym_str = ",".join(symbols) + # 심볼 약칭 + short = {s: s.replace("USDT", "") for s in symbols} + + print(f"\n{'=' * 130}") + print(f" Combined Strategy Sweep ({sym_str}, WF {train_months}/{test_months})") + print(f" Filter: {min_pf_count}+ symbols with PF >= {min_pf}") + print(f"{'=' * 130}") + + # 헤더 + sym_headers = " ".join(f"{short[s]:>12s}" for s in symbols) + print(f" {'Rank':>4} {'SL':>4} {'TP':>4} {'Sig':>3} {'ADX':>3} {'Vol':>4} " + f"{'Tot':>4} {'CombPF':>6} {'PnL':>9} {sym_headers}") + + # 심볼별 서브헤더 + sub = " ".join(f"{'PF/WR%/Trd':>12s}" for _ in symbols) + print(f" {'':>4} {'':>4} {'':>4} {'':>3} {'':>3} {'':>4} " + f"{'':>4} {'':>6} {'':>9} {sub}") + print(f" {'-' * 124}") + + for i, r in enumerate(results): + p = r["params"] + cpf = r["combined_pf"] + cpf_str = f"{cpf:.2f}" if cpf != float("inf") else "INF" + + is_current = all(p[k] == CURRENT_PARAMS[k] for k in CURRENT_PARAMS) + marker = " ←CUR" if is_current else "" + + # 심볼별 PF/WR/Trades + sym_cols = [] + for s in symbols: + ss = r["per_symbol"][s] + spf = ss["profit_factor"] + spf_str = f"{spf:.1f}" if spf != float("inf") else "INF" + sym_cols.append(f"{spf_str}/{ss['win_rate']:.0f}%/{ss['total_trades']}") + + sym_detail = " ".join(f"{c:>12s}" for c in sym_cols) + + print(f" {i+1:>4} {p['atr_sl_mult']:>4.1f} {p['atr_tp_mult']:>4.1f} " + f"{p['signal_threshold']:>3} {p['adx_threshold']:>3.0f} {p['volume_multiplier']:>4.1f} " + f"{r['combined_trades']:>4} {cpf_str:>6} {r['combined_pnl']:>+9.1f} " + f"{sym_detail}{marker}") + + print(f"{'=' * 130}") + print(f" 표시된 조합: {len(results)}개 / 전체 324개") + print(f" 심볼별 칼럼: PF/승률%/거래수") + + +def save_results(results: list[dict], symbols: list[str]): + ts = datetime.now().strftime("%Y%m%d_%H%M%S") + for sym in symbols: + out_dir = Path(f"results/{sym.lower()}") + out_dir.mkdir(parents=True, exist_ok=True) + path = out_dir / f"strategy_sweep_{ts}.json" + + if len(symbols) > 1: + out_dir = Path("results/combined") + out_dir.mkdir(parents=True, exist_ok=True) + path = out_dir / f"strategy_sweep_{ts}.json" + + def sanitize(obj): + if isinstance(obj, bool): + return obj + if isinstance(obj, (np.integer,)): + return int(obj) + if isinstance(obj, (np.floating,)): + return float(obj) + if isinstance(obj, float) and obj == float("inf"): + return "Infinity" + 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(results), f, indent=2, ensure_ascii=False) + print(f"결과 저장: {path}") + + +def main(): + p = argparse.ArgumentParser(description="Strategy Parameter Sweep") + group = p.add_mutually_exclusive_group(required=True) + group.add_argument("--symbol", type=str) + group.add_argument("--symbols", type=str) + p.add_argument("--train-months", type=int, default=3) + p.add_argument("--test-months", type=int, default=1) + p.add_argument("--combined", action="store_true", + help="심볼별 독립 실행 후 합산 PF 기준 정렬 (--symbols 필수)") + p.add_argument("--min-pf", type=float, default=0.9, + help="심볼별 최소 PF 필터 (기본: 0.9)") + p.add_argument("--min-pf-count", type=int, default=2, + help="최소 PF 충족 심볼 수 (기본: 2)") + args = p.parse_args() + + symbols = [args.symbol.upper()] if args.symbol else [s.strip().upper() for s in args.symbols.split(",")] + + if args.combined: + if len(symbols) < 2: + logger.error("--combined 모드는 --symbols에 2개 이상 심볼 필요") + sys.exit(1) + run_combined_sweep(symbols, args) + else: + run_single_sweep(symbols, args) + + +def run_single_sweep(symbols: list[str], args): + combos = generate_combinations(PARAM_GRID) + logger.info(f"스윕 시작: {len(combos)}개 조합, 심볼={','.join(symbols)}") + + results = [] + for i, params in enumerate(combos): + param_str = " | ".join(f"{k}={v}" for k, v in params.items()) + logger.info(f" [{i+1}/{len(combos)}] {param_str}") + + try: + summary = run_single_backtest(symbols, params, args.train_months, args.test_months) + results.append({"params": params, "summary": summary}) + except Exception as e: + logger.warning(f" 실패: {e}") + results.append({"params": params, "summary": EMPTY_SUMMARY.copy()}) + + # PF 기준 내림차순 정렬 + def sort_key(r): + pf = r["summary"]["profit_factor"] + return pf if pf != float("inf") else 999 + results.sort(key=sort_key, reverse=True) + + print_results_table(results, symbols, args.train_months, args.test_months) + save_results(results, symbols) + + +def run_combined_sweep(symbols: list[str], args): + combos = generate_combinations(PARAM_GRID) + total_runs = len(combos) * len(symbols) + logger.info(f"합산 스윕 시작: {len(combos)}개 조합 × {len(symbols)}심볼 = {total_runs}회") + + results = [] + for i, params in enumerate(combos): + param_str = " | ".join(f"{k}={v}" for k, v in params.items()) + logger.info(f" [{i+1}/{len(combos)}] {param_str}") + + r = run_combined_backtest(symbols, params, args.train_months, args.test_months) + results.append(r) + + # 필터: N개 이상 심볼에서 PF >= min_pf + filtered = [] + for r in results: + pf_pass = sum( + 1 for s in symbols + if r["per_symbol"][s]["profit_factor"] >= args.min_pf + and r["per_symbol"][s]["total_trades"] > 0 + ) + if pf_pass >= args.min_pf_count: + filtered.append(r) + + # 합산 PF 기준 정렬 + def sort_key(r): + pf = r["combined_pf"] + return pf if pf != float("inf") else 999 + filtered.sort(key=sort_key, reverse=True) + + print_combined_results_table(filtered, symbols, args.train_months, args.test_months, + min_pf_count=args.min_pf_count, min_pf=args.min_pf) + save_results(filtered, symbols) + + +if __name__ == "__main__": + main() diff --git a/src/backtest_validator.py b/src/backtest_validator.py new file mode 100644 index 0000000..8856e8c --- /dev/null +++ b/src/backtest_validator.py @@ -0,0 +1,228 @@ +""" +백테스트 결과 Sanity Check 검증. +논리적 불변 조건(FAIL) + 통계적 이상 감지(WARNING)를 수행한다. +""" +from __future__ import annotations + +from dataclasses import dataclass +import pandas as pd + + +RED = "\033[91m" +GREEN = "\033[92m" +YELLOW = "\033[93m" +RESET = "\033[0m" + + +@dataclass +class CheckResult: + name: str + passed: bool + level: str # "FAIL" | "WARNING" + message: str + + +def validate(trades: list[dict], summary: dict, cfg) -> dict: + """ + 모든 검증을 실행하고 결과를 dict로 반환한다. + CLI에도 PASS/WARNING/FAIL을 출력한다. + """ + results: list[CheckResult] = [] + + # 검증 1: 논리적 불변 조건 + results.extend(_check_invariants(trades)) + + # 검증 2: 통계적 이상 감지 + results.extend(_check_statistics(trades, summary)) + + # 결과 출력 + _print_results(results) + + return { + "overall": "PASS" if all(r.passed for r in results) else "FAIL", + "checks": [ + {"name": r.name, "passed": r.passed, "level": r.level, "message": r.message} + for r in results + ], + } + + +def _check_invariants(trades: list[dict]) -> list[CheckResult]: + """논리적 불변 조건. 하나라도 위반 시 FAIL.""" + results = [] + + if not trades: + results.append(CheckResult( + "trade_count", True, "FAIL", "트레이드 없음 (검증 스킵)" + )) + return results + + # 1. 청산 시각 >= 진입 시각 (END_OF_DATA는 동일 캔들 가능) + bad_times = [] + for i, t in enumerate(trades): + if pd.Timestamp(t["exit_time"]) < pd.Timestamp(t["entry_time"]): + bad_times.append(i) + passed = len(bad_times) == 0 + results.append(CheckResult( + "exit_after_entry", + passed, + "FAIL", + f"모든 트레이드에서 청산 > 진입" if passed else f"위반 트레이드 인덱스: {bad_times}", + )) + + # 2. SL/TP 방향 정합성 + bad_sltp = [] + for i, t in enumerate(trades): + if t["side"] == "LONG": + if not (t["sl"] < t["entry_price"] < t["tp"]): + bad_sltp.append(i) + else: + if not (t["tp"] < t["entry_price"] < t["sl"]): + bad_sltp.append(i) + passed = len(bad_sltp) == 0 + results.append(CheckResult( + "sl_tp_direction", + passed, + "FAIL", + "SL/TP 방향 정합" if passed else f"위반 트레이드 인덱스: {bad_sltp}", + )) + + # 3. 포지션 비중첩 (같은 심볼에서 직전 청산 ≤ 다음 진입) + by_symbol: dict[str, list[dict]] = {} + for t in trades: + by_symbol.setdefault(t["symbol"], []).append(t) + + overlap_symbols = [] + for sym, sym_trades in by_symbol.items(): + sorted_trades = sorted(sym_trades, key=lambda x: pd.Timestamp(x["entry_time"])) + for j in range(1, len(sorted_trades)): + prev_exit = pd.Timestamp(sorted_trades[j - 1]["exit_time"]) + curr_entry = pd.Timestamp(sorted_trades[j]["entry_time"]) + if prev_exit > curr_entry: + overlap_symbols.append(sym) + break + passed = len(overlap_symbols) == 0 + results.append(CheckResult( + "no_overlap", + passed, + "FAIL", + "포지션 비중첩 확인" if passed else f"중첩 심볼: {overlap_symbols}", + )) + + # 4. 수수료 항상 양수 + bad_fees = [i for i, t in enumerate(trades) if t["entry_fee"] <= 0 or t["exit_fee"] <= 0] + passed = len(bad_fees) == 0 + results.append(CheckResult( + "positive_fees", + passed, + "FAIL", + "수수료 양수 확인" if passed else f"위반 트레이드 인덱스: {bad_fees}", + )) + + # 5. 잔고가 음수가 된 적 없음 + balance = 1000.0 # cfg.initial_balance를 몰라도 trades에서 추적 가능 + min_balance = balance + for t in trades: + balance += t["net_pnl"] + min_balance = min(min_balance, balance) + passed = min_balance >= 0 + results.append(CheckResult( + "no_negative_balance", + passed, + "FAIL", + "잔고 양수 유지" if passed else f"최저 잔고: {min_balance:.4f}", + )) + + return results + + +def _check_statistics(trades: list[dict], summary: dict) -> list[CheckResult]: + """통계적 이상 감지. WARNING 수준.""" + results = [] + + if not trades: + return results + + win_rate = summary.get("win_rate", 0) + mdd = summary.get("max_drawdown_pct", 0) + pf = summary.get("profit_factor", 0) + + # 승률 > 80% + passed = win_rate <= 80 + results.append(CheckResult( + "win_rate_high", + passed, + "WARNING", + f"승률 정상 ({win_rate:.1f}%)" if passed else f"승률 {win_rate:.1f}% > 80% — look-ahead bias 의심", + )) + + # 승률 < 20% + passed = win_rate >= 20 + results.append(CheckResult( + "win_rate_low", + passed, + "WARNING", + f"승률 정상 ({win_rate:.1f}%)" if passed else f"승률 {win_rate:.1f}% < 20% — 신호 로직 반전 의심", + )) + + # MDD 0% + passed = mdd > 0 + results.append(CheckResult( + "mdd_nonzero", + passed, + "WARNING", + f"MDD 정상 ({mdd:.1f}%)" if passed else "MDD 0% — SL 미작동 의심", + )) + + # 월 평균 거래 < 5건 + if len(trades) >= 2: + first = pd.Timestamp(trades[0]["entry_time"]) + last = pd.Timestamp(trades[-1]["entry_time"]) + months = max(1, (last - first).days / 30) + trades_per_month = len(trades) / months + passed = trades_per_month >= 5 + results.append(CheckResult( + "trade_frequency", + passed, + "WARNING", + f"월 평균 {trades_per_month:.1f}건" if passed else f"월 평균 {trades_per_month:.1f}건 < 5건 — 신호 생성 부족", + )) + + # Profit Factor > 5.0 + if pf != float("inf"): + passed = pf <= 5.0 + results.append(CheckResult( + "profit_factor_high", + passed, + "WARNING", + f"PF 정상 ({pf:.2f})" if passed else f"PF {pf:.2f} > 5.0 — 비현실적 수익", + )) + + return results + + +def _print_results(results: list[CheckResult]): + print("\n" + "=" * 60) + print(" BACKTEST SANITY CHECK") + print("=" * 60) + + has_fail = any(not r.passed and r.level == "FAIL" for r in results) + has_warn = any(not r.passed and r.level == "WARNING" for r in results) + + for r in results: + if r.passed: + status = f"{GREEN}PASS{RESET}" + elif r.level == "FAIL": + status = f"{RED}FAIL{RESET}" + else: + status = f"{YELLOW}WARNING{RESET}" + print(f" [{status}] {r.name}: {r.message}") + + print("-" * 60) + if has_fail: + print(f" {RED}RESULT: FAIL — 논리적 불변 조건 위반{RESET}") + elif has_warn: + print(f" {YELLOW}RESULT: WARNING — 수동 확인 필요{RESET}") + else: + print(f" {GREEN}RESULT: ALL PASS{RESET}") + print("=" * 60 + "\n") diff --git a/src/backtester.py b/src/backtester.py new file mode 100644 index 0000000..f0d9c55 --- /dev/null +++ b/src/backtester.py @@ -0,0 +1,837 @@ +""" +독립 백테스트 엔진. +봇 코드(src/bot.py)를 수정하지 않고, 기존 모듈을 재활용하여 +풀 파이프라인(지표 → 시그널 → ML 필터 → 진입/청산)을 동기 루프로 시뮬레이션한다. +""" +from __future__ import annotations + +import json +from dataclasses import dataclass, field, asdict +from datetime import datetime +from pathlib import Path + +import numpy as np +import pandas as pd +from loguru import logger + +import warnings + +import joblib +import lightgbm as lgb + +from src.dataset_builder import ( + _calc_indicators, _calc_signals, _calc_features_vectorized, + generate_dataset_vectorized, stratified_undersample, +) +from src.ml_features import FEATURE_COLS +from src.ml_filter import MLFilter + + +# ── 설정 ───────────────────────────────────────────────────────────── +@dataclass +class BacktestConfig: + symbols: list[str] = field(default_factory=lambda: ["XRPUSDT"]) + start: str | None = None + end: str | None = None + initial_balance: float = 1000.0 + leverage: int = 10 + fee_pct: float = 0.04 # taker 수수료 (%) + slippage_pct: float = 0.01 # 슬리피지 (%) + use_ml: bool = True + ml_threshold: float = 0.55 + # 리스크 + max_daily_loss_pct: float = 0.05 + max_positions: int = 3 + max_same_direction: int = 2 + # 증거금 + margin_max_ratio: float = 0.50 + margin_min_ratio: float = 0.20 + margin_decay_rate: float = 0.0006 + # SL/TP ATR 배수 + atr_sl_mult: float = 2.0 + atr_tp_mult: float = 2.0 + min_notional: float = 5.0 + # 전략 파라미터 + signal_threshold: int = 3 + adx_threshold: float = 25.0 + volume_multiplier: float = 2.5 + + WARMUP = 60 # 지표 안정화에 필요한 캔들 수 + + +# ── 포지션 상태 ────────────────────────────────────────────────────── +@dataclass +class Position: + symbol: str + side: str # "LONG" | "SHORT" + entry_price: float + quantity: float + sl: float + tp: float + entry_time: pd.Timestamp + entry_fee: float + entry_indicators: dict = field(default_factory=dict) + ml_proba: float | None = None + + +# ── 동기 RiskManager ───────────────────────────────────────────────── +class BacktestRiskManager: + def __init__(self, cfg: BacktestConfig): + self.cfg = cfg + self.daily_pnl: float = 0.0 + self.initial_balance: float = cfg.initial_balance + self.base_balance: float = cfg.initial_balance + self.open_positions: dict[str, str] = {} # {symbol: side} + self._current_date: str | None = None + + def new_day(self, date_str: str): + if self._current_date != date_str: + self._current_date = date_str + self.daily_pnl = 0.0 + + def is_trading_allowed(self) -> bool: + if self.initial_balance <= 0: + return True + if self.daily_pnl < 0 and abs(self.daily_pnl) / self.initial_balance >= self.cfg.max_daily_loss_pct: + return False + return True + + def can_open(self, symbol: str, side: str) -> bool: + if len(self.open_positions) >= self.cfg.max_positions: + return False + if symbol in self.open_positions: + return False + same_dir = sum(1 for s in self.open_positions.values() if s == side) + if same_dir >= self.cfg.max_same_direction: + return False + return True + + def register(self, symbol: str, side: str): + self.open_positions[symbol] = side + + def close(self, symbol: str, pnl: float): + self.open_positions.pop(symbol, None) + self.daily_pnl += pnl + + def get_dynamic_margin_ratio(self, balance: float) -> float: + ratio = self.cfg.margin_max_ratio - ( + (balance - self.base_balance) * self.cfg.margin_decay_rate + ) + return max(self.cfg.margin_min_ratio, min(self.cfg.margin_max_ratio, ratio)) + + +# ── 유틸 ───────────────────────────────────────────────────────────── +def _apply_slippage(price: float, side: str, slippage_pct: float) -> float: + """시장가 주문의 슬리피지 적용. BUY는 불리하게(+), SELL은 불리하게(-).""" + factor = slippage_pct / 100.0 + if side == "BUY": + return price * (1 + factor) + return price * (1 - factor) + + +def _calc_fee(price: float, quantity: float, fee_pct: float) -> float: + return price * quantity * fee_pct / 100.0 + + +def _load_data(symbol: str, start: str | None, end: str | None) -> pd.DataFrame: + path = Path(f"data/{symbol.lower()}/combined_15m.parquet") + if not path.exists(): + raise FileNotFoundError(f"데이터 파일 없음: {path}") + df = pd.read_parquet(path) + if "timestamp" in df.columns: + df["timestamp"] = pd.to_datetime(df["timestamp"]) + df = df.set_index("timestamp").sort_index() + elif not isinstance(df.index, pd.DatetimeIndex): + df.index = pd.to_datetime(df.index) + df = df.sort_index() + # tz-aware → tz-naive 통일 (UTC 기준) + if df.index.tz is not None: + df.index = df.index.tz_localize(None) + if start: + df = df[df.index >= pd.Timestamp(start)] + if end: + df = df[df.index <= pd.Timestamp(end)] + return df + + +def _get_ml_proba(ml_filter: MLFilter | None, features: pd.Series) -> float | None: + """ML 확률을 반환. 모델이 없거나 비활성이면 None.""" + if ml_filter is None or not ml_filter.is_model_loaded(): + return None + try: + if ml_filter._onnx_session is not None: + input_name = ml_filter._onnx_session.get_inputs()[0].name + X = features[FEATURE_COLS].values.astype(np.float32).reshape(1, -1) + return float(ml_filter._onnx_session.run(None, {input_name: X})[0][0]) + else: + X = features.to_frame().T + return float(ml_filter._lgbm_model.predict_proba(X)[0][1]) + except Exception: + return None + + +# ── 메인 엔진 ──────────────────────────────────────────────────────── +class Backtester: + def __init__(self, cfg: BacktestConfig): + self.cfg = cfg + self.risk = BacktestRiskManager(cfg) + self.balance = cfg.initial_balance + self.positions: dict[str, Position] = {} # {symbol: Position} + self.trades: list[dict] = [] + self.equity_curve: list[dict] = [] + self._peak_equity: float = cfg.initial_balance + + # ML 필터 (심볼별) + self.ml_filters: dict[str, MLFilter | None] = {} + if cfg.use_ml: + for sym in cfg.symbols: + sym_dir = Path(f"models/{sym.lower()}") + onnx = str(sym_dir / "mlx_filter.weights.onnx") + lgbm = str(sym_dir / "lgbm_filter.pkl") + if not sym_dir.exists(): + onnx = "models/mlx_filter.weights.onnx" + lgbm = "models/lgbm_filter.pkl" + mf = MLFilter(onnx_path=onnx, lgbm_path=lgbm, threshold=cfg.ml_threshold) + self.ml_filters[sym] = mf if mf.is_model_loaded() else None + else: + for sym in cfg.symbols: + self.ml_filters[sym] = None + + def run(self, ml_models: dict[str, object] | None = None) -> dict: + """백테스트 실행. 결과 dict(config, summary, trades, validation) 반환. + + ml_models: walk-forward에서 심볼별 사전 학습 모델을 전달할 때 사용. + {symbol: lgbm_model} 형태. None이면 기존 파일 기반 MLFilter 사용. + """ + # 데이터 로드 + all_data: dict[str, pd.DataFrame] = {} + all_indicators: dict[str, pd.DataFrame] = {} + all_signals: dict[str, np.ndarray] = {} + all_features: dict[str, pd.DataFrame] = {} + + # BTC/ETH 상관 데이터 (있으면 로드) + btc_df = self._try_load_corr("BTCUSDT") + eth_df = self._try_load_corr("ETHUSDT") + + for sym in self.cfg.symbols: + df = _load_data(sym, self.cfg.start, self.cfg.end) + all_data[sym] = df + df_ind = _calc_indicators(df) + all_indicators[sym] = df_ind + sig_arr = _calc_signals( + df_ind, + signal_threshold=self.cfg.signal_threshold, + adx_threshold=self.cfg.adx_threshold, + volume_multiplier=self.cfg.volume_multiplier, + ) + all_signals[sym] = sig_arr + # 벡터화 피처 미리 계산 (학습과 동일한 z-score 적용) + all_features[sym] = _calc_features_vectorized( + df_ind, sig_arr, btc_df=btc_df, eth_df=eth_df, + ) + logger.info(f"[{sym}] 데이터 로드: {len(df):,}캔들 ({df.index[0]} ~ {df.index[-1]})") + + # walk-forward 모델 주입 + if ml_models is not None: + self.ml_filters = {} + for sym in self.cfg.symbols: + if sym in ml_models and ml_models[sym] is not None: + mf = MLFilter.__new__(MLFilter) + mf._disabled = False + mf._onnx_session = None + mf._lgbm_model = ml_models[sym] + mf._threshold = self.cfg.ml_threshold + mf._onnx_path = Path("/dev/null") + mf._lgbm_path = Path("/dev/null") + mf._loaded_onnx_mtime = 0.0 + mf._loaded_lgbm_mtime = 0.0 + self.ml_filters[sym] = mf + else: + self.ml_filters[sym] = None + + # 멀티심볼: 타임스탬프 기준 통합 이벤트 생성 + events = self._build_events(all_indicators, all_signals) + logger.info(f"총 이벤트: {len(events):,}개") + + # 메인 루프 + for ts, sym, candle_idx in events: + date_str = str(ts.date()) + self.risk.new_day(date_str) + + df_ind = all_indicators[sym] + signal = all_signals[sym][candle_idx] + row = df_ind.iloc[candle_idx] + + # 에퀴티 기록 + self._record_equity(ts) + + # 1) 일일 손실 체크 + if not self.risk.is_trading_allowed(): + continue + + # 2) SL/TP 체크 (보유 포지션) + if sym in self.positions: + closed = self._check_sl_tp(sym, row, ts) + if closed: + continue + + # 3) 반대 시그널 재진입 + if sym in self.positions and signal != "HOLD": + pos = self.positions[sym] + if (pos.side == "LONG" and signal == "SHORT") or \ + (pos.side == "SHORT" and signal == "LONG"): + self._close_position(sym, row["close"], ts, "REVERSE_SIGNAL") + # 새 방향으로 재진입 시도 + if self.risk.can_open(sym, signal): + self._try_enter( + sym, signal, df_ind, candle_idx, + all_features[sym], ts=ts, + ) + continue + + # 4) 신규 진입 + if sym not in self.positions and signal != "HOLD": + if self.risk.can_open(sym, signal): + self._try_enter( + sym, signal, df_ind, candle_idx, + all_features[sym], ts=ts, + ) + + # 미청산 포지션 강제 청산 + for sym in list(self.positions.keys()): + last_df = all_indicators[sym] + last_price = last_df["close"].iloc[-1] + last_ts = last_df.index[-1] + self._close_position(sym, last_price, last_ts, "END_OF_DATA") + + return self._build_result() + + def _try_load_corr(self, symbol: str) -> pd.DataFrame | None: + path = Path(f"data/{symbol.lower()}/combined_15m.parquet") + if not path.exists(): + alt = Path(f"data/combined_15m.parquet") + if not alt.exists(): + return None + path = alt + try: + df = pd.read_parquet(path) + if "timestamp" in df.columns: + df["timestamp"] = pd.to_datetime(df["timestamp"]) + df = df.set_index("timestamp").sort_index() + elif not isinstance(df.index, pd.DatetimeIndex): + df.index = pd.to_datetime(df.index) + df = df.sort_index() + if df.index.tz is not None: + df.index = df.index.tz_localize(None) + if self.cfg.start: + df = df[df.index >= pd.Timestamp(self.cfg.start)] + if self.cfg.end: + df = df[df.index <= pd.Timestamp(self.cfg.end)] + return df + except Exception: + return None + + def _build_events( + self, + all_indicators: dict[str, pd.DataFrame], + all_signals: dict[str, np.ndarray], + ) -> list[tuple[pd.Timestamp, str, int]]: + """모든 심볼의 캔들을 타임스탬프 순서로 정렬한 이벤트 리스트 생성.""" + events = [] + for sym, df_ind in all_indicators.items(): + for i in range(self.cfg.WARMUP, len(df_ind)): + ts = df_ind.index[i] + events.append((ts, sym, i)) + events.sort(key=lambda x: (x[0], x[1])) + return events + + def _check_sl_tp(self, symbol: str, row: pd.Series, ts: pd.Timestamp) -> bool: + """캔들의 고가/저가로 SL/TP 체크. SL 우선. 청산 시 True 반환.""" + pos = self.positions[symbol] + high = row["high"] + low = row["low"] + + if pos.side == "LONG": + # SL 먼저 (보수적) + if low <= pos.sl: + self._close_position(symbol, pos.sl, ts, "STOP_LOSS") + return True + if high >= pos.tp: + self._close_position(symbol, pos.tp, ts, "TAKE_PROFIT") + return True + else: # SHORT + if high >= pos.sl: + self._close_position(symbol, pos.sl, ts, "STOP_LOSS") + return True + if low <= pos.tp: + self._close_position(symbol, pos.tp, ts, "TAKE_PROFIT") + return True + return False + + def _try_enter( + self, + symbol: str, + signal: str, + df_ind: pd.DataFrame, + candle_idx: int, + feat_df: pd.DataFrame, + ts: pd.Timestamp, + ): + """ML 필터 + 포지션 크기 계산 → 진입.""" + row = df_ind.iloc[candle_idx] + + # 벡터화된 피처에서 해당 행을 lookup (학습과 동일한 z-score 적용) + available_cols = [c for c in FEATURE_COLS if c in feat_df.columns] + features = feat_df.iloc[candle_idx][available_cols] + + # ML 필터 + ml_filter = self.ml_filters.get(symbol) + ml_proba = _get_ml_proba(ml_filter, features) + + if ml_filter is not None and ml_filter.is_model_loaded(): + if ml_proba is not None and ml_proba < self.cfg.ml_threshold: + return # ML 차단 + + # 포지션 크기 계산 + num_symbols = len(self.cfg.symbols) + per_symbol_balance = self.balance / num_symbols + price = float(row["close"]) + margin_ratio = self.risk.get_dynamic_margin_ratio(self.balance) + notional = per_symbol_balance * margin_ratio * self.cfg.leverage + if notional < self.cfg.min_notional: + notional = self.cfg.min_notional + quantity = round(notional / price, 1) + if quantity * price < self.cfg.min_notional: + quantity = round(self.cfg.min_notional / price + 0.05, 1) + if quantity <= 0 or quantity * price < self.cfg.min_notional: + return + + # 슬리피지 적용 (시장가 진입) + buy_side = "BUY" if signal == "LONG" else "SELL" + entry_price = _apply_slippage(price, buy_side, self.cfg.slippage_pct) + + # 수수료 + entry_fee = _calc_fee(entry_price, quantity, self.cfg.fee_pct) + self.balance -= entry_fee + + # SL/TP 계산 + atr = float(row.get("atr", 0)) + if atr <= 0: + return + if signal == "LONG": + sl = entry_price - atr * self.cfg.atr_sl_mult + tp = entry_price + atr * self.cfg.atr_tp_mult + else: + sl = entry_price + atr * self.cfg.atr_sl_mult + tp = entry_price - atr * self.cfg.atr_tp_mult + + indicators_snapshot = { + "rsi": float(row.get("rsi", 0)), + "macd_hist": float(row.get("macd_hist", 0)), + "atr": float(atr), + "adx": float(row.get("adx", 0)), + } + + pos = Position( + symbol=symbol, + side=signal, + entry_price=entry_price, + quantity=quantity, + sl=sl, + tp=tp, + entry_time=ts, + entry_fee=entry_fee, + entry_indicators=indicators_snapshot, + ml_proba=ml_proba, + ) + self.positions[symbol] = pos + self.risk.register(symbol, signal) + + def _close_position( + self, symbol: str, exit_price: float, ts: pd.Timestamp, reason: str + ): + pos = self.positions.pop(symbol) + + # SL/TP 히트는 지정가이므로 슬리피지 없음. 그 외는 시장가. + if reason in ("REVERSE_SIGNAL", "END_OF_DATA"): + close_side = "SELL" if pos.side == "LONG" else "BUY" + exit_price = _apply_slippage(exit_price, close_side, self.cfg.slippage_pct) + + exit_fee = _calc_fee(exit_price, pos.quantity, self.cfg.fee_pct) + + if pos.side == "LONG": + gross_pnl = (exit_price - pos.entry_price) * pos.quantity + else: + gross_pnl = (pos.entry_price - exit_price) * pos.quantity + + net_pnl = gross_pnl - pos.entry_fee - exit_fee + self.balance += net_pnl + self.risk.close(symbol, net_pnl) + + trade = { + "symbol": symbol, + "side": pos.side, + "entry_time": str(pos.entry_time), + "exit_time": str(ts), + "entry_price": round(pos.entry_price, 6), + "exit_price": round(exit_price, 6), + "quantity": pos.quantity, + "sl": round(pos.sl, 6), + "tp": round(pos.tp, 6), + "gross_pnl": round(gross_pnl, 6), + "entry_fee": round(pos.entry_fee, 6), + "exit_fee": round(exit_fee, 6), + "net_pnl": round(net_pnl, 6), + "close_reason": reason, + "ml_proba": round(pos.ml_proba, 4) if pos.ml_proba is not None else None, + "indicators": pos.entry_indicators, + } + self.trades.append(trade) + + def _record_equity(self, ts: pd.Timestamp): + # 미실현 PnL 포함 에퀴티 + unrealized = 0.0 + for pos in self.positions.values(): + # 에퀴티 기록 시점에는 현재가를 알 수 없으므로 entry_price 기준으로 0 처리 + pass + equity = self.balance + unrealized + self.equity_curve.append({"timestamp": str(ts), "equity": round(equity, 4)}) + if equity > self._peak_equity: + self._peak_equity = equity + + def _build_result(self) -> dict: + summary = self._calc_summary() + from src.backtest_validator import validate + validation = validate(self.trades, summary, self.cfg) + return { + "config": asdict(self.cfg), + "summary": summary, + "trades": self.trades, + "validation": validation, + } + + def _calc_summary(self) -> dict: + if not self.trades: + return { + "total_trades": 0, + "total_pnl": 0.0, + "return_pct": 0.0, + "win_rate": 0.0, + "avg_win": 0.0, + "avg_loss": 0.0, + "profit_factor": 0.0, + "max_drawdown_pct": 0.0, + "sharpe_ratio": 0.0, + "total_fees": 0.0, + "close_reasons": {}, + } + + pnls = [t["net_pnl"] for t in self.trades] + wins = [p for p in pnls if p > 0] + losses = [p for p in pnls if p <= 0] + + total_pnl = sum(pnls) + total_fees = sum(t["entry_fee"] + t["exit_fee"] for t in self.trades) + gross_profit = sum(wins) if wins else 0.0 + gross_loss = abs(sum(losses)) if losses else 0.0 + + # MDD 계산 + cumulative = np.cumsum(pnls) + equity = self.cfg.initial_balance + cumulative + peak = np.maximum.accumulate(equity) + drawdown = (peak - equity) / peak + mdd = float(np.max(drawdown)) * 100 if len(drawdown) > 0 else 0.0 + + # 샤프비율 (연율화, 15분봉 기준: 252일 * 96봉 = 24192) + if len(pnls) > 1: + pnl_arr = np.array(pnls) + sharpe = float(np.mean(pnl_arr) / np.std(pnl_arr) * np.sqrt(24192)) if np.std(pnl_arr) > 0 else 0.0 + else: + sharpe = 0.0 + + # 청산 사유별 비율 + reasons = {} + for t in self.trades: + r = t["close_reason"] + reasons[r] = reasons.get(r, 0) + 1 + + return { + "total_trades": len(self.trades), + "total_pnl": round(total_pnl, 4), + "return_pct": round(total_pnl / self.cfg.initial_balance * 100, 2), + "win_rate": round(len(wins) / len(self.trades) * 100, 2) if self.trades else 0.0, + "avg_win": round(np.mean(wins), 4) if wins else 0.0, + "avg_loss": round(np.mean(losses), 4) if losses else 0.0, + "profit_factor": round(gross_profit / gross_loss, 2) if gross_loss > 0 else float("inf"), + "max_drawdown_pct": round(mdd, 2), + "sharpe_ratio": round(sharpe, 2), + "total_fees": round(total_fees, 4), + "close_reasons": reasons, + } + + +# ── Walk-Forward 백테스트 ───────────────────────────────────────────── +@dataclass +class WalkForwardConfig(BacktestConfig): + train_months: int = 6 # 학습 윈도우 (개월) + test_months: int = 1 # 검증 윈도우 (개월) + time_weight_decay: float = 2.0 + negative_ratio: int = 5 + + +class WalkForwardBacktester: + """ + Walk-Forward 백테스트: 기간별로 모델을 학습하고 미래 데이터에서만 검증한다. + look-ahead bias를 완전히 제거한다. + """ + + def __init__(self, cfg: WalkForwardConfig): + self.cfg = cfg + + def run(self) -> dict: + # 데이터 로드 (전체 기간) + all_raw: dict[str, pd.DataFrame] = {} + for sym in self.cfg.symbols: + all_raw[sym] = _load_data(sym, self.cfg.start, self.cfg.end) + + # 윈도우 생성 + windows = self._build_windows(all_raw) + logger.info(f"Walk-Forward: {len(windows)}개 윈도우 " + f"(학습 {self.cfg.train_months}개월, 검증 {self.cfg.test_months}개월)") + + all_trades = [] + fold_summaries = [] + + for i, (train_start, train_end, test_start, test_end) in enumerate(windows): + logger.info(f" 폴드 {i+1}/{len(windows)}: " + f"학습 {train_start.date()}~{train_end.date()}, " + f"검증 {test_start.date()}~{test_end.date()}") + + # 심볼별 모델 학습 + models = {} + for sym in self.cfg.symbols: + model = self._train_model( + all_raw[sym], train_start, train_end, sym + ) + models[sym] = model + + # 검증 구간 백테스트 + test_cfg = BacktestConfig( + symbols=self.cfg.symbols, + start=str(test_start.date()), + end=str(test_end.date()), + initial_balance=self.cfg.initial_balance, + leverage=self.cfg.leverage, + fee_pct=self.cfg.fee_pct, + slippage_pct=self.cfg.slippage_pct, + use_ml=self.cfg.use_ml, + ml_threshold=self.cfg.ml_threshold, + max_daily_loss_pct=self.cfg.max_daily_loss_pct, + max_positions=self.cfg.max_positions, + max_same_direction=self.cfg.max_same_direction, + margin_max_ratio=self.cfg.margin_max_ratio, + margin_min_ratio=self.cfg.margin_min_ratio, + margin_decay_rate=self.cfg.margin_decay_rate, + atr_sl_mult=self.cfg.atr_sl_mult, + atr_tp_mult=self.cfg.atr_tp_mult, + min_notional=self.cfg.min_notional, + signal_threshold=self.cfg.signal_threshold, + adx_threshold=self.cfg.adx_threshold, + volume_multiplier=self.cfg.volume_multiplier, + ) + bt = Backtester(test_cfg) + result = bt.run(ml_models=models) + + # 폴드별 트레이드에 폴드 번호 추가 + for t in result["trades"]: + t["fold"] = i + 1 + all_trades.extend(result["trades"]) + + fold_summaries.append({ + "fold": i + 1, + "train_period": f"{train_start.date()} ~ {train_end.date()}", + "test_period": f"{test_start.date()} ~ {test_end.date()}", + "summary": result["summary"], + }) + + # 전체 결과 집계 + return self._aggregate_results(all_trades, fold_summaries) + + def _build_windows( + self, all_raw: dict[str, pd.DataFrame] + ) -> list[tuple[pd.Timestamp, pd.Timestamp, pd.Timestamp, pd.Timestamp]]: + # 모든 심볼의 공통 기간 + start = max(df.index[0] for df in all_raw.values()) + end = min(df.index[-1] for df in all_raw.values()) + + train_delta = pd.DateOffset(months=self.cfg.train_months) + test_delta = pd.DateOffset(months=self.cfg.test_months) + + windows = [] + cursor = start + while cursor + train_delta + test_delta <= end: + train_start = cursor + train_end = cursor + train_delta + test_start = train_end + test_end = test_start + test_delta + windows.append((train_start, train_end, test_start, test_end)) + cursor = test_start # 슬라이딩 (겹침 없음) + + return windows + + def _train_model( + self, + raw_df: pd.DataFrame, + train_start: pd.Timestamp, + train_end: pd.Timestamp, + symbol: str, + ) -> object | None: + """학습 구간 데이터로 LightGBM 모델 학습. 실패 시 None 반환.""" + # tz-naive로 비교 + ts_start = train_start.tz_localize(None) if train_start.tz else train_start + ts_end = train_end.tz_localize(None) if train_end.tz else train_end + idx = raw_df.index + if idx.tz is not None: + idx = idx.tz_localize(None) + train_df = raw_df[(idx >= ts_start) & (idx < ts_end)] + if len(train_df) < 200: + logger.warning(f" [{symbol}] 학습 데이터 부족: {len(train_df)}캔들") + return None + + base_cols = ["open", "high", "low", "close", "volume"] + df = train_df[base_cols].copy() + + # BTC/ETH 상관 데이터 (있으면) + btc_df = eth_df = None + if "close_btc" in train_df.columns: + btc_df = train_df[[c + "_btc" for c in base_cols]].copy() + btc_df.columns = base_cols + if "close_eth" in train_df.columns: + eth_df = train_df[[c + "_eth" for c in base_cols]].copy() + eth_df.columns = base_cols + + try: + dataset = generate_dataset_vectorized( + df, btc_df=btc_df, eth_df=eth_df, + time_weight_decay=self.cfg.time_weight_decay, + negative_ratio=self.cfg.negative_ratio, + signal_threshold=self.cfg.signal_threshold, + adx_threshold=self.cfg.adx_threshold, + volume_multiplier=self.cfg.volume_multiplier, + ) + except Exception as e: + logger.warning(f" [{symbol}] 데이터셋 생성 실패: {e}") + return None + + if dataset.empty or "label" not in dataset.columns: + return None + + actual_cols = [c for c in FEATURE_COLS if c in dataset.columns] + X = dataset[actual_cols].values + y = dataset["label"].values + w = dataset["sample_weight"].values + source = dataset["source"].values if "source" in dataset.columns else np.full(len(X), "signal") + + # 언더샘플링 + idx = stratified_undersample(y, source, seed=42) + + # LightGBM 파라미터 (active 파일 또는 기본값) + lgbm_params = self._load_params(symbol) + + model = lgb.LGBMClassifier(**lgbm_params, random_state=42, verbose=-1) + with warnings.catch_warnings(): + warnings.simplefilter("ignore") + model.fit(X[idx], y[idx], sample_weight=w[idx]) + + return model + + def _load_params(self, symbol: str) -> dict: + """심볼별 active 파라미터 로드. 없으면 기본값.""" + params_path = Path(f"models/{symbol.lower()}/active_lgbm_params.json") + if not params_path.exists(): + params_path = Path("models/active_lgbm_params.json") + + default = { + "n_estimators": 434, + "learning_rate": 0.123659, + "max_depth": 6, + "num_leaves": 14, + "min_child_samples": 10, + "subsample": 0.929062, + "colsample_bytree": 0.946330, + "reg_alpha": 0.573971, + "reg_lambda": 0.000157, + } + + if params_path.exists(): + import json + with open(params_path) as f: + data = json.load(f) + best = dict(data["best_trial"]["params"]) + best.pop("weight_scale", None) + default.update(best) + + return default + + def _aggregate_results( + self, all_trades: list[dict], fold_summaries: list[dict] + ) -> dict: + """폴드별 결과를 합산하여 전체 Walk-Forward 결과 생성.""" + from src.backtest_validator import validate + + # 전체 통계 계산 + if not all_trades: + summary = {"total_trades": 0, "total_pnl": 0.0, "return_pct": 0.0, + "win_rate": 0.0, "avg_win": 0.0, "avg_loss": 0.0, + "profit_factor": 0.0, "max_drawdown_pct": 0.0, + "sharpe_ratio": 0.0, "total_fees": 0.0, "close_reasons": {}} + else: + pnls = [t["net_pnl"] for t in all_trades] + wins = [p for p in pnls if p > 0] + losses = [p for p in pnls if p <= 0] + total_pnl = sum(pnls) + total_fees = sum(t["entry_fee"] + t["exit_fee"] for t in all_trades) + gross_profit = sum(wins) if wins else 0.0 + gross_loss = abs(sum(losses)) if losses else 0.0 + + cumulative = np.cumsum(pnls) + equity = self.cfg.initial_balance + cumulative + peak = np.maximum.accumulate(equity) + drawdown = (peak - equity) / peak + mdd = float(np.max(drawdown)) * 100 if len(drawdown) > 0 else 0.0 + + if len(pnls) > 1: + pnl_arr = np.array(pnls) + sharpe = float(np.mean(pnl_arr) / np.std(pnl_arr) * np.sqrt(24192)) if np.std(pnl_arr) > 0 else 0.0 + else: + sharpe = 0.0 + + reasons = {} + for t in all_trades: + r = t["close_reason"] + reasons[r] = reasons.get(r, 0) + 1 + + summary = { + "total_trades": len(all_trades), + "total_pnl": round(total_pnl, 4), + "return_pct": round(total_pnl / self.cfg.initial_balance * 100, 2), + "win_rate": round(len(wins) / len(all_trades) * 100, 2), + "avg_win": round(np.mean(wins), 4) if wins else 0.0, + "avg_loss": round(np.mean(losses), 4) if losses else 0.0, + "profit_factor": round(gross_profit / gross_loss, 2) if gross_loss > 0 else float("inf"), + "max_drawdown_pct": round(mdd, 2), + "sharpe_ratio": round(sharpe, 2), + "total_fees": round(total_fees, 4), + "close_reasons": reasons, + } + + validation = validate(all_trades, summary, self.cfg) + + return { + "mode": "walk_forward", + "config": asdict(self.cfg), + "summary": summary, + "folds": fold_summaries, + "trades": all_trades, + "validation": validation, + } diff --git a/src/bot.py b/src/bot.py index 98a5290..100d84d 100644 --- a/src/bot.py +++ b/src/bot.py @@ -10,7 +10,7 @@ from src.data_stream import MultiSymbolStream from src.notifier import DiscordNotifier from src.risk_manager import RiskManager from src.ml_filter import MLFilter -from src.ml_features import build_features +from src.ml_features import build_features_aligned from src.user_data_stream import UserDataStream @@ -139,7 +139,12 @@ class TradingBot: ind = Indicators(df) df_with_indicators = ind.calculate_all() - raw_signal = ind.get_signal(df_with_indicators) + raw_signal = ind.get_signal( + df_with_indicators, + signal_threshold=self.config.signal_threshold, + adx_threshold=self.config.adx_threshold, + volume_multiplier=self.config.volume_multiplier, + ) current_price = df_with_indicators["close"].iloc[-1] logger.info(f"[{self.symbol}] 신호: {raw_signal} | 현재가: {current_price:.4f} USDT") @@ -152,7 +157,7 @@ class TradingBot: logger.info(f"[{self.symbol}] 포지션 오픈 불가") return signal = raw_signal - features = build_features( + features = build_features_aligned( df_with_indicators, signal, btc_df=btc_df, eth_df=eth_df, oi_change=oi_change, funding_rate=funding_rate, @@ -185,7 +190,11 @@ class TradingBot: balance=per_symbol_balance, price=price, leverage=self.config.leverage, margin_ratio=margin_ratio ) logger.info(f"[{self.symbol}] 포지션 크기: 잔고={per_symbol_balance:.2f}/{balance:.2f} USDT, 증거금비율={margin_ratio:.1%}, 수량={quantity}") - stop_loss, take_profit = Indicators(df).get_atr_stop(df, signal, price) + stop_loss, take_profit = Indicators(df).get_atr_stop( + df, signal, price, + atr_sl_mult=self.config.atr_sl_mult, + atr_tp_mult=self.config.atr_tp_mult, + ) notional = quantity * price if quantity <= 0 or notional < self.exchange.MIN_NOTIONAL: @@ -339,7 +348,7 @@ class TradingBot: return if self.ml_filter.is_model_loaded(): - features = build_features( + features = build_features_aligned( df, signal, btc_df=btc_df, eth_df=eth_df, oi_change=oi_change, funding_rate=funding_rate, diff --git a/src/config.py b/src/config.py index 3f9b477..dde160a 100644 --- a/src/config.py +++ b/src/config.py @@ -23,6 +23,11 @@ class Config: margin_min_ratio: float = 0.20 margin_decay_rate: float = 0.0006 ml_threshold: float = 0.55 + atr_sl_mult: float = 2.0 + atr_tp_mult: float = 2.0 + signal_threshold: int = 3 + adx_threshold: float = 25.0 + volume_multiplier: float = 2.5 def __post_init__(self): self.api_key = os.getenv("BINANCE_API_KEY", "") @@ -35,6 +40,11 @@ class Config: self.margin_decay_rate = float(os.getenv("MARGIN_DECAY_RATE", "0.0006")) self.ml_threshold = float(os.getenv("ML_THRESHOLD", "0.55")) self.max_same_direction = int(os.getenv("MAX_SAME_DIRECTION", "2")) + self.atr_sl_mult = float(os.getenv("ATR_SL_MULT", "2.0")) + self.atr_tp_mult = float(os.getenv("ATR_TP_MULT", "2.0")) + self.signal_threshold = int(os.getenv("SIGNAL_THRESHOLD", "3")) + self.adx_threshold = float(os.getenv("ADX_THRESHOLD", "25")) + self.volume_multiplier = float(os.getenv("VOL_MULTIPLIER", "2.5")) # symbols: SYMBOLS 환경변수 우선, 없으면 SYMBOL에서 변환 symbols_env = os.getenv("SYMBOLS", "") diff --git a/src/dataset_builder.py b/src/dataset_builder.py index 7d9f517..30344a9 100644 --- a/src/dataset_builder.py +++ b/src/dataset_builder.py @@ -54,10 +54,19 @@ def _calc_indicators(df: pd.DataFrame) -> pd.DataFrame: return d -def _calc_signals(d: pd.DataFrame) -> np.ndarray: +def _calc_signals( + d: pd.DataFrame, + signal_threshold: int = 3, + adx_threshold: float = 25, + volume_multiplier: float = 2.5, +) -> np.ndarray: """ indicators.py get_signal() 로직을 numpy 배열 연산으로 재현한다. 반환: signal_arr — 각 행에 대해 "LONG" | "SHORT" | "HOLD" + + signal_threshold: 최소 가중치 합계 (기본 3) + adx_threshold: ADX 최소값 필터 (0=비활성화) + volume_multiplier: 거래량 급증 배수 (기본 1.5) """ n = len(d) @@ -105,10 +114,11 @@ def _calc_signals(d: pd.DataFrame) -> np.ndarray: short_score += ((stoch_k > 80) & (stoch_k < stoch_d)).astype(np.float32) # 6. 거래량 급증 - vol_surge = volume > vol_ma20 * 1.5 + vol_surge = volume > vol_ma20 * volume_multiplier - long_enter = (long_score >= 3) & (vol_surge | (long_score >= 4)) - short_enter = (short_score >= 3) & (vol_surge | (short_score >= 4)) + thr = signal_threshold + long_enter = (long_score >= thr) & (vol_surge | (long_score >= thr + 1)) + short_enter = (short_score >= thr) & (vol_surge | (short_score >= thr + 1)) signal_arr = np.full(n, "HOLD", dtype=object) signal_arr[long_enter] = "LONG" @@ -116,6 +126,12 @@ def _calc_signals(d: pd.DataFrame) -> np.ndarray: # 둘 다 해당하면 HOLD (충돌 방지) signal_arr[long_enter & short_enter] = "HOLD" + # ADX 필터 + if adx_threshold > 0 and "adx" in d.columns: + adx_vals = d["adx"].values + low_adx = adx_vals < adx_threshold + signal_arr[low_adx] = "HOLD" + return signal_arr @@ -372,6 +388,9 @@ def generate_dataset_vectorized( eth_df: pd.DataFrame | None = None, time_weight_decay: float = 0.0, negative_ratio: int = 0, + signal_threshold: int = 3, + adx_threshold: float = 25, + volume_multiplier: float = 2.5, ) -> pd.DataFrame: """ 전체 시계열을 1회 계산해 학습 데이터셋을 생성한다. @@ -390,7 +409,12 @@ def generate_dataset_vectorized( d = _calc_indicators(df) print(" [2/3] 신호 마스킹 및 피처 추출...") - signal_arr = _calc_signals(d) + signal_arr = _calc_signals( + d, + signal_threshold=signal_threshold, + adx_threshold=adx_threshold, + volume_multiplier=volume_multiplier, + ) feat_all = _calc_features_vectorized(d, signal_arr, btc_df=btc_df, eth_df=eth_df) # 신호 발생 + NaN 없음 + 미래 데이터 충분한 인덱스만 diff --git a/src/indicators.py b/src/indicators.py index c3b5c70..b67cddb 100644 --- a/src/indicators.py +++ b/src/indicators.py @@ -52,18 +52,29 @@ class Indicators: return df - def get_signal(self, df: pd.DataFrame) -> str: + def get_signal( + self, + df: pd.DataFrame, + signal_threshold: int = 3, + adx_threshold: float = 25, + volume_multiplier: float = 2.5, + ) -> str: """ 복합 지표 기반 매매 신호 생성. - 공격적 전략: 3개 이상 지표 일치 시 진입. + + signal_threshold: 최소 가중치 합계 (기본 3) + adx_threshold: ADX 최소값 필터 (0=비활성화, 25=ADX<25이면 HOLD) + volume_multiplier: 거래량 급증 배수 (기본 1.5) """ last = df.iloc[-1] prev = df.iloc[-2] - # ADX 로깅 (ML 피처로 위임, 하드필터 제거) + # ADX 필터 adx = last.get("adx", None) if adx is not None and not pd.isna(adx): logger.debug(f"ADX: {adx:.1f}") + if adx_threshold > 0 and adx < adx_threshold: + return "HOLD" long_signals = 0 short_signals = 0 @@ -99,22 +110,22 @@ class Indicators: short_signals += 1 # 6. 거래량 확인 (신호 강화) - vol_surge = last["volume"] > last["vol_ma20"] * 1.5 + vol_surge = last["volume"] > last["vol_ma20"] * volume_multiplier - threshold = 3 - if long_signals >= threshold and (vol_surge or long_signals >= 4): + if long_signals >= signal_threshold and (vol_surge or long_signals >= signal_threshold + 1): return "LONG" - elif short_signals >= threshold and (vol_surge or short_signals >= 4): + elif short_signals >= signal_threshold and (vol_surge or short_signals >= signal_threshold + 1): return "SHORT" return "HOLD" def get_atr_stop( - self, df: pd.DataFrame, side: str, entry_price: float + self, df: pd.DataFrame, side: str, entry_price: float, + atr_sl_mult: float = 2.0, atr_tp_mult: float = 2.0, ) -> tuple[float, float]: """ATR 기반 손절/익절 가격 반환 (stop_loss, take_profit)""" atr = df["atr"].iloc[-1] - multiplier_sl = 1.5 - multiplier_tp = 3.0 + multiplier_sl = atr_sl_mult + multiplier_tp = atr_tp_mult if side == "LONG": stop_loss = entry_price - atr * multiplier_sl take_profit = entry_price + atr * multiplier_tp diff --git a/src/ml_features.py b/src/ml_features.py index a61073c..e51bf3b 100644 --- a/src/ml_features.py +++ b/src/ml_features.py @@ -15,6 +15,10 @@ FEATURE_COLS = [ "adx", ] +# rolling z-score 윈도우 (학습과 동일) +_ZSCORE_WINDOW = 288 # 일반 피처: 15분봉 × 288 = 3일 +_ZSCORE_WINDOW_OI = 96 # OI/펀딩비: 15분봉 × 96 = 1일 + def _calc_ret(closes: pd.Series, n: int) -> float: """n캔들 전 대비 수익률. 데이터 부족 시 0.0.""" @@ -31,6 +35,18 @@ def _calc_rs(xrp_ret: float, other_ret: float) -> float: return xrp_ret / other_ret +def _rolling_zscore_last(arr: np.ndarray, window: int = _ZSCORE_WINDOW) -> float: + """배열의 마지막 값에 대한 rolling z-score를 반환한다. + 학습(dataset_builder._rolling_zscore)과 동일한 로직.""" + s = pd.Series(arr, dtype=np.float64) + r = s.rolling(window=window, min_periods=1) + mean = r.mean().iloc[-1] + std = r.std(ddof=0).iloc[-1] + if std < 1e-8: + std = 1e-8 + return float((s.iloc[-1] - mean) / std) + + def build_features( df: pd.DataFrame, signal: str, @@ -42,10 +58,8 @@ def build_features( oi_price_spread: float | None = None, ) -> pd.Series: """ - 기술 지표가 계산된 DataFrame의 마지막 행에서 ML 피처를 추출한다. - btc_df, eth_df가 제공되면 26개 피처를, 없으면 18개 피처를 반환한다. - signal: "LONG" | "SHORT" - oi_change, funding_rate, oi_change_ma5, oi_price_spread: 실제 값이 제공되면 사용, 없으면 0.0으로 채운다. + [Deprecated] raw 값 기반 피처. 하위 호환용으로 유지. + 신규 코드는 build_features_aligned()를 사용할 것. """ last = df.iloc[-1] close = last["close"] @@ -142,3 +156,154 @@ def build_features( base["adx"] = float(last.get("adx", 0)) return pd.Series(base) + + +def build_features_aligned( + df: pd.DataFrame, + signal: str, + btc_df: pd.DataFrame | None = None, + eth_df: pd.DataFrame | None = None, + oi_change: float | None = None, + funding_rate: float | None = None, + oi_change_ma5: float | None = None, + oi_price_spread: float | None = None, +) -> pd.Series: + """ + 학습(dataset_builder._calc_features_vectorized)과 동일한 rolling z-score를 + 적용한 피처를 반환한다. train-serve skew를 방지한다. + + df: 지표가 이미 계산된 DataFrame (최소 60캔들 이상) + signal: "LONG" | "SHORT" + """ + last = df.iloc[-1] + close_series = df["close"] + close = float(close_series.iloc[-1]) + + # --- raw 값 계산 (z-score 전) --- + bb_upper = df["bb_upper"] if "bb_upper" in df.columns else pd.Series(close, index=df.index) + bb_lower = df["bb_lower"] if "bb_lower" in df.columns else pd.Series(close, index=df.index) + bb_range = bb_upper - bb_lower + bb_pct_series = (close_series - bb_lower) / (bb_range + 1e-8) + + ema9 = df.get("ema9", close_series) + ema21 = df.get("ema21", close_series) + ema50 = df.get("ema50", close_series) + + ema_align_arr = np.where( + (ema9 > ema21) & (ema21 > ema50), 1, + np.where((ema9 < ema21) & (ema21 < ema50), -1, 0) + ).astype(np.float32) + + atr_series = df["atr"] if "atr" in df.columns else pd.Series(0.0, index=df.index) + atr_pct_arr = (atr_series / (close_series + 1e-8)).values + + volume = df["volume"] + vol_ma20 = df["vol_ma20"] if "vol_ma20" in df.columns else pd.Series(1.0, index=df.index) + vol_ratio_arr = (volume / (vol_ma20 + 1e-8)).values + + ret_1_arr = close_series.pct_change(1).fillna(0).values + ret_3_arr = close_series.pct_change(3).fillna(0).values + ret_5_arr = close_series.pct_change(5).fillna(0).values + + # z-score 적용 (학습과 동일) + atr_pct_z = _rolling_zscore_last(atr_pct_arr) + vol_ratio_z = _rolling_zscore_last(vol_ratio_arr) + ret_1_z = _rolling_zscore_last(ret_1_arr) + ret_3_z = _rolling_zscore_last(ret_3_arr) + ret_5_z = _rolling_zscore_last(ret_5_arr) + + # signal_strength + rsi = float(last.get("rsi", 50)) + macd_val = float(last.get("macd", 0)) + macd_sig_val = float(last.get("macd_signal", 0)) + stoch_k = float(last.get("stoch_k", 50)) + stoch_d = float(last.get("stoch_d", 50)) + prev = df.iloc[-2] if len(df) >= 2 else last + prev_macd = float(prev.get("macd", 0)) + prev_macd_sig = float(prev.get("macd_signal", 0)) + + strength = 0 + if signal == "LONG": + if rsi < 35: strength += 1 + if prev_macd < prev_macd_sig and macd_val > macd_sig_val: strength += 2 + if close < float(last.get("bb_lower", close)): strength += 1 + if ema_align_arr[-1] == 1: strength += 1 + if stoch_k < 20 and stoch_k > stoch_d: strength += 1 + else: + if rsi > 65: strength += 1 + if prev_macd > prev_macd_sig and macd_val < macd_sig_val: strength += 2 + if close > float(last.get("bb_upper", close)): strength += 1 + if ema_align_arr[-1] == -1: strength += 1 + if stoch_k > 80 and stoch_k < stoch_d: strength += 1 + + # ADX z-score + adx_arr = df["adx"].values.astype(np.float64) if "adx" in df.columns else np.zeros(len(df)) + adx_z = _rolling_zscore_last(adx_arr) + + base = { + "rsi": rsi, + "macd_hist": float(last.get("macd_hist", 0)), + "bb_pct": float(bb_pct_series.iloc[-1]), + "ema_align": float(ema_align_arr[-1]), + "stoch_k": stoch_k, + "stoch_d": stoch_d, + "atr_pct": atr_pct_z, + "vol_ratio": vol_ratio_z, + "ret_1": ret_1_z, + "ret_3": ret_3_z, + "ret_5": ret_5_z, + "signal_strength": float(strength), + "side": 1.0 if signal == "LONG" else 0.0, + } + + # BTC/ETH 상관 피처 (z-score) + if btc_df is not None and eth_df is not None: + btc_r1 = btc_df["close"].pct_change(1).fillna(0).values + btc_r3 = btc_df["close"].pct_change(3).fillna(0).values + btc_r5 = btc_df["close"].pct_change(5).fillna(0).values + eth_r1 = eth_df["close"].pct_change(1).fillna(0).values + eth_r3 = eth_df["close"].pct_change(3).fillna(0).values + eth_r5 = eth_df["close"].pct_change(5).fillna(0).values + + # 길이 맞춤 (btc/eth가 더 길 수 있음) + n = len(df) + def _align(arr): + if len(arr) >= n: + return arr[-n:] + return np.concatenate([np.zeros(n - len(arr)), arr]) + + btc_r1 = _align(btc_r1) + btc_r3 = _align(btc_r3) + btc_r5 = _align(btc_r5) + eth_r1 = _align(eth_r1) + eth_r3 = _align(eth_r3) + eth_r5 = _align(eth_r5) + + # 상대강도 (raw → z-score) + xrp_r1 = ret_1_arr.astype(np.float32) + btc_r1_f = btc_r1.astype(np.float32) + eth_r1_f = eth_r1.astype(np.float32) + rs_btc = np.divide(xrp_r1, btc_r1_f, out=np.zeros_like(xrp_r1), where=(btc_r1_f != 0)) + rs_eth = np.divide(xrp_r1, eth_r1_f, out=np.zeros_like(xrp_r1), where=(eth_r1_f != 0)) + + base.update({ + "btc_ret_1": _rolling_zscore_last(btc_r1), + "btc_ret_3": _rolling_zscore_last(btc_r3), + "btc_ret_5": _rolling_zscore_last(btc_r5), + "eth_ret_1": _rolling_zscore_last(eth_r1), + "eth_ret_3": _rolling_zscore_last(eth_r3), + "eth_ret_5": _rolling_zscore_last(eth_r5), + "xrp_btc_rs": _rolling_zscore_last(rs_btc), + "xrp_eth_rs": _rolling_zscore_last(rs_eth), + }) + + # OI/펀딩비 z-score (실시간 값이 제공되면 히스토리 끝에 추가하여 z-score) + # 서빙 시 OI/펀딩비 히스토리가 없으므로 단일 값 → z-score 불가, NaN 처리 + # LightGBM은 NaN을 자체 처리함 + base["oi_change"] = float(oi_change) if oi_change is not None else np.nan + base["funding_rate"] = float(funding_rate) if funding_rate is not None else np.nan + base["oi_change_ma5"] = float(oi_change_ma5) if oi_change_ma5 is not None else np.nan + base["oi_price_spread"] = float(oi_price_spread) if oi_price_spread is not None else np.nan + base["adx"] = adx_z + + return pd.Series(base) diff --git a/tests/test_bot.py b/tests/test_bot.py index e9846ba..07de871 100644 --- a/tests/test_bot.py +++ b/tests/test_bot.py @@ -246,7 +246,7 @@ async def test_process_candle_fetches_oi_and_funding(config, sample_df): mock_ind.get_signal.return_value = "LONG" mock_ind_cls.return_value = mock_ind - with patch("src.bot.build_features") as mock_build: + with patch("src.bot.build_features_aligned") as mock_build: from src.ml_features import FEATURE_COLS mock_build.return_value = pd.Series({col: 0.0 for col in FEATURE_COLS}) bot.ml_filter.is_model_loaded = MagicMock(return_value=False) diff --git a/tests/test_dataset_builder.py b/tests/test_dataset_builder.py index 2899f48..bc5c6ce 100644 --- a/tests/test_dataset_builder.py +++ b/tests/test_dataset_builder.py @@ -230,7 +230,7 @@ def signal_producing_df(): def test_hold_negative_labels_are_all_zero(signal_producing_df): """HOLD negative 샘플의 label은 전부 0이어야 한다.""" - result = generate_dataset_vectorized(signal_producing_df, negative_ratio=3) + result = generate_dataset_vectorized(signal_producing_df, negative_ratio=3, adx_threshold=0, volume_multiplier=1.5) assert len(result) > 0, "시그널이 발생하지 않아 테스트 불가" assert "source" in result.columns hold_neg = result[result["source"] == "hold_negative"] @@ -241,8 +241,8 @@ def test_hold_negative_labels_are_all_zero(signal_producing_df): def test_signal_samples_preserved_after_sampling(signal_producing_df): """계층적 샘플링 후 source='signal' 샘플이 하나도 버려지지 않아야 한다.""" - result_signal_only = generate_dataset_vectorized(signal_producing_df, negative_ratio=0) - result_with_hold = generate_dataset_vectorized(signal_producing_df, negative_ratio=3) + result_signal_only = generate_dataset_vectorized(signal_producing_df, negative_ratio=0, adx_threshold=0, volume_multiplier=1.5) + result_with_hold = generate_dataset_vectorized(signal_producing_df, negative_ratio=3, adx_threshold=0, volume_multiplier=1.5) assert len(result_signal_only) > 0, "시그널이 발생하지 않아 테스트 불가" assert "source" in result_with_hold.columns diff --git a/tests/test_indicators.py b/tests/test_indicators.py index 8dbad7e..43acc63 100644 --- a/tests/test_indicators.py +++ b/tests/test_indicators.py @@ -54,20 +54,22 @@ def test_adx_column_exists(sample_df): assert (valid >= 0).all() -def test_adx_low_does_not_block_signal(sample_df): - """ADX < 25여도 시그널이 차단되지 않는다 (ML에 위임).""" +def test_adx_filter_blocks_low_adx(sample_df): + """ADX < adx_threshold이면 HOLD 반환.""" ind = Indicators(sample_df) df = ind.calculate_all() - # 강한 LONG 신호가 나오도록 지표 조작 df.loc[df.index[-1], "rsi"] = 20 df.loc[df.index[-2], "macd"] = -1 df.loc[df.index[-2], "macd_signal"] = 0 df.loc[df.index[-1], "macd"] = 1 df.loc[df.index[-1], "macd_signal"] = 0 - df.loc[df.index[-1], "volume"] = df.loc[df.index[-1], "vol_ma20"] * 2 + df.loc[df.index[-1], "volume"] = df.loc[df.index[-1], "vol_ma20"] * 3 df["adx"] = 15.0 + # 기본 adx_threshold=25이므로 ADX=15은 HOLD signal = ind.get_signal(df) - # ADX 낮아도 지표 조건 충족 시 LONG 반환 (ML이 최종 판단) + assert signal == "HOLD" + # adx_threshold=0이면 ADX 필터 비활성화 → LONG + signal = ind.get_signal(df, adx_threshold=0) assert signal == "LONG"