← Home

Implement the Multivariate Bootstrap method used in Aizhan Anarkulova's articles [INCOMPLETE]

This is "101" implementation, playing with simpler data sets and fewer assets to understand the power and the limitations of the bootstrap method.

  • Respects serial dependence and cross-asset correlation via synchronized stationary block bootstrap
  • Downloads free adjusted-close price data via yfinance
  • Generates grid of allocations (10% increments) over SPY, VT, GLD, IEF
  • Estimates expected outcomes over 5, 10, 20, and 30-year horizons: mean final value, median final value, 5th/95th percentiles, probability of loss, mean/median CAGR
  • Automatically handles simulation start point based on overlapping data availability
In [1]:
import numpy as np
import pandas as pd
from dataclasses import dataclass
from typing import List, Dict
from tqdm import tqdm
import plotly.express as px
import yfinance as yf

yf.__version__
Out[1]:
'0.2.65'
In [7]:
@dataclass
class BootstrapOutcome:
    horizon_years: int
    weights: Dict[str, float]
    weights_txt: str # For display purposes
    mean_final: float
    median_final: float
    p5_final: float
    p95_final: float
    probability_loss: float
    mean_cagr: float
    median_cagr: float

def download_adjusted_prices(tickers: List[str], start: str = None, end: str = None, interval: str = "1d") -> pd.DataFrame:
    data = yf.download(tickers, start=start, end=end, interval=interval, progress=False, auto_adjust=True)
    if 'Close' in data.columns:
        prices = data['Close'].copy()
    else:
        prices = data.copy()
    if isinstance(prices, pd.Series):
        prices = prices.to_frame()
    prices.columns = tickers if len(tickers) > 1 else [tickers[0]]
    prices = prices.sort_index()
    return prices

def compute_monthly_returns(prices: pd.DataFrame) -> pd.DataFrame:
    monthly = prices.resample('ME').last() # ME = Month End
    returns = monthly.pct_change().dropna(how='any')
    return returns

def stationary_bootstrap_indices(n_obs: int, horizon: int, avg_block_size: int, rng: np.random.Generator) -> np.ndarray:
    p_new = 1.0 / avg_block_size
    indices = np.empty(horizon, dtype=int)
    indices[0] = rng.integers(0, n_obs)
    for t in range(1, horizon):
        if rng.random() < (1 - p_new):
            indices[t] = (indices[t-1] + 1) % n_obs
        else:
            indices[t] = rng.integers(0, n_obs)
    return indices

def simulate_bootstrap_paths(returns: pd.DataFrame, horizon_months: int, n_sims: int,
                             avg_block_size: int, rng: np.random.Generator) -> np.ndarray:
    data = returns.to_numpy()
    n_obs, n_assets = data.shape
    block = min(max(2, avg_block_size), max(2, n_obs))
    sims = np.empty((n_sims, horizon_months, n_assets))
    for i in range(n_sims):
        idx = stationary_bootstrap_indices(n_obs, horizon_months, block, rng)
        sims[i] = data[idx]
    return sims

def generate_weight_grid(n_assets: int, step: float = 0.10) -> List[np.ndarray]:
    from itertools import product
    steps = np.arange(0, 1 + step, step)
    combinations = product(steps, repeat=n_assets)
    grids = []
    for combo in combinations:
        if abs(sum(combo) - 1.0) < 1e-10:
            grids.append(np.array(combo))
    return grids

def compute_portfolio_statistics(sims: np.ndarray, weights: np.ndarray, years: int) -> BootstrapOutcome:
    """
    sims: shape (n_sims, n_months, n_assets)
    weights: shape (n_assets,)
    years: investment horizon in years (n_months = years * 12)
    Returns all statistics based on annualized (CAGR) returns.
    """
    sims_pr = sims @ weights  # shape (n_sims, n_months)
    # Compute final value for each simulation
    final_values = np.prod(1 + sims_pr, axis=1)
    # Compute CAGR for each simulation
    cagr = final_values ** (1.0 / years) - 1.0
    # All statistics below are based on annualized returns (CAGR)
    return BootstrapOutcome(
        horizon_years=years,
        weights=dict(zip(['SPY', 'VT', 'GLD', 'IEF'], weights)),
        weights_txt=' + '.join(f"{w:.0%} {k}" for w, k in zip(weights, ['SPY', 'VT', 'GLD', 'IEF']) if w > 0),
        mean_final=float(np.mean(cagr)),
        median_final=float(np.median(cagr)),
        p5_final=float(np.percentile(cagr, 5)),
        p95_final=float(np.percentile(cagr, 95)),
        probability_loss=float(np.mean(cagr < 0)),
        mean_cagr=float(np.mean(cagr)),
        median_cagr=float(np.median(cagr)),
    )
In [10]:
tickers = ['SPY', 'VT', 'GLD', 'IEF']
start = start="1900-01-01"
end  = None
n_sims = 4000
avg_block_months = 12
horizons = [5, 10, 20, 30]
weight_step = 0.10
random_seed = 42

rng = np.random.default_rng(random_seed)
prices = download_adjusted_prices(tickers, start, end)
print(f"Display prices: oldest available since {start}")
display(prices)
returns = compute_monthly_returns(prices)
print(f"Display returns: oldest available FOR ALL since {start}")
display(returns)
if returns.shape[0] < 36:
    raise ValueError(f"Not enough data points: only {returns.shape[0]} months available.")
