Skip to main content

walk-forward validation: stopped fooling myself with in-sample results

2:15 AM monday.

A. called it around 11:30. she reads for like 20 minutes and then just drops — book still open on the nightstand, her laptop sitting open on the coffee table. I turned the screen off around midnight, refilled my coffee, sat back down.

april’s been closed for four days. account at $1.214M, YTD +1.5%. not writing home about it but it’s real. the options book ran clean through may 1st, theta collected exactly as modeled, NQ adaptive lookback has been live for 10 days and holding. all good.

except crypto. -$800 for the whole month of april. in conditions that should’ve been ideal.

been staring at that number for an hour. tonight I figured out why.


the problem with my backtest
#

BTC was between $88k and $93k almost the entire month of april. sideways, choppy, low realized vol. exactly the environment my BTC momentum strategy was built to run in — momentum-fade alpha with a vol filter. should’ve been collecting small consistent wins.

it didn’t. and I kept telling myself it was just noise. bad luck. wrong timing.

it wasn’t luck. the parameters were stale.

I optimized the BTC strategy in january on 2024 data. it looked incredible — Sharpe 1.62, max drawdown under 12%. I deployed those parameters and didn’t touch them. four months later, the market structure had shifted and I was still running january’s optimal config.

the problem is in-sample optimization. I knew this was a risk. I did it anyway because the backtest looked good and I was lazy about building the proper validation infrastructure.

tonight I built it.


walk-forward: the actual fix
#

most backtesting works like this: pick your full historical period, run an optimization grid across the whole thing, find the parameters that maximize Sharpe on that dataset, declare victory. the problem is those parameters are curve-fit to noise in that specific data. out-of-sample they fall apart.

walk-forward is the proper fix. instead of one big optimization window, you use rolling windows:

  1. train window (90 days): grid search for best parameters
  2. test window (30 days immediately after): evaluate those exact parameters on data they’ve never seen
  3. slide everything forward by 30 days, repeat
  4. stitch the OOS results together: that combined series is your actual performance estimate

the combined out-of-sample Sharpe is the number that matters. not the in-sample number. if they diverge badly, you built a curve-fitter.

I did this for the NQ momentum signal two weeks ago when the adaptive lookback work surfaced similar parameter instability. should’ve done it for BTC at the same time. I didn’t. tonight I did.


the code
#

walk-forward optimizer for the BTC strategy. runs the full parameter grid inside each training window, evaluates the winner OOS, stores everything:

import numpy as np
import pandas as pd
from dataclasses import dataclass, field
from typing import Optional
from itertools import product
import logging

logger = logging.getLogger(__name__)


@dataclass
class WalkForwardResult:
    window_id: int
    train_start: pd.Timestamp
    train_end: pd.Timestamp
    test_start: pd.Timestamp
    test_end: pd.Timestamp
    best_params: dict
    train_sharpe: float
    test_sharpe: float
    test_returns: pd.Series = field(default_factory=pd.Series)
    train_returns: pd.Series = field(default_factory=pd.Series)


@dataclass
class WalkForwardConfig:
    train_days: int = 90
    test_days: int = 30
    step_days: int = 30
    min_trades: int = 40

    # BTC momentum parameter grid
    momentum_period: tuple = (10, 20, 30, 45)
    vol_threshold: tuple = (0.15, 0.20, 0.22, 0.25, 0.30)
    entry_threshold: tuple = (0.30, 0.50, 0.55, 0.70, 0.90)


