Download algotrader/validation/walkforward.py from ParallelLLC/algorithmic_trading: direct link, hf CLI and curl.
- Browser
- Download file 9.14 kB
-
https://huggingface.co/ParallelLLC/algorithmic_trading/resolve/main/algotrader/validation/walkforward.py
- Command line
-
hf download hf://ParallelLLC/algorithmic_trading/algotrader/validation/walkforward.py
-
curl -L -o walkforward.py https://huggingface.co/ParallelLLC/algorithmic_trading/resolve/main/algotrader/validation/walkforward.py
9.14 kB
| """Walk-forward analysis. | |
| Re-tune on a training window, trade the next window blind, roll forward. The | |
| gap between in-sample and out-of-sample Sharpe is the honest estimate of how | |
| much of the backtest was curve-fitting. | |
| """ | |
| from __future__ import annotations | |
| from typing import Callable, Dict, List, Optional | |
| import numpy as np | |
| import pandas as pd | |
| from ..engine import run_backtest | |
| from ..strategies import Strategy | |
| from ..types import CostModel | |
| __all__ = ["walk_forward", "walk_forward_panel"] | |
| def walk_forward( | |
| df: pd.DataFrame, | |
| strategy: Strategy, | |
| n_folds: int = 5, | |
| train_ratio: float = 0.7, | |
| costs: Optional[CostModel] = None, | |
| lag: int = 1, | |
| max_leverage: float = 1.0, | |
| allow_short: bool = True, | |
| grid_limit: int = 40, | |
| progress: Optional[Callable[[float, str], None]] = None, | |
| ) -> Dict[str, object]: | |
| """Roll a train/test split forward ``n_folds`` times. | |
| Each fold picks the best parameters by in-sample Sharpe and reports what | |
| those parameters then did out of sample. The stitched OOS returns are the | |
| closest thing to a paper-trading record this repo can produce offline. | |
| """ | |
| costs = costs or CostModel() | |
| grid = strategy.grid(limit=grid_limit) | |
| n = len(df) | |
| if n < 250 or n_folds < 2: | |
| return {"folds": [], "note": "Not enough history for walk-forward analysis."} | |
| # Each fold is a contiguous train+test block; blocks advance by test length. | |
| block = int(n / (1 + (n_folds - 1) * (1 - train_ratio))) | |
| block = min(block, n) | |
| train_len = int(block * train_ratio) | |
| test_len = block - train_len | |
| if train_len < 100 or test_len < 20: | |
| return {"folds": [], "note": "Not enough history for walk-forward analysis."} | |
| folds: List[Dict[str, object]] = [] | |
| oos_returns: List[pd.Series] = [] | |
| for k in range(n_folds): | |
| start = k * test_len | |
| train = df.iloc[start : start + train_len] | |
| test = df.iloc[start + train_len : start + train_len + test_len] | |
| if len(test) < 20: | |
| break | |
| best_params, best_sharpe = None, -np.inf | |
| for params in grid: | |
| target = strategy.generate(train, params) | |
| sr = run_backtest( | |
| train, target, costs=costs, lag=lag, | |
| max_leverage=max_leverage, allow_short=allow_short, | |
| ).sharpe | |
| if sr > best_sharpe: | |
| best_params, best_sharpe = params, sr | |
| # Generate signals on train+test so indicators are warm at the fold | |
| # boundary, then evaluate only the test slice. | |
| combined = df.iloc[start : start + train_len + len(test)] | |
| target = strategy.generate(combined, best_params).loc[test.index] | |
| oos = run_backtest( | |
| test, target, costs=costs, lag=lag, | |
| max_leverage=max_leverage, allow_short=allow_short, | |
| ) | |
| folds.append( | |
| { | |
| "fold": k + 1, | |
| "train_start": str(train.index[0].date()), | |
| "train_end": str(train.index[-1].date()), | |
| "test_start": str(test.index[0].date()), | |
| "test_end": str(test.index[-1].date()), | |
| "params": best_params, | |
| "is_sharpe": float(best_sharpe), | |
| "oos_sharpe": float(oos.sharpe), | |
| "oos_return": float(oos.metrics.get("total_return", 0.0)), | |
| "oos_max_dd": float(oos.metrics.get("max_drawdown", 0.0)), | |
| } | |
| ) | |
| oos_returns.append(oos.returns) | |
| if progress is not None: | |
| progress((k + 1) / n_folds, f"Walk-forward fold {k + 1}/{n_folds}") | |
| if not folds: | |
| return {"folds": [], "note": "Not enough history for walk-forward analysis."} | |
| is_sharpes = np.array([f["is_sharpe"] for f in folds], dtype=float) | |
| oos_sharpes = np.array([f["oos_sharpe"] for f in folds], dtype=float) | |
| stitched = pd.concat(oos_returns) if oos_returns else pd.Series(dtype=float) | |
| stitched = stitched[~stitched.index.duplicated(keep="first")].sort_index() | |
| mean_is = float(np.mean(is_sharpes)) | |
| mean_oos = float(np.mean(oos_sharpes)) | |
| return { | |
| "folds": folds, | |
| "mean_is_sharpe": mean_is, | |
| "mean_oos_sharpe": mean_oos, | |
| # 1.0 = the edge fully survived; 0.0 = it evaporated out of sample. | |
| "efficiency": float(mean_oos / mean_is) if mean_is > 1e-9 else 0.0, | |
| "oos_win_rate": float(np.mean(oos_sharpes > 0)), | |
| # 1.0 means every fold chose different parameters -- a tuning process | |
| # that cannot make up its mind is fitting noise. | |
| "param_instability": float( | |
| len({str(f["params"]) for f in folds}) / max(len(folds), 1) | |
| ), | |
| "oos_returns": stitched, | |
| "oos_equity": (1.0 + stitched).cumprod() if len(stitched) else stitched, | |
| "note": "", | |
| } | |
| def walk_forward_panel( | |
| panel, | |
| strategy, | |
| n_folds: int = 4, | |
| train_ratio: float = 0.7, | |
| costs: Optional[CostModel] = None, | |
| lag: int = 1, | |
| gross_leverage: float = 1.0, | |
| allow_short: bool = True, | |
| rebalance: str = "M", | |
| grid_limit: int = 16, | |
| progress: Optional[Callable[[float, str], None]] = None, | |
| ) -> Dict[str, object]: | |
| """Walk-forward for cross-sectional strategies over a :class:`Panel`. | |
| Same contract as :func:`walk_forward`: tune on the training window, trade | |
| the next window blind, roll on. Signals are generated over train+test | |
| together so the indicators are warm at the fold boundary, then evaluated | |
| only on the test slice -- the panel equivalent of the single-asset path. | |
| """ | |
| from ..portfolio import rebalance_schedule, run_portfolio_backtest | |
| costs = costs or CostModel() | |
| grid = strategy.grid(limit=grid_limit) | |
| n = len(panel) | |
| if n < 250 or n_folds < 2: | |
| return {"folds": [], "note": "Not enough history for walk-forward analysis."} | |
| block = min(int(n / (1 + (n_folds - 1) * (1 - train_ratio))), n) | |
| train_len = int(block * train_ratio) | |
| test_len = block - train_len | |
| if train_len < 100 or test_len < 20: | |
| return {"folds": [], "note": "Not enough history for walk-forward analysis."} | |
| folds: List[Dict[str, object]] = [] | |
| oos_returns: List[pd.Series] = [] | |
| for k in range(n_folds): | |
| start = k * test_len | |
| train = panel.slice(panel.index[start], panel.index[min(start + train_len - 1, n - 1)]) | |
| test_start = start + train_len | |
| if test_start + 20 > n: | |
| break | |
| test_end = min(test_start + test_len, n) - 1 | |
| test = panel.slice(panel.index[test_start], panel.index[test_end]) | |
| if len(test) < 20: | |
| break | |
| best_params, best_sharpe = None, -np.inf | |
| for params in grid: | |
| weights = strategy.generate(train, params) | |
| sharpe = run_portfolio_backtest( | |
| train, weights, costs=costs, lag=lag, gross_leverage=gross_leverage, | |
| allow_short=allow_short, rebalance_on=rebalance_schedule(train.index, rebalance), | |
| ).sharpe | |
| if sharpe > best_sharpe: | |
| best_params, best_sharpe = params, sharpe | |
| combined = panel.slice(panel.index[start], panel.index[test_end]) | |
| weights = strategy.generate(combined, best_params).loc[test.index] | |
| oos = run_portfolio_backtest( | |
| test, weights, costs=costs, lag=lag, gross_leverage=gross_leverage, | |
| allow_short=allow_short, rebalance_on=rebalance_schedule(test.index, rebalance), | |
| ) | |
| folds.append({ | |
| "fold": k + 1, | |
| "train_start": str(train.index[0].date()), | |
| "train_end": str(train.index[-1].date()), | |
| "test_start": str(test.index[0].date()), | |
| "test_end": str(test.index[-1].date()), | |
| "params": best_params, | |
| "is_sharpe": float(best_sharpe), | |
| "oos_sharpe": float(oos.sharpe), | |
| "oos_return": float(oos.metrics.get("total_return", 0.0)), | |
| "oos_max_dd": float(oos.metrics.get("max_drawdown", 0.0)), | |
| }) | |
| oos_returns.append(oos.returns) | |
| if progress is not None: | |
| progress((k + 1) / n_folds, f"Walk-forward fold {k + 1}/{n_folds}") | |
| if not folds: | |
| return {"folds": [], "note": "Not enough history for walk-forward analysis."} | |
| is_sharpes = np.array([f["is_sharpe"] for f in folds], dtype=float) | |
| oos_sharpes = np.array([f["oos_sharpe"] for f in folds], dtype=float) | |
| stitched = pd.concat(oos_returns) if oos_returns else pd.Series(dtype=float) | |
| stitched = stitched[~stitched.index.duplicated(keep="first")].sort_index() | |
| mean_is, mean_oos = float(np.mean(is_sharpes)), float(np.mean(oos_sharpes)) | |
| return { | |
| "folds": folds, | |
| "mean_is_sharpe": mean_is, | |
| "mean_oos_sharpe": mean_oos, | |
| "efficiency": float(mean_oos / mean_is) if mean_is > 1e-9 else 0.0, | |
| "oos_win_rate": float(np.mean(oos_sharpes > 0)), | |
| "param_instability": float(len({str(f["params"]) for f in folds}) / max(len(folds), 1)), | |
| "oos_returns": stitched, | |
| "oos_equity": (1.0 + stitched).cumprod() if len(stitched) else stitched, | |
| "note": "", | |
| } | |