summaryrefslogtreecommitdiff
path: root/services/backtester/src
diff options
context:
space:
mode:
authorTheSiahxyz <164138827+TheSiahxyz@users.noreply.github.com>2026-04-01 18:23:46 +0900
committerTheSiahxyz <164138827+TheSiahxyz@users.noreply.github.com>2026-04-01 18:23:46 +0900
commit9e82c51dfde3941189db1b2d62dcc239442b9dc6 (patch)
tree2c19068a4b2e381d44d22d662ec095794e144180 /services/backtester/src
parentcd3a06c7788ad8a747b1b4579fb6c45b6c43008e (diff)
feat(backtester): add walk-forward analysis engine
Diffstat (limited to 'services/backtester/src')
-rw-r--r--services/backtester/src/backtester/walk_forward.py145
1 files changed, 145 insertions, 0 deletions
diff --git a/services/backtester/src/backtester/walk_forward.py b/services/backtester/src/backtester/walk_forward.py
new file mode 100644
index 0000000..fe6d020
--- /dev/null
+++ b/services/backtester/src/backtester/walk_forward.py
@@ -0,0 +1,145 @@
+"""Walk-forward analysis for strategy parameter optimization."""
+from dataclasses import dataclass, field
+from decimal import Decimal
+from typing import Callable
+
+from shared.models import Candle
+from backtester.engine import BacktestEngine, BacktestResult, StrategyProtocol
+
+
+@dataclass
+class WalkForwardWindow:
+ """Result for a single in-sample/out-of-sample window."""
+ window_index: int
+ in_sample_result: BacktestResult
+ out_of_sample_result: BacktestResult
+ best_params: dict
+
+
+@dataclass
+class WalkForwardResult:
+ """Aggregated walk-forward analysis results."""
+ strategy_name: str
+ symbol: str
+ num_windows: int
+ windows: list[WalkForwardWindow] = field(default_factory=list)
+
+ @property
+ def out_of_sample_profit_pct(self) -> float:
+ """Combined out-of-sample profit percentage."""
+ if not self.windows:
+ return 0.0
+ total_profit = sum(float(w.out_of_sample_result.profit) for w in self.windows)
+ initial = float(self.windows[0].out_of_sample_result.initial_balance)
+ return (total_profit / initial * 100) if initial > 0 else 0.0
+
+ @property
+ def in_sample_profit_pct(self) -> float:
+ if not self.windows:
+ return 0.0
+ total_profit = sum(float(w.in_sample_result.profit) for w in self.windows)
+ initial = float(self.windows[0].in_sample_result.initial_balance)
+ return (total_profit / initial * 100) if initial > 0 else 0.0
+
+ @property
+ def efficiency_ratio(self) -> float:
+ """Out-of-sample / in-sample performance ratio.
+ Close to 1.0 = robust. Much less = overfitting."""
+ if self.in_sample_profit_pct == 0:
+ return 0.0
+ return self.out_of_sample_profit_pct / self.in_sample_profit_pct
+
+
+class WalkForwardEngine:
+ """Runs walk-forward analysis on a strategy.
+
+ Splits candle data into N rolling windows. For each window:
+ 1. In-sample: Try each param set, pick the best by profit
+ 2. Out-of-sample: Run best params on held-out data
+ """
+
+ def __init__(
+ self,
+ strategy_factory: Callable[[], StrategyProtocol],
+ param_grid: list[dict],
+ initial_balance: Decimal = Decimal("10000"),
+ num_windows: int = 5,
+ in_sample_pct: float = 0.7,
+ ) -> None:
+ self._strategy_factory = strategy_factory
+ self._param_grid = param_grid
+ self._initial_balance = initial_balance
+ self._num_windows = num_windows
+ self._in_sample_pct = in_sample_pct
+
+ def run(self, candles: list[Candle]) -> WalkForwardResult:
+ """Run walk-forward analysis over the candle data."""
+ if not candles or not self._param_grid:
+ strategy = self._strategy_factory()
+ return WalkForwardResult(
+ strategy_name=strategy.name,
+ symbol=candles[0].symbol if candles else "",
+ num_windows=0,
+ )
+
+ total = len(candles)
+ window_size = total // self._num_windows
+ if window_size < 10:
+ # Not enough data for meaningful walk-forward
+ strategy = self._strategy_factory()
+ return WalkForwardResult(
+ strategy_name=strategy.name,
+ symbol=candles[0].symbol if candles else "",
+ num_windows=0,
+ )
+
+ windows = []
+ strategy_name = self._strategy_factory().name
+ symbol = candles[0].symbol
+
+ for i in range(self._num_windows):
+ start = i * window_size
+ end = min(start + window_size, total)
+ window_candles = candles[start:end]
+
+ split = int(len(window_candles) * self._in_sample_pct)
+ in_sample = window_candles[:split]
+ out_of_sample = window_candles[split:]
+
+ if len(in_sample) < 5 or len(out_of_sample) < 5:
+ continue
+
+ # Optimize on in-sample
+ best_params = {}
+ best_profit = Decimal("-999999")
+ best_is_result = None
+
+ for params in self._param_grid:
+ strategy = self._strategy_factory()
+ strategy.configure(params)
+ engine = BacktestEngine(strategy, self._initial_balance)
+ result = engine.run(in_sample)
+ if result.profit > best_profit:
+ best_profit = result.profit
+ best_params = params
+ best_is_result = result
+
+ # Validate on out-of-sample with best params
+ strategy = self._strategy_factory()
+ strategy.configure(best_params)
+ engine = BacktestEngine(strategy, self._initial_balance)
+ oos_result = engine.run(out_of_sample)
+
+ windows.append(WalkForwardWindow(
+ window_index=i,
+ in_sample_result=best_is_result,
+ out_of_sample_result=oos_result,
+ best_params=best_params,
+ ))
+
+ return WalkForwardResult(
+ strategy_name=strategy_name,
+ symbol=symbol,
+ num_windows=len(windows),
+ windows=windows,
+ )