effective_block = min(max(2, avg_block_months), max(2, returns.shape[0] // 5))
print(f"Monthly returns from {returns.index.min().date()} to {returns.index.max().date()}{returns.shape[0]} months")
print(f"Effective average block size (months): {effective_block}")
weight_sets = generate_weight_grid(n_assets=len(tickers), step=weight_step)
print(f"Generated {len(weight_sets)} weight sets with step {weight_step:.2f} for {len(tickers)} assets")
outcomes = []
total = len(horizons) * len(weight_sets)
with tqdm(total=total, desc="Simulations") as pbar:
    for years in horizons:
        months = years * 12
        sims = simulate_bootstrap_paths(returns, months, n_sims, effective_block, rng)
        for w in weight_sets:
            outcome = compute_portfolio_statistics(sims, w, years)
            outcomes.append(outcome)
            pbar.update(1)
            
df = pd.DataFrame([{
    'horizon_years': outcome.horizon_years,
    'weight_txt': outcome.weights_txt,
    'mean_final': outcome.mean_final,
    'median_final': outcome.median_final,
    'p5_final': outcome.p5_final,
    'p95_final': outcome.p95_final,
    'probability_loss': outcome.probability_loss,
    'mean_cagr': outcome.mean_cagr,
    'median_cagr': outcome.median_cagr,
    **{f'w_{ticker}': outcome.weights[ticker] for ticker in tickers}
} for outcome in outcomes])
df = df.round(3).sort_values(['horizon_years', 'mean_cagr'], ascending=[True, False]).reset_index(drop=True)
display(df)
Display prices: oldest available since 1900-01-01
SPY VT GLD IEF
Date
1993-01-29 NaN NaN 24.380434 NaN
1993-02-01 NaN NaN 24.553848 NaN
1993-02-02 NaN NaN 24.605873 NaN
1993-02-03 NaN NaN 24.865955 NaN
1993-02-04 NaN NaN 24.970011 NaN
... ... ... ... ...
2025-08-11 308.549988 95.410004 635.919983 131.460007
2025-08-12 308.269989 95.389999 642.690002 133.029999
2025-08-13 309.209991 95.730003 644.890015 133.699997
2025-08-14 307.250000 95.419998 644.950012 133.380005
2025-08-15 307.429993 95.230003 643.440002 133.380005

8193 rows × 4 columns

Display returns: oldest available FOR ALL since 1900-01-01
SPY VT GLD IEF
Date
2008-07-31 -0.014442 0.007206 -0.008986 -0.026847
2008-08-31 -0.092917 0.015266 0.015454 -0.018461
2008-09-30 0.041121 -0.001403 -0.094174 -0.091082
2008-10-31 -0.161397 -0.008628 -0.165186 -0.214369
2008-11-30 0.125736 0.077538 -0.069607 -0.068067
... ... ... ... ...
2025-04-30 0.054244 0.010561 -0.008670 0.005606
2025-05-31 -0.000560 -0.012396 0.062845 0.058062
2025-06-30 0.004051 0.016020 0.051386 0.046732
2025-07-31 -0.006135 -0.005939 0.023032 0.010971
2025-08-31 0.014754 0.006755 0.017972 0.026553

206 rows × 4 columns

Monthly returns from 2008-07-31 to 2025-08-31 — 206 months
Effective average block size (months): 12
Generated 286 weight sets with step 0.10 for 4 assets
Simulations: 100%|██████████| 1144/1144 [00:11<00:00, 97.92it/s]
horizon_years weight_txt mean_final median_final p5_final p95_final probability_loss mean_cagr median_cagr w_SPY w_VT w_GLD w_IEF
0 5 100% GLD 0.120 0.124 0.004 0.223 0.044 0.120 0.124 0.0 0.0 1.0 0.0
1 5 10% SPY + 90% GLD 0.118 0.121 0.012 0.209 0.030 0.118 0.121 0.1 0.0 0.9 0.0
2 5 90% GLD + 10% IEF 0.117 0.120 0.001 0.220 0.050 0.117 0.120 0.0 0.0 0.9 0.1
3 5 20% SPY + 80% GLD 0.115 0.116 0.023 0.200 0.020 0.115 0.116 0.2 0.0 0.8 0.0
4 5 10% SPY + 80% GLD + 10% IEF 0.114 0.117 0.008 0.208 0.035 0.114 0.117 0.1 0.0 0.8 0.1
... ... ... ... ... ... ... ... ... ... ... ... ... ...
1139 30 20% SPY + 80% VT 0.040 0.039 0.016 0.063 0.003 0.040 0.039 0.2 0.8 0.0 0.0
1140 30 90% VT + 10% GLD 0.039 0.039 0.019 0.058 0.000 0.039 0.039 0.0 0.9 0.1 0.0
1141 30 90% VT + 10% IEF 0.035 0.036 0.016 0.055 0.001 0.035 0.036 0.0 0.9 0.0 0.1
1142 30 10% SPY + 90% VT 0.034 0.034 0.012 0.057 0.005 0.034 0.034 0.1 0.9 0.0 0.0
1143 30 100% VT 0.029 0.029 0.007 0.050 0.014 0.029 0.029 0.0 1.0 0.0 0.0

1144 rows × 13 columns

In [11]:
for ww in ['w_SPY', 'w_VT', 'w_GLD', 'w_IEF']:
    fig = px.scatter(
        df,
        x="median_final",
        y="p5_final",
        color=ww,
        facet_col="horizon_years",
        hover_data=[col for col in df.columns if not col.startswith('w_') and col not in ['median_final', 'p5_final', 'horizon_years']],
    )
    fig.update_layout(height=250, width=1200, margin=dict(t=20, b=20, l=20, r=20))
    fig.for_each_yaxis(lambda y: y.update(matches=None, showticklabels=True, tickformat=".0%"))
    fig.for_each_xaxis(lambda x: x.update(matches=None, tickformat=".0%"))
    fig.show()
← Home