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:
- train window (90 days): grid search for best parameters
- test window (30 days immediately after): evaluate those exact parameters on data they’ve never seen
- slide everything forward by 30 days, repeat
- 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: 20vol_threshold: 0.22entry_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