class BTCWalkForwardOptimizer:
    """
    Walk-forward optimizer for BTC directional momentum strategy.
    Hourly OHLCV data via Binance API (ccxt). Directional entries only,
    adaptive parameter selection across rolling windows.
    """

    def __init__(self, data: pd.DataFrame, config: WalkForwardConfig):
        self.data = data
        self.config = config
        self.results: list[WalkForwardResult] = []
        self.account_size = 360_000.0  # BTC allocation (~30% of 1.2M)

    def _build_signals(self, data: pd.DataFrame, params: dict) -> pd.DataFrame:
        """
        Compute momentum signals with given parameters.
        Returns DataFrame with signal columns added.
        """
        mp = params['momentum_period']
        vt = params['vol_threshold']
        et = params['entry_threshold']

        df = data.copy()

        # rate of change, EMA-smoothed to reduce whipsaws
        roc = df['close'].pct_change(mp)
        ema_span = max(3, mp // 4)
        df['momentum'] = roc.ewm(span=ema_span, adjust=False).mean()

        # annualized realized vol on hourly bars (*sqrt(8760))
        log_ret = np.log(df['close'] / df['close'].shift(1))
        df['rvol'] = log_ret.rolling(24 * 20).std() * np.sqrt(8760)

        # volume confirmation z-score
        vol_20d_mean = df['volume'].rolling(24 * 20).mean()
        vol_20d_std = df['volume'].rolling(24 * 20).std().replace(0, np.nan)
        df['vol_zscore'] = (df['volume'] - vol_20d_mean) / vol_20d_std

        # entry conditions
        df['long_entry'] = (
            (df['momentum'] > et)
            & (df['rvol'] < vt)
            & (df['vol_zscore'] > 0.3)
        )
        df['short_entry'] = (
            (df['momentum'] < -et)
            & (df['rvol'] < vt)
            & (df['vol_zscore'] > 0.3)
        )

        return df.dropna(subset=['momentum', 'rvol', 'vol_zscore'])

    def _simulate(self, data: pd.DataFrame, params: dict) -> pd.DataFrame:
        """
        Event-loop simulation on hourly bars.
        Returns trade log with entry/exit timestamps and PnL.
        """
        df = self._build_signals(data, params)
        if df.empty:
            return pd.DataFrame()

        max_hold_bars = 48  # 2-day max at 1h bars
        et = params['entry_threshold']

        trades = []
        position = 0       # -1, 0, or 1
        entry_price = 0.0
        entry_time = None
        bars_held = 0

        for i in range(len(df)):
            row = df.iloc[i]

            # exit logic
            if position != 0:
                bars_held += 1
                exit_now = (
                    bars_held >= max_hold_bars
                    or (position == 1 and row['momentum'] < -et * 0.5)
                    or (position == -1 and row['momentum'] > et * 0.5)
                    or (row['rvol'] > params['vol_threshold'] * 2.0)
                )
                if exit_now:
                    pnl = (row['close'] - entry_price) * position
                    trades.append({
                        'entry_time': entry_time,
                        'exit_time': row.name,
                        'direction': position,
                        'entry_price': entry_price,
                        'exit_price': row['close'],
                        'pnl': pnl,
                        'bars_held': bars_held,
                    })
                    position = 0
                    bars_held = 0

            # entry logic (only when flat)
            if position == 0:
                if bool(row['long_entry']):
                    position = 1
                    entry_price = row['close']
                    entry_time = row.name
                elif bool(row['short_entry']):
                    position = -1
                    entry_price = row['close']
                    entry_time = row.name

        if not trades:
            return pd.DataFrame()

        return pd.DataFrame(trades).set_index('entry_time')

    def _sharpe(self, trades: pd.DataFrame) -> Optional[float]:
        if trades.empty or len(trades) < self.config.min_trades:
            return None
        daily = trades['pnl'].resample('D').sum() / self.account_size
        std = daily.std()
        if std == 0 or np.isnan(std):
            return None
        return float(daily.mean() / std * np.sqrt(252))

    def run(self) -> list[WalkForwardResult]:
        """Run the full walk-forward optimization across all rolling windows."""
        self.results = []

        data_end = self.data.index[-1]
        train_td = pd.Timedelta(days=self.config.train_days)
        test_td  = pd.Timedelta(days=self.config.test_days)
        step_td  = pd.Timedelta(days=self.config.step_days)

        param_grid = list(product(
            self.config.momentum_period,
            self.config.vol_threshold,
            self.config.entry_threshold,
        ))

        cursor   = self.data.index[0]
        window_id = 0

        while True:
            train_start = cursor
            train_end   = cursor + train_td
            test_start  = train_end
            test_end    = test_start + test_td

            if test_end > data_end:
                break

            train_data = self.data[train_start:train_end]
            test_data  = self.data[test_start:test_end]

            if len(train_data) < 500 or len(test_data) < 200:
                cursor += step_td
                window_id += 1
                continue

            # grid search on training window
            best_params, best_train_sharpe = None, -np.inf

            for mp, vt, et in param_grid:
                params = {'momentum_period': mp, 'vol_threshold': vt, 'entry_threshold': et}
                trades = self._simulate(train_data, params)
                s = self._sharpe(trades)
                if s is not None and s > best_train_sharpe:
                    best_train_sharpe = s
                    best_params = params

            if best_params is None:
                logger.warning(f"Window {window_id}: no valid params, skipping")
                cursor += step_td
                window_id += 1
                continue

            # evaluate on OOS test window with winning params
            test_trades  = self._simulate(test_data, best_params)
            train_trades = self._simulate(train_data, best_params)

            def daily_rets(t):
                if t.empty:
                    return pd.Series(dtype=float)
                return t['pnl'].resample('D').sum() / self.account_size

            test_rets  = daily_rets(test_trades)
            train_rets = daily_rets(train_trades)

            test_sharpe = (
                float(test_rets.mean() / test_rets.std() * np.sqrt(252))
                if len(test_rets) > 1 and test_rets.std() > 0
                else 0.0
            )

            self.results.append(WalkForwardResult(
                window_id=window_id,
                train_start=train_start,
                train_end=train_end,
                test_start=test_start,
                test_end=test_end,
                best_params=best_params,
                train_sharpe=best_train_sharpe,
                test_sharpe=test_sharpe,
                test_returns=test_rets,
                train_returns=train_rets,
            ))

            cursor += step_td
            window_id += 1

        return self.results

    def combined_oos_returns(self) -> pd.Series:
        """Stitch OOS test windows into a single continuous returns series."""
        parts = [r.test_returns for r in self.results if not r.test_returns.empty]
        return pd.concat(parts).sort_index() if parts else pd.Series(dtype=float)

    def summary(self) -> dict:
        oos = self.combined_oos_returns()
        if len(oos) < 5:
            return {}
        equity   = (1 + oos).cumprod()
        drawdown = equity / equity.cummax() - 1
        return {
            'windows':           len(self.results),
            'oos_days':          len(oos),
            'oos_sharpe':        round(float(oos.mean() / oos.std() * np.sqrt(252)), 2),
            'oos_return_pct':    round(float((1 + oos).prod() - 1) * 100, 1),
            'max_drawdown_pct':  round(float(drawdown.min()) * 100, 1),
            'positive_windows':  sum(1 for r in self.results if r.test_sharpe > 0),
            'avg_train_sharpe':  round(float(np.mean([r.train_sharpe for r in self.results])), 2),
        }

you run it like this:

# hourly BTC OHLCV from TimescaleDB, Jan 2024 through end of April 2026
data = ts.query_ohlcv('BTC', '1h', '2024-01-01', '2026-04-30')

cfg = WalkForwardConfig()
optimizer = BTCWalkForwardOptimizer(data, cfg)
results = optimizer.run()

print(optimizer.summary())
# {
#   'windows': 14, 'oos_days': 420,
#   'oos_sharpe': 0.71, 'oos_return_pct': 8.1,
#   'max_drawdown_pct': -9.3,
#   'positive_windows': 9, 'avg_train_sharpe': 1.62
# }

in-sample average: 1.62. combined OOS sharpe: 0.71.

that’s the gap. that’s what I was actually running.


infrastructure: storing this properly
#

every walk-forward run generates a lot of state. I push it all to TimescaleDB so I can track parameter evolution over time — which matters more than any single run result.

CREATE TABLE IF NOT EXISTS btc_wf_runs (
    run_id       TEXT        NOT NULL,
    window_id    INT         NOT NULL,
    train_start  TIMESTAMPTZ NOT NULL,
    test_start   TIMESTAMPTZ NOT NULL,
    test_end     TIMESTAMPTZ NOT NULL,
    best_params  JSONB       NOT NULL,
    train_sharpe FLOAT8,
    test_sharpe  FLOAT8,
    oos_ret_arr  FLOAT8[],   -- daily return array for the test window
    inserted_at  TIMESTAMPTZ DEFAULT NOW(),
    PRIMARY KEY  (run_id, window_id)
);

each run gets a tagged run_id like btc_wf_20260504_001. I can then query the parameter evolution:

SELECT
    test_start::date,
    best_params->>'momentum_period' AS mom_period,
    best_params->>'vol_threshold'   AS vol_thresh,
    best_params->>'entry_threshold' AS entry_thresh,
    ROUND(train_sharpe::numeric, 2) AS train_s,
    ROUND(test_sharpe::numeric, 2)  AS test_s
FROM btc_wf_runs
WHERE run_id = 'btc_wf_20260504_001'
ORDER BY window_id;

that query is what built the second chart below. seeing parameter drift across windows is the actual signal — more useful than any single OOS number.

the walk-forward runner is now scheduled as a cron job: runs on the 1st of each month, uses the prior 90 days as training, deploys new parameters automatically if the OOS validation passes a minimum Sharpe threshold (0.40 currently). should’ve had this since day one.


chart 1: in-sample vs out-of-sample equity
#

the in-sample equity is the comfortable story. the OOS equity is what actually happened.

In-sample (blue): parameter-optimized curve on 2024 data. OOS (orange, dashed): what those same parameters actually delivered on unseen data starting Jan 2025. Net positive — Sharpe 0.71 on 14 months. But it’s not 1.62. The gap is where april’s -$800 lives.


chart 2: parameter drift across windows
#

this one showed me the root cause. different windows want different parameters. if they’re jumping around randomly, you’re fitting noise.

Momentum period (purple bars) is the unstable one — jumping between 10, 20, and 30 depending on the window. Vol threshold and entry threshold (green/orange lines) stay more consistent. The pattern: windows where momentum period was 10 (W3, W4, W8) had the weakest OOS Sharpe. Fast-period settings overfit to specific short-term BTC structure that doesn’t persist.


what changes for may
#

the strategy has real edge. 14-month OOS Sharpe of 0.71 with 9 of 14 windows positive isn’t curve-fitting — it’s a real signal. I’m not killing it.

but the current parameters were stale. based on the last three walk-forward windows, the optimal config for the current BTC regime (sideways, vol in the 18-22% annualized range) is:

  • momentum_period: 20
  • vol_threshold: 0.22
  • entry_threshold: 0.55

I was running 30/0.20/0.50. the entry threshold difference alone filters out a significant chunk of the false signals that cost me in april’s chop. the 20-period momentum reads the current BTC structure better than 30.

reconfigured at midnight. live as of monday open.


may setup
#

account at $1.214M, YTD +1.5%. conservative start to 2026 but the structures are intact.

options book rolling fresh into may — no leftover positions from april. theta strategy parameters unchanged, IV rank has been running 28-35%, which is exactly the sweet spot for iron condors on SPX and QQQ. NQ adaptive lookback has 10 days of live data, holding its backtest Sharpe so far.

crypto now running validated parameters. the walk-forward cron job will run again June 1.


one thing my dad used to say when he was running biotech projections: “the comfortable answer is the one that needs the most scrutiny.” I kept the in-sample backtest results because they looked good and I was comfortable. didn’t poke hard enough at them.

poking harder now. took longer than it should have. going forward it’s automated.


the NexusFi Attack of the Robots journal has been running since 2019 and some of the later sections on parameter stability and regime awareness are legitimately useful — different tooling but the same core problem of strategies drifting over time. worth reading through if this walk-forward stuff is new to you.

-AK

Related

april theta harvest: weekly closed clean, colo queue backed up, thursday hit different
2:30 AM. friday night. A. made chicken marsala — she does it maybe once a month and I forget every time how good it is. ate around 7, she went back to her desk, lights off in the bedroom by midnight. apartment’s quiet. been staring at P&L since 11.
nq momentum signal: adaptive lookback after the tariff vol test
2:30 AM wednesday. A. finished something around 1 and went to bed still holding her coffee mug. found it on the counter half-full when I went for water. she’s like that when she’s in flow — stops the world when she figures it out.
replaying the yen carry unwind: validating sqs against a real vol event
2:15 AM monday. system’s been clean since the websocket IV fix went live friday. heartbeat healthy, colo latency normal, no stale data flags. spent most of sunday going deep on something i’ve been meaning to do since the tariff postmortem.
tariff week post-mortem: what the data actually showed
2:30 AM monday. week one of what i’m calling “the post-tariff-chaos era” starts in a few hours. last week was one of those that splits into a clear before and after. monday and tuesday felt like freefall — VIX went from 20 to 32 in about 36 hours, SPX dropped hard, options spreads blew out 3-4x, and my event risk throttle (which I built the week prior and wrote about here) was earning every line of code it took to build. then wednesday happened. whoever made the tariff pause call did it at 1:07 PM eastern and watching the S&P rip 8% in ninety minutes while running algorithms was… a lot.
execution quality tracking: slippage attribution across 40 algo positions
2:45 AM monday. A. went to bed around midnight after spending the evening fighting a client’s postgres migration that kept deadlocking under load. she was frustrated, said goodnight, gave me a look that meant don’t be up all night. I said I wouldn’t be.
fixing the stale iv problem: thetadata websocket streaming for real-time greeks
2:30 AM friday. been at this since 9 PM. promised myself two weeks ago, right in the middle of the tariff chaos, that i’d actually fix the IV rank staleness issue. the signal quality scoring work was the band-aid — a composite gate that tells the system “this signal isn’t reliable right now.” it worked. it’s in production. but the underlying problem was unchanged: during the spike, my IV rank was being computed from options data that was 10-14 minutes old. the signal wasn’t wrong, technically. it was just answering a question about a market that no longer existed.