Project write-up · Strategy / Backtesting
Stress-testing portfolio strategies with a block bootstrap
The problem
A historical backtest tells you how a strategy did on the one path history happened to take. To compare buy-and-hold, DCA, rebalancing and momentum rules fairly, you want many plausible futures. The textbook way is to simulate Gaussian, independent returns, but real returns have fat tails, volatility clusters, and correlations between assets that the simulation then throws away. A momentum rule in particular depends on how returns follow each other, which is exactly what independent draws destroy.
Approach: resample blocks of real history
Instead of drawing from a fitted distribution, the simulator cuts the historical monthly returns into blocks (6 months by default) and stitches randomly chosen blocks together into new paths. Each block is taken for all assets at once. That keeps:
- cross-asset correlation, because a block holds every asset's returns for the same months;
- short-range dependence, such as volatility clustering and short-term momentum, within each block;
- fat tails, because only returns that really happened are used.
starts = rng.integers(0, T - block_size + 1, size=(n_sims, n_blocks))
for b in range(n_blocks):
idx = starts[:, b][:, None] + np.arange(block_size)[None, :]
out[:, b*block_size:(b+1)*block_size, :] = hist_returns[idx] # all assets together
Every strategy then runs on all paths at once (1,000 by default, NumPy-vectorised). Momentum rules compute their trailing returns from each simulated path, so they only ever see that path's past.
Strategies and outputs
- Strategies: buy-and-hold, DCA without rebalancing, periodic rebalancing, relative momentum (hold the top K assets by trailing return) and dual momentum (relative momentum plus a trend filter that moves losing assets to cash).
- Forward view: a fan chart of the 5th to 95th percentiles of portfolio value, against money invested, and the probability of reaching a target value.
- Historical view: CAGR, volatility, Sharpe, Sortino, maximum drawdown, Calmar, and monthly 95% VaR and CVaR.
Limits and what I'd change
- Block size is a trade-off. Short blocks lose dependence, long blocks make paths look too much like history. The app lets you change it (1 to 24 months); a stationary bootstrap (random block lengths, Politis–Romano) would make results less sensitive to that choice.
- Edge months are undersampled. Blocks don't wrap around the end of the sample, so the first and last months of history appear in fewer blocks than the middle ones. A circular block bootstrap fixes this.
- The future is assumed to look like the lookback window. Fifteen years of a bond bull market will make bonds look safer than they may be.
- It's a sandbox, not an execution simulator: no transaction costs, slippage or taxes, a monthly grid, and cash earns 0%.