Skip to content

Walk-Forward

Rolling walk-forward optimization: optimize parameters on a training window, then test on the next out-of-sample window. Repeat across the dataset.

Complete Example

from replaybt import CSVProvider, Strategy, MarketOrder, Side
from replaybt import WalkForward


class EMACrossover(Strategy):
    def configure(self, config):
        self._prev_fast = self._prev_slow = None

    def on_bar(self, bar, indicators, positions):
        fast = indicators.get("ema_fast")
        slow = indicators.get("ema_slow")
        if fast is None or slow is None or self._prev_fast is None:
            self._prev_fast, self._prev_slow = fast, slow
            return None

        crossed_up = fast > slow and self._prev_fast <= self._prev_slow
        self._prev_fast, self._prev_slow = fast, slow

        if not positions and crossed_up:
            return MarketOrder(side=Side.LONG)
        return None


wf = WalkForward(
    strategy_class=EMACrossover,
    data=CSVProvider("ETH_1m.csv", symbol_name="ETH"),
    base_config={
        "initial_equity": 10_000,
        "indicators": {
            "ema_fast": {"type": "ema", "period": 5, "source": "close"},
            "ema_slow": {"type": "ema", "period": 10, "source": "close"},
        },
    },
    param_grid={
        "take_profit_pct": [0.04, 0.06, 0.08],
        "stop_loss_pct": [0.02, 0.03, 0.04],
    },
    n_windows=4,       # number of train/test windows
    train_pct=0.60,    # 60% train, 40% test per window
    metric="net_pnl",  # optimize for net PnL
    anchored=False,    # sliding windows (vs anchored)
)

result = wf.run()
print(result.summary())

Parameters

WalkForward(
    strategy_class=MyStrategy,
    data=data_provider,
    base_config=config_dict,
    param_grid=param_dict,
    n_windows=4,          # number of train/test windows
    train_pct=0.60,       # train fraction per window
    metric="net_pnl",     # metric to optimize on train
    anchored=False,       # False=sliding, True=anchored
    n_workers=10,         # parallel workers for sweep
)

Sliding vs Anchored

Sliding (default): each window is a fixed-size slice that moves forward.

Window 1: [====TRAIN====][==TEST==]
Window 2:       [====TRAIN====][==TEST==]
Window 3:             [====TRAIN====][==TEST==]

Anchored: training always starts from the beginning, growing larger.

Window 1: [====TRAIN====][==TEST==]
Window 2: [========TRAIN========][==TEST==]
Window 3: [============TRAIN============][==TEST==]

WalkForwardResult

result = wf.run()

# Aggregate OOS metrics
result.oos_net_pnl           # combined OOS PnL
result.oos_total_trades      # combined OOS trades
result.oos_win_rate          # combined OOS win rate
result.oos_max_drawdown_pct  # combined OOS max drawdown

# Parameter stability
result.param_stability       # {param_name: [values per window]}
result.params_consistent     # True if same params chosen >50% of windows

# Per-window details
for w in result.windows:
    print(f"Window {w.window_index}: "
          f"train PnL=${w.train_metrics['net_pnl']:,.0f}, "
          f"test PnL=${w.test_result.net_pnl:,.0f}, "
          f"params={w.best_params}")

Interpreting Results

  • params_consistent = True: the same parameters keep winning across windows. Good sign.
  • params_consistent = False: optimal parameters change every window. Likely overfitting.
  • OOS PnL negative: the strategy doesn't generalize. Reconsider the approach.