diff --git a/backtests/vbt_runner.py b/backtests/vbt_runner.py index 3c5fec4..0af57b3 100644 --- a/backtests/vbt_runner.py +++ b/backtests/vbt_runner.py @@ -35,12 +35,21 @@ RESULTS_DIR.mkdir(parents=True, exist_ok=True) # Strategy signal generators # ═══════════════════════════════════════════════════════════════ -def _generate_signals(strategy: str, data: dict[str, pd.DataFrame]) -> tuple[pd.Series, pd.Series]: +def _generate_signals(strategy: str, data: dict[str, pd.DataFrame], + params: dict | None = None) -> tuple[pd.Series, pd.Series]: """Generate entry/exit signals for a strategy from candle data. Returns (entries, exits) as boolean pandas Series. Each strategy uses the primary coin's close prices. + + Params: + grid_mm: grid_levels, spacing_bps, rebalance_every + as_mm: gamma, k, tau, min_hold, max_hold, profit_target, stop_loss + obi: lookback, entry_threshold, exit_threshold + pairs: z_entry, z_exit, lookback """ + if params is None: + params = {} main_coin = {"pairs": "ETH", "hurst_vpin": "BTC", "as_mm": "BTC", "obi": "BTC", "grid_mm": "BTC", "composite_mm": "BTC", "iceberg": "BTC", "funding_arb": "BTC", "momentum": "BTC", @@ -142,14 +151,13 @@ def _generate_signals(strategy: str, data: dict[str, pd.DataFrame]) -> tuple[pd. elif strategy == "grid_mm": # Grid MM: simulate grid fills from candle high/low ranges - grid_levels = 10 - grid_spacing_pct = 0.001 + grid_levels = params.get("grid_levels", 10) + grid_spacing_pct = params.get("spacing_bps", 10) / 10000 # bps → decimal + rebalance = params.get("rebalance_every", 20) entries = pd.Series(False, index=close.index) exits = pd.Series(False, index=close.index) - # Track grid state per bar - grid_fills = 0 - prev_entry = 0 + fills_accumulated = 0 for i in range(1, len(close)): mid = close.iloc[i] @@ -164,10 +172,11 @@ def _generate_signals(strategy: str, data: dict[str, pd.DataFrame]) -> tuple[pd. if high >= sell_px: fills_this_bar += 1 if fills_this_bar > 0: + fills_accumulated += fills_this_bar entries.iloc[i] = True - # Exit after spread capture (next bar close) - if i + 1 < len(close): - exits.iloc[i + 1] = True + # Exit after rebalance period + if i + rebalance < len(close): + exits.iloc[i + rebalance] = True elif strategy == "composite_mm": # Composite: weighted ensemble of OBI + Hurst @@ -314,6 +323,7 @@ class VBTBacktestRunner: limit: int = 5000, start_ms: int | None = None, end_ms: int | None = None, + params: dict | None = None, ) -> dict[str, Any] | None: """Fetch candles, generate signals, run VBT backtest, return metrics.""" coins = self._get_coins(strategy) @@ -332,7 +342,7 @@ class VBTBacktestRunner: logger.error("No candle data fetched for strategy: %s", strategy) return None - entries, exits = _generate_signals(strategy, data) + entries, exits = _generate_signals(strategy, data, params) primary = list(data.values())[0] close = primary["close"] @@ -365,7 +375,7 @@ class VBTBacktestRunner: return self._empty_result(strategy, interval) stats = pf.stats() - result = self._extract_metrics(pf, stats, strategy, interval, len(close)) + result = self._extract_metrics(pf, stats, strategy, interval, len(close), params) # Save equity curve eq_curve = pf.value().dropna() @@ -448,7 +458,8 @@ class VBTBacktestRunner: } return coin_map.get(strategy, ["BTC"]) - def _extract_metrics(self, pf, stats, strategy, interval, n_bars) -> dict: + def _extract_metrics(self, pf, stats, strategy, interval, n_bars, + runtime_params=None) -> dict: from config.fee_tiers import compute_trade_fees, get_strategy_fee_model main_coin = self._get_coins(strategy)[0] @@ -519,7 +530,7 @@ class VBTBacktestRunner: "profit_factor": round(float(stats.get("Profit Factor", 0)), 3), "expectancy": round(float(stats.get("Expectancy", 0)), 3), "trades": trades, - "params": _strategy_params(strategy), + "params": _strategy_params(strategy, runtime_params), "fee_info": fee_info, } @@ -543,20 +554,23 @@ class VBTBacktestRunner: } -def _strategy_params(strategy: str) -> dict: +def _strategy_params(strategy: str, runtime_params: dict | None = None) -> dict: """Return the key parameters/coefficients for a strategy.""" - params = { + base = { "pairs": {"z_entry": 1.5, "z_exit": 0.5, "lookback": 20, "type": "Stat Arb"}, "hurst_vpin": {"hurst_entry": 0.55, "hurst_exit": 0.45, "vpin_threshold": 0.25, "vpin_window": 50, "hurst_window": 64, "type": "Directional"}, "as_mm": {"gamma": 0.1, "sigma_dynamic": True, "inventory_skew": True, "type": "Market Making"}, "obi": {"obi_lookback": 20, "obi_entry": 0.35, "obi_exit": 0.10, "type": "Reversal"}, - "grid_mm": {"grid_levels": 10, "grid_spacing_pct": 0.1, "rebalance_every": 20, "type": "Market Making"}, + "grid_mm": {"grid_levels": 10, "spacing_bps": 10, "rebalance_every": 20, "type": "Market Making"}, "composite_mm": {"obi_weight": 0.30, "as_weight": 0.40, "hurst_weight": 0.30, "entry_score": 0.50, "type": "Ensemble"}, "iceberg": {"vol_mult": 1.8, "min_consec": 3, "max_hold": 8, "type": "Momentum"}, "momentum": {"bollinger_window": 20, "bollinger_std": 2.0, "type": "Momentum"}, "mean_rev": {"vwap_window": 20, "deviation": 1.0, "type": "Reversal"}, } - return params.get(strategy, {"type": "Unknown"}) + result = base.get(strategy, {"type": "Unknown"}) + if runtime_params: + result.update({k: v for k, v in runtime_params.items() if k in result}) + return result def _generate_signals_sweep( diff --git a/quant/optimizer.py b/quant/optimizer.py new file mode 100644 index 0000000..0dc3682 --- /dev/null +++ b/quant/optimizer.py @@ -0,0 +1,280 @@ +""" +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