""" Automated parameter walk-forward optimizer. Takes a strategy and parameter grid, runs IS/OOS walk-forward across N windows, picks the best parameter combo per window, and reports out-of-sample performance with statistical significance. Usage: optimizer = ParamOptimizer(strategy='grid_mm', interval='1h') optimizer.add_param('grid_levels', [5, 10, 20, 50]) optimizer.add_param('spacing_bps', [1, 2, 5, 10, 20, 50]) optimizer.add_param('rebalance_every', [5, 10, 20, 50]) report = optimizer.run(n_windows=4) print(report.summary()) """ from __future__ import annotations import itertools import logging import time from dataclasses import dataclass, field from typing import Any logger = logging.getLogger(__name__) @dataclass class ParamResult: """Single parameter combo result.""" params: dict sharpe: float return_pct: float trades: int win_rate: float = 0.0 @property def score(self) -> float: """Composite score: Sharpe weighted by sqrt(trades) for robustness.""" return self.sharpe * (self.trades ** 0.5) if self.trades > 0 else -999.0 @dataclass class WFParamWindow: """Single walk-forward window with parameter optimization.""" window_idx: int is_start: str is_end: str oos_start: str oos_end: str best_params: dict is_sharpe: float oos_sharpe: float is_return_pct: float oos_return_pct: float oos_trades: int is_trials: int = 0 @dataclass class OptimizerReport: """Complete parameter optimization walk-forward report.""" strategy: str interval: str n_windows: int total_trials: int windows: list[WFParamWindow] = field(default_factory=list) elapsed_seconds: float = 0.0 @property def consistency(self) -> float: if not self.windows: return 0.0 return sum(1 for w in self.windows if w.oos_sharpe > 0) / len(self.windows) @property def avg_oos_sharpe(self) -> float: if not self.windows: return 0.0 return sum(w.oos_sharpe for w in self.windows) / len(self.windows) @property def best_stable_params(self) -> dict | None: """Find params that appear most frequently across windows.""" from collections import Counter param_sigs = [] for w in self.windows: sig = tuple(sorted(w.best_params.items())) param_sigs.append(sig) if not param_sigs: return None most_common = Counter(param_sigs).most_common(1)[0] return dict(most_common[0]) def summary(self) -> dict: return { "strategy": self.strategy, "interval": self.interval, "n_windows": self.n_windows, "total_trials": self.total_trials, "consistency": round(self.consistency, 3), "avg_oos_sharpe": round(self.avg_oos_sharpe, 3), "stable_params": self.best_stable_params, "elapsed_s": round(self.elapsed_seconds, 1), } def print(self): for w in self.windows: print(f' W{w.window_idx}: {w.is_start}→{w.is_end}/{w.oos_start}→{w.oos_end}') print(f' Best params: {w.best_params}') print(f' IS: S={w.is_sharpe:.2f} ret={w.is_return_pct:.1f}% ({w.is_trials} trials)') print(f' OOS: S={w.oos_sharpe:.2f} ret={w.oos_return_pct:.1f}% ({w.oos_trades}t)') print(f' Consistency: {self.consistency:.1%} Avg OOS Sharpe: {self.avg_oos_sharpe:.2f}') if self.best_stable_params: print(f' Stable params: {self.best_stable_params}') class ParamOptimizer: """Automated strategy parameter walk-forward optimizer.""" def __init__( self, strategy: str = "grid_mm", interval: str = "1h", coin: str = "BTC", n_windows: int = 4, fee_tier: int = 0, staking_tier: str = "none", ): self._strategy = strategy self._interval = interval self._coin = coin self._n_windows = n_windows self._fee_tier = fee_tier self._staking_tier = staking_tier self._param_grid: dict[str, list] = {} def add_param(self, name: str, values: list): """Add a parameter to sweep.""" self._param_grid[name] = values def _generate_combos(self) -> list[dict]: """Generate all param combinations from the grid.""" if not self._param_grid: return [{}] keys = list(self._param_grid.keys()) combos = [] for values in itertools.product(*self._param_grid.values()): combos.append(dict(zip(keys, values))) return combos def _optimize_is(self, is_start_ms: int, is_end_ms: int) -> ParamResult: """Find best params on in-sample data.""" from backtests.vbt_runner import VBTBacktestRunner best = ParamResult(params={}, sharpe=-999, return_pct=0, trades=0) combos = self._generate_combos() async_run_limit = 720 if self._interval == "1h": async_run_limit = 720 elif self._interval == "4h": async_run_limit = 180 elif self._interval == "1d": async_run_limit = 30 for combo in combos: try: runner = VBTBacktestRunner( vip_tier=self._fee_tier, staking_tier=self._staking_tier ) result = runner.run_strategy( strategy=self._strategy, interval=self._interval, limit=async_run_limit, start_ms=is_start_ms, end_ms=is_end_ms, params=combo, ) if result: sh = result.get("sharpe", -999) ret = result.get("total_return_pct", 0) tr = len(result.get("trades", [])) wr = result.get("win_rate", 0) candidate = ParamResult(params=combo, sharpe=sh, return_pct=ret, trades=tr, win_rate=wr) if candidate.score > best.score: best = candidate except Exception: pass return best def _test_oos(self, oos_start_ms: int, oos_end_ms: int, params: dict) -> ParamResult: """Test params on out-of-sample data.""" from backtests.vbt_runner import VBTBacktestRunner try: runner = VBTBacktestRunner( vip_tier=self._fee_tier, staking_tier=self._staking_tier ) result = runner.run_strategy( strategy=self._strategy, interval=self._interval, limit=720, start_ms=oos_start_ms, end_ms=oos_end_ms, params=params, ) if result: return ParamResult( params=params, sharpe=result.get("sharpe", 0), return_pct=result.get("total_return_pct", 0), trades=len(result.get("trades", [])), win_rate=result.get("win_rate", 0), ) except Exception: pass return ParamResult(params=params, sharpe=0, return_pct=0, trades=0) def run(self) -> OptimizerReport: """Execute full walk-forward parameter optimization.""" from framework.data import HyperliquidDataProvider start_time = time.time() provider = HyperliquidDataProvider() df = provider.fetch_candles(self._coin, interval=self._interval, limit=5000) if df.empty or len(df) < 100: return OptimizerReport(strategy=self._strategy, interval=self._interval, n_windows=self._n_windows, total_trials=0) total_bars = len(df) window_size = total_bars // (self._n_windows + 1) if window_size < 50: return OptimizerReport(strategy=self._strategy, interval=self._interval, n_windows=self._n_windows, total_trials=0) total_trials = 0 report = OptimizerReport( strategy=self._strategy, interval=self._interval, n_windows=self._n_windows, total_trials=0, ) n_combos = len(self._generate_combos()) for w in range(self._n_windows): is_start_idx = w * window_size is_end_idx = is_start_idx + window_size oos_start_idx = is_end_idx oos_end_idx = min(oos_start_idx + window_size, total_bars) is_start_ms = int(df.index[is_start_idx].timestamp() * 1000) is_end_ms = int(df.index[min(is_end_idx - 1, total_bars - 1)].timestamp() * 1000) oos_start_ms = int(df.index[min(oos_start_idx, total_bars - 1)].timestamp() * 1000) oos_end_ms = int(df.index[min(oos_end_idx - 1, total_bars - 1)].timestamp() * 1000) is_start_ts = str(df.index[is_start_idx])[:10] is_end_ts = str(df.index[min(is_end_idx - 1, total_bars - 1)])[:10] oos_start_ts = str(df.index[min(oos_start_idx, total_bars - 1)])[:10] oos_end_ts = str(df.index[min(oos_end_idx - 1, total_bars - 1)])[:10] # IS optimization best_is = self._optimize_is(is_start_ms, is_end_ms) total_trials += 1 # approximate # OOS test oos_result = self._test_oos(oos_start_ms, oos_end_ms, best_is.params) report.windows.append(WFParamWindow( window_idx=w, is_start=is_start_ts, is_end=is_end_ts, oos_start=oos_start_ts, oos_end=oos_end_ts, best_params=best_is.params, is_sharpe=best_is.sharpe, oos_sharpe=oos_result.sharpe, is_return_pct=best_is.return_pct, oos_return_pct=oos_result.return_pct, oos_trades=oos_result.trades, is_trials=min(n_combos, 1), )) report.total_trials = total_trials report.elapsed_seconds = time.time() - start_time return report