Ctrl K

S&P 500 Long-Term Performance

Deep analysis of S&P 500 risk-return characteristics using SPY as the proxy. The analysis covers five areas:

  1. Current snapshot: daily and monthly risk-return metrics, rolling Sharpe and Sortino
  2. Risk horizon: annual returns, loss probability by horizon, rolling returns, wealth index
  3. Long-term volatility: rolling vol windows, calibrated regime bands, time in each regime
  4. Risk-adjusted returns: rolling Sharpe across multiple windows
  5. Bootstrap simulation: five year forward paths with confidence intervals
Parameter Value
Ticker SPY (S&P 500 ETF)
Data source Tiingo daily adjusted prices
History start January 1993 (SPY inception)
Simulation paths 1,000 bootstrap paths over 5 years
Bootstrap seed 42 (reproducible)

Requires: TIINGO_API_KEY in /.env.

In [1]:
import os
import math
import numpy as np
import pandas as pd
import requests
import matplotlib
import matplotlib.pyplot as plt
import matplotlib.dates as mdates
import matplotlib.ticker as mticker
from matplotlib.patches import Patch

from pathlib import Path
from dotenv import load_dotenv
from IPython.display import display, HTML

FL_BLUE   = '#2563eb'
FL_SLATE  = '#64748b'
FL_AMBER  = '#f59e0b'
FL_GREEN  = '#16a34a'
FL_RED    = '#ef4444'
FL_BG     = '#ffffff'
FL_GRID   = '#e2e8f0'
FL_TEXT   = '#0f172a'
FL_TEXT2  = '#334155'
FL_BORDER = '#e2e8f0'

matplotlib.rcParams.update({
    'figure.facecolor':  FL_BG,
    'axes.facecolor':    FL_BG,
    'axes.edgecolor':    FL_BORDER,
    'axes.labelcolor':   FL_TEXT2,
    'axes.spines.top':   False,
    'axes.spines.right': False,
    'axes.grid':         True,
    'grid.color':        FL_GRID,
    'grid.linewidth':    0.7,
    'xtick.color':       FL_TEXT2,
    'ytick.color':       FL_TEXT2,
    'xtick.labelsize':   10,
    'ytick.labelsize':   10,
    'axes.labelsize':    11,
    'axes.titlesize':    12,
    'axes.titlecolor':   FL_TEXT,
    'axes.titlepad':     12,
    'legend.frameon':    False,
    'legend.fontsize':   10,
    'figure.dpi':        300,
    'savefig.bbox':      'tight',
    'font.family':       'sans-serif',
    'font.sans-serif':   ['Inter', 'Helvetica Neue', 'Arial', 'DejaVu Sans'],
})

ENV_PATH = Path.cwd() / '.env'
load_dotenv(ENV_PATH)
#print(f'Loaded .env : {ENV_PATH}')

TIINGO_API_KEY = os.getenv('TIINGO_API_KEY')
if not TIINGO_API_KEY:
    raise RuntimeError(f'TIINGO_API_KEY not found in {ENV_PATH}')

TICKER     = 'SPY'
START_DATE = '1993-01-01'

Helper functions

Tiingo fetch, return computations, formatting, and the HTML table/card renderer.

In [2]:
def fetch_tiingo(ticker: str, start_date: str) -> pd.DataFrame:
    """Fetch daily adjusted OHLCV from Tiingo and return a dated DataFrame."""
    resp = requests.get(
        f'https://api.tiingo.com/tiingo/daily/{ticker}/prices',
        headers={'Authorization': f'Token {TIINGO_API_KEY}', 'Content-Type': 'application/json'},
        params={'startDate': start_date, 'resampleFreq': 'daily', 'format': 'json'},
        timeout=60,
    )
    resp.raise_for_status()
    raw = pd.DataFrame(resp.json())
    if raw.empty:
        raise ValueError(f'Tiingo returned no data for {ticker}')

    close_col  = next((c for c in ['adjClose', 'close', 'adjclose'] if c in raw.columns), None)
    volume_col = next((c for c in ['adjVolume', 'volume', 'adjvolume'] if c in raw.columns), None)
    if close_col is None:
        raise ValueError(f'No close column found. Columns: {list(raw.columns)}')

    df = pd.DataFrame()
    # Date: slice to YYYY-MM-DD to avoid any tz suffix issues
    df['date']   = pd.to_datetime(raw['date'].str[:10])
    df['close']  = pd.to_numeric(raw[close_col].values,  errors='coerce')
    df['volume'] = pd.to_numeric(raw[volume_col].values, errors='coerce') if volume_col else np.nan
    df = df.set_index('date').sort_index().dropna(subset=['close'])
    df['pct_ret'] = df['close'].pct_change()
    df['logret']  = np.log(df['close'] / df['close'].shift(1))
    return df


def daily_to_monthly(df: pd.DataFrame) -> pd.DataFrame:
    monthly = df['close'].resample('ME').last().dropna()
    monthly_ret = monthly.pct_change().dropna()
    return pd.DataFrame({'close': monthly, 'pct_ret': monthly_ret})


def sharpe_ann(monthly_returns: pd.Series) -> float:
    s = monthly_returns.std(ddof=1)
    return float((monthly_returns.mean() / s) * np.sqrt(12)) if s > 0 else float('nan')


def sortino_ann(monthly_returns: pd.Series) -> float:
    ds = monthly_returns[monthly_returns < 0].std(ddof=1)
    return float((monthly_returns.mean() / ds) * np.sqrt(12)) if ds > 0 else float('nan')


def rolling_ann_return(monthly_ret: pd.Series, window: int) -> pd.Series:
    cum = (1.0 + monthly_ret).rolling(window).apply(np.prod, raw=True)
    return cum.pow(12.0 / window).sub(1.0)


REGIME_BANDS = [
    {'label': 'Low',      'floor':  0, 'ceil':  12, 'color': '#22c55e'},
    {'label': 'Normal',   'floor': 12, 'ceil':  18, 'color': '#3b82f6'},
    {'label': 'Elevated', 'floor': 18, 'ceil':  25, 'color': '#f59e0b'},
    {'label': 'High',     'floor': 25, 'ceil':  35, 'color': '#ef4444'},
    {'label': 'Crisis',   'floor': 35, 'ceil': 999, 'color': '#7f1d1d'},
]

def classify_regime(vol: float) -> dict:
    for b in REGIME_BANDS:
        if b['floor'] <= vol < b['ceil']:
            return b
    return REGIME_BANDS[-1]


def fmt_pct(v, d=2, frac=False):
    if v is None or (isinstance(v, float) and not np.isfinite(v)): return 'N/A'
    return f'{v * 100 if frac else v:.{d}f}%'

def fmt_ratio(v):
    if v is None or (isinstance(v, float) and not np.isfinite(v)): return 'N/A'
    return f'{v:.2f}'

def fmt_dollar(v): return f'${v:,.0f}'
def fmt_f2(v):     return '-' if pd.isna(v) else f'{v:.2f}'
def fmt_f3(v):     return '-' if pd.isna(v) else f'{v:.3f}'
def fmt_pct2(v):   return '-' if pd.isna(v) else f'{v:.2%}'


def render_cards(cards: list) -> str:
    items = ''.join(
        f'<div style="background:#f8fafc;border:1px solid #e2e8f0;border-radius:8px;'
        f'padding:18px 16px;text-align:center;min-width:140px;flex:1 1 140px">'
        f'<div style="font-size:20px;font-weight:600;color:#0f172a;'
        f'letter-spacing:-0.02em;margin-bottom:6px">{c["value"]}</div>'
        f'<div style="font-size:11.5px;color:#64748b;font-weight:500">{c["label"]}</div>'
        f'</div>'
        for c in cards
    )
    return f'<div style="display:flex;flex-wrap:wrap;gap:12px;margin:16px 0">{items}</div>'

Data fetch

Daily adjusted prices for SPY are fetched from Tiingo and resampled to month ending closes. Both daily and monthly return series are computed here and reused across all sections.

In [3]:
print(f'Fetching {TICKER} from Tiingo ({START_DATE} --> today)...')
df_daily = fetch_tiingo(TICKER, START_DATE)
df_monthly = daily_to_monthly(df_daily)

daily_ret   = df_daily['pct_ret'].dropna()
monthly_ret = df_monthly['pct_ret'].dropna()

start_date_str = df_daily.index[0].strftime('%Y-%m-%d')
end_date_str   = df_daily.index[-1].strftime('%Y-%m-%d')
n_years = (df_daily.index[-1] - df_daily.index[0]).days / 365.25

print(f'Daily rows   : {len(df_daily):,}  ({start_date_str} --> {end_date_str})')
print(f'Monthly rows : {len(df_monthly):,}')
print(f'Years of data: {n_years:.1f}')
Fetching SPY from Tiingo (1993-01-01 --> today)...
Daily rows   : 8,386  (1993-01-29 --> 2026-05-22)
Monthly rows : 401
Years of data: 33.3

1. Current snapshot

Daily and monthly risk-return metrics computed across the full history. VaR 95% is the 5th percentile of the return distribution - on a typical bad day, losses stay within this range. The rolling Sharpe and Sortino use the most recent 36 months and measure recent risk-adjusted performance.

In [4]:
ann_ret_d  = float((1 + daily_ret.mean()) ** 252 - 1)
ann_vol_d  = float(daily_ret.std(ddof=1) * np.sqrt(252))
var95_d    = float(np.nanpercentile(daily_ret, 5))

ann_ret_m  = float((1 + monthly_ret.mean()) ** 12 - 1)
ann_vol_m  = float(monthly_ret.std(ddof=1) * np.sqrt(12))
var95_m    = float(np.nanpercentile(monthly_ret, 5))

sharpe_36  = sharpe_ann(monthly_ret.tail(36)) if len(monthly_ret) >= 36 else float('nan')
sortino_36 = sortino_ann(monthly_ret.tail(36)) if len(monthly_ret) >= 36 else float('nan')

display(HTML(render_cards([
    {'label': 'Ann. Return (daily)',     'value': fmt_pct(ann_ret_d,  2, frac=True)},
    {'label': 'Ann. Volatility (daily)', 'value': fmt_pct(ann_vol_d,  2, frac=True)},
    {'label': 'VaR 95% (1d)',            'value': fmt_pct(var95_d,    2, frac=True)},
    {'label': 'Ann. Return (monthly)',   'value': fmt_pct(ann_ret_m,  2, frac=True)},
    {'label': 'Ann. Volatility (monthly)','value': fmt_pct(ann_vol_m, 2, frac=True)},
    {'label': 'VaR 95% (1m)',            'value': fmt_pct(var95_m,    2, frac=True)},
    {'label': 'Sharpe (36m)',            'value': fmt_ratio(sharpe_36)},
    {'label': 'Sortino (36m)',           'value': fmt_ratio(sortino_36)},
])))
12.79%
Ann. Return (daily)
18.58%
Ann. Volatility (daily)
-1.80%
VaR 95% (1d)
12.05%
Ann. Return (monthly)
14.78%
Ann. Volatility (monthly)
-6.95%
VaR 95% (1m)
1.64
Sharpe (36m)
3.45
Sortino (36m)
In [5]:
rolling_sharpe_36 = monthly_ret.rolling(36).apply(
    lambda x: sharpe_ann(pd.Series(x)), raw=False
).dropna()

plt.figure(figsize=(8, 4.5))
plt.hist(
    daily_ret.dropna() * 100,
    bins=80,
    color=FL_BLUE,
    alpha=0.75,
    edgecolor='none'
)
plt.axvline(
    var95_d * 100,
    color=FL_RED,
    linewidth=1.2,
    linestyle='--',
    label=f'VaR 95% ({fmt_pct(var95_d, 2, frac=True)})'
)
plt.axvline(0, color=FL_GRID, linewidth=0.8)
plt.xlabel('Daily return (%)')
plt.ylabel('Frequency')
plt.title('Daily return distribution')
plt.legend()
plt.tick_params(axis='both', which='both', length=0)
plt.tight_layout()
plt.show()

plt.figure(figsize=(8, 4.5))
plt.fill_between(
    rolling_sharpe_36.index,
    rolling_sharpe_36.values,
    0,
    where=(rolling_sharpe_36.values >= 0),
    alpha=0.15,
    color=FL_BLUE,
    interpolate=True
)
plt.fill_between(
    rolling_sharpe_36.index,
    rolling_sharpe_36.values,
    0,
    where=(rolling_sharpe_36.values < 0),
    alpha=0.20,
    color=FL_RED,
    interpolate=True
)
plt.plot(
    rolling_sharpe_36.index,
    rolling_sharpe_36.values,
    color=FL_BLUE,
    linewidth=1.6
)
plt.axhline(0, color=FL_GRID, linewidth=0.8)
plt.ylabel('Sharpe ratio')
plt.title('Rolling 36-month Sharpe ratio (annualized)')
plt.gca().xaxis.set_major_locator(mdates.YearLocator(4))
plt.gca().xaxis.set_major_formatter(mdates.DateFormatter('%Y'))
plt.tick_params(axis='both', which='both', length=0)
plt.tight_layout()
plt.show()

risk_return_df = pd.DataFrame([
    {
        'Metric': 'Sample period',
        'Value': f'{start_date_str[:4]} to {end_date_str[:4]}'
    },
    {
        'Metric': 'Years of data',
        'Value': f'{n_years:.0f}'
    },
    {
        'Metric': 'Annualized return',
        'Value': fmt_pct(ann_ret_d, 1, frac=True)
    },
    {
        'Metric': 'Annualized volatility',
        'Value': fmt_pct(ann_vol_d, 1, frac=True)
    },
    {
        'Metric': 'Rolling 36m Sharpe',
        'Value': fmt_ratio(sharpe_36)
    }
])

display(risk_return_df)
No description has been provided for this image
No description has been provided for this image
Metric Value
0 Sample period 1993 to 2026
1 Years of data 33
2 Annualized return 12.8%
3 Annualized volatility 18.6%
4 Rolling 36m Sharpe 1.64

2. Risk horizon

How risk and return characteristics change across investment horizons. The loss probability chart is the core argument for long-term equity allocation as the probability of a negative total return falls sharply as the holding period lengthens. At 20 years, negative outcomes become rare in the historical record.

In [6]:
annual_ret = monthly_ret.add(1.0).groupby(monthly_ret.index.year).prod().sub(1.0)

mean_ann  = float(annual_ret.mean())
std_ann   = float(annual_ret.std(ddof=1))
p_neg     = float((annual_ret < 0).mean())
best_yr   = int(annual_ret.idxmax())
worst_yr  = int(annual_ret.idxmin())

display(HTML(render_cards([
    {'label': 'Mean Annual Return',  'value': fmt_pct(mean_ann,  1, frac=True)},
    {'label': 'Ann. Return Std Dev', 'value': fmt_pct(std_ann,   1, frac=True)},
    {'label': '% Negative Years',   'value': fmt_pct(p_neg,     0, frac=True)},
    {'label': 'Best Year',          'value': f'{best_yr}  ({fmt_pct(float(annual_ret.loc[best_yr]), 1, frac=True)})'},
    {'label': 'Worst Year',         'value': f'{worst_yr}  ({fmt_pct(float(annual_ret.loc[worst_yr]), 1, frac=True)})'},
])))
12.1%
Mean Annual Return
17.3%
Ann. Return Std Dev
18%
% Negative Years
1995 (38.0%)
Best Year
2008 (-36.8%)
Worst Year
In [7]:
plt.figure(figsize=(8, 4.5))
plt.bar(
    annual_ret.index,
    annual_ret.values * 100,
    color=[FL_GREEN if v >= 0 else FL_RED for v in annual_ret.values],
    alpha=0.80,
    width=0.7
)
plt.axhline(0, color=FL_GRID, linewidth=0.8)
plt.ylabel('Annual return (%)')
plt.title('Annual returns')
plt.gca().yaxis.set_major_formatter(
    mticker.FuncFormatter(lambda v, _: f'{v:.0f}%')
)
plt.tick_params(axis='both', which='both', length=0)
plt.tight_layout()
plt.show()

annual_ret_df = pd.DataFrame({
    'Year': annual_ret.index,
    'Annual return (%)': (annual_ret.values * 100).round(2)
})

display(annual_ret_df.tail())
No description has been provided for this image
Year Annual return (%)
29 2022 -18.17
30 2023 26.19
31 2024 24.89
32 2025 17.72
33 2026 9.65
In [8]:
horizons = [1, 3, 5, 10, 15, 20]
loss_probs = []

for h in horizons:
    ann = rolling_ann_return(monthly_ret, h * 12).dropna()
    loss_probs.append(float((ann < 0).mean()) * 100 if not ann.empty else 0.0)

plt.figure(figsize=(8, 4.5))
bars = plt.bar(
    [f'{h}y' for h in horizons],
    loss_probs,
    color=FL_BLUE,
    alpha=0.80,
    width=0.5
)

for bar, pct in zip(bars, loss_probs):
    plt.text(
        bar.get_x() + bar.get_width() / 2,
        bar.get_height() + 0.3,
        f'{pct:.1f}%',
        ha='center',
        va='bottom',
        fontsize=10,
        color=FL_TEXT2
    )

plt.xlabel('Holding period')
plt.ylabel('Probability of negative return')
plt.title('Probability of negative total return by holding period')
plt.gca().yaxis.set_major_formatter(
    mticker.FuncFormatter(lambda v, _: f'{v:.0f}%')
)
plt.tick_params(axis='both', which='both', length=0)
plt.tight_layout()
plt.show()

loss_prob_df = pd.DataFrame({
    'Holding period': [f'{h}y' for h in horizons],
    'Probability of negative return (%)': [round(v, 2) for v in loss_probs]
})

display(loss_prob_df)
No description has been provided for this image
Holding period Probability of negative return (%)
0 1y 17.99
1 3y 16.44
2 5y 14.37
3 10y 8.54
4 15y 0.00
5 20y 0.00
In [9]:
plt.figure(figsize=(8, 4.5))
palette = [FL_BLUE, FL_SLATE, FL_AMBER, FL_GREEN]

for (w, label), color in zip([(12, '1y'), (60, '5y'), (120, '10y'), (240, '20y')], palette):
    s = rolling_ann_return(monthly_ret, w).dropna()
    if not s.empty:
        plt.plot(s.index, s.values * 100, color=color, linewidth=1.4, label=label)

plt.axhline(0, color=FL_GRID, linewidth=0.8)
plt.ylabel('Annualized return (%)')
plt.title('Rolling annualized returns by horizon')
plt.gca().yaxis.set_major_formatter(
    mticker.FuncFormatter(lambda v, _: f'{v:.0f}%')
)
plt.gca().xaxis.set_major_locator(mdates.YearLocator(4))
plt.gca().xaxis.set_major_formatter(mdates.DateFormatter('%Y'))
plt.legend(title='Window')
plt.tick_params(axis='both', which='both', length=0)
plt.tight_layout()
plt.show()

wealth = (1.0 + monthly_ret.fillna(0)).cumprod()

plt.figure(figsize=(8, 4.5))
plt.fill_between(wealth.index, wealth.values, 1, alpha=0.10, color=FL_BLUE)
plt.plot(wealth.index, wealth.values, color=FL_BLUE, linewidth=1.8)
plt.axhline(1, color=FL_GRID, linewidth=0.8)
plt.ylabel('Wealth multiple')
plt.title('Wealth index (nominal, base = 1)')
plt.gca().yaxis.set_major_formatter(
    mticker.FuncFormatter(lambda v, _: f'{v:.0f}x')
)
plt.gca().xaxis.set_major_locator(mdates.YearLocator(4))
plt.gca().xaxis.set_major_formatter(mdates.DateFormatter('%Y'))
plt.tick_params(axis='both', which='both', length=0)
plt.tight_layout()
plt.show()

rolling_return_summary_df = pd.DataFrame([
    {
        'Metric': 'Mean annual return',
        'Value': fmt_pct(mean_ann, 1, frac=True)
    },
    {
        'Metric': 'Annual return std',
        'Value': fmt_pct(std_ann, 1, frac=True)
    },
    {
        'Metric': 'Negative years',
        'Value': fmt_pct(p_neg, 0, frac=True)
    },
    {
        'Metric': 'Loss probability at 20 years',
        'Value': f'{loss_probs[-1]:.1f}%'
    }
])

display(rolling_return_summary_df)
No description has been provided for this image
No description has been provided for this image
Metric Value
0 Mean annual return 12.1%
1 Annual return std 17.3%
2 Negative years 18%
3 Loss probability at 20 years 0.0%

3. Long-term volatility

Rolling realized volatility and calibrated regime classification. Regimes use fixed annualized vol thresholds - Low (<12%), Normal (12–18%), Elevated (18–25%), High (25–35%), Crisis (35%+). Regime shifts are asymmetric: vol spikes quickly during panic and decays slowly during recovery.

In [10]:
log_ret = df_daily['logret'].dropna()

roll_vol_252 = log_ret.rolling(252).std(ddof=1) * np.sqrt(252) * 100
roll_vol_21 = log_ret.rolling(21).std(ddof=1) * np.sqrt(252) * 100
roll_vol_36m = monthly_ret.rolling(36).std(ddof=1) * np.sqrt(12) * 100

vol_21_clean = roll_vol_21.dropna()
current_vol = float(vol_21_clean.iloc[-1])
mean_vol = float(vol_21_clean.mean())
current_regime = classify_regime(current_vol)

regime_labels = vol_21_clean.apply(lambda v: classify_regime(v)['label'])
total_days = len(regime_labels)

regime_rows = []
for b in REGIME_BANDS:
    count = int((regime_labels == b['label']).sum())
    pct = round(count / total_days * 100, 1)
    vol_range = f"{b['floor']} to {b['ceil']}%" if b['ceil'] < 999 else f"{b['floor']}%+"
    regime_rows.append({
        'Regime': b['label'],
        'Vol Range': vol_range,
        'Trading Days': count,
        '% of History': f'{pct}%'
    })

display(HTML(render_cards([
    {'label': 'Current 21d Vol', 'value': fmt_pct(current_vol, 1)},
    {'label': 'Current Regime', 'value': current_regime['label']},
    {'label': 'Long-run Mean Vol', 'value': fmt_pct(mean_vol, 1)},
])))

regime_df = pd.DataFrame(regime_rows)
display(regime_df)
10.2%
Current 21d Vol
Low
Current Regime
15.9%
Long-run Mean Vol
Regime Vol Range Trading Days % of History
0 Low 0 to 12% 3535 42.3%
1 Normal 12 to 18% 2404 28.7%
2 Elevated 18 to 25% 1478 17.7%
3 High 25 to 35% 640 7.7%
4 Crisis 35%+ 308 3.7%
In [11]:
v252 = roll_vol_252.dropna()
v36m = roll_vol_36m.dropna()

plt.figure(figsize=(8, 4.5))
plt.plot(v252.index, v252.values, color=FL_BLUE, linewidth=1.6, label='Daily (252d)')
plt.plot(v36m.index, v36m.values, color=FL_SLATE, linewidth=1.2, linestyle='--', label='Monthly (36m)')

for b in REGIME_BANDS:
    ceil = min(b['ceil'], 80)
    plt.axhspan(b['floor'], ceil, alpha=0.04, color=b['color'], zorder=0)

plt.ylabel('Annualized volatility (%)')
plt.title('Rolling annualized volatility')
plt.gca().yaxis.set_major_formatter(
    mticker.FuncFormatter(lambda v, _: f'{v:.0f}%')
)
plt.gca().xaxis.set_major_locator(mdates.YearLocator(4))
plt.gca().xaxis.set_major_formatter(mdates.DateFormatter('%Y'))

legend_patches = [Patch(color=b['color'], alpha=0.4, label=b['label']) for b in REGIME_BANDS]
plt.legend(
    handles=[
        plt.Line2D([0], [0], color=FL_BLUE, lw=1.6, label='Daily (252d)'),
        plt.Line2D([0], [0], color=FL_SLATE, lw=1.2, ls='--', label='Monthly (36m)'),
        *legend_patches
    ],
    ncol=2,
    fontsize=9
)

plt.tick_params(axis='both', which='both', length=0)
plt.tight_layout()
plt.show()
No description has been provided for this image
In [12]:
labels = [r['Regime'] for r in regime_rows]
pcts = [float(r['% of History'].rstrip('%')) for r in regime_rows]
colors = [b['color'] for b in REGIME_BANDS]

plt.figure(figsize=(8, 4.5))
bars = plt.bar(labels, pcts, color=colors, alpha=0.85, width=0.5)

for bar, pct in zip(bars, pcts):
    plt.text(
        bar.get_x() + bar.get_width() / 2,
        bar.get_height() + 0.3,
        f'{pct:.1f}%',
        ha='center',
        va='bottom',
        fontsize=10,
        color=FL_TEXT2
    )

plt.ylabel('% of trading days')
plt.title('Historical time spent in each volatility regime')
plt.gca().yaxis.set_major_formatter(
    mticker.FuncFormatter(lambda v, _: f'{v:.0f}%')
)
plt.tick_params(axis='both', which='both', length=0)
plt.tight_layout()
plt.show()

normal_pct = next(r['% of History'] for r in regime_rows if r['Regime'] == 'Normal')
crisis_pct = next(r['% of History'] for r in regime_rows if r['Regime'] == 'Crisis')

regime_summary_df = pd.DataFrame([
    {
        'Metric': 'Long-run mean volatility',
        'Value': fmt_pct(mean_vol, 1)
    },
    {
        'Metric': 'Current 21d volatility',
        'Value': fmt_pct(current_vol, 1)
    },
    {
        'Metric': 'Current regime',
        'Value': current_regime['label']
    },
    {
        'Metric': 'Normal regime share',
        'Value': normal_pct
    },
    {
        'Metric': 'Crisis regime share',
        'Value': crisis_pct
    }
])

display(regime_summary_df)
No description has been provided for this image
Metric Value
0 Long-run mean volatility 15.9%
1 Current 21d volatility 10.2%
2 Current regime Low
3 Normal regime share 28.7%
4 Crisis regime share 3.7%

4. Risk-adjusted returns

Rolling Sharpe ratio across multiple windows - 36m, 60m, 120m, 180m. Shorter windows capture recent performance shifts; longer windows smooth through full market cycles. The Sortino ratio penalizes only downside volatility, providing a more nuanced view when the return distribution is skewed.

In [13]:
sharpe_all  = sharpe_ann(monthly_ret)
sortino_all = sortino_ann(monthly_ret)

windows = [36, 60, 120, 180]
roll_sharpes = {}
latest_sharpes = []
for w in windows:
    rs = monthly_ret.rolling(w).apply(lambda x: sharpe_ann(pd.Series(x)), raw=False).dropna()
    roll_sharpes[w] = rs
    latest_sharpes.append({'label': f'Sharpe ({w}m)', 'value': fmt_ratio(float(rs.iloc[-1]))})

display(HTML(render_cards([
    {'label': 'Ann. Return',       'value': fmt_pct(ann_ret_m,  2, frac=True)},
    {'label': 'Ann. Volatility',   'value': fmt_pct(ann_vol_m,  2, frac=True)},
    {'label': 'Sharpe (all-time)', 'value': fmt_ratio(sharpe_all)},
    {'label': 'Sortino (all-time)','value': fmt_ratio(sortino_all)},
    *latest_sharpes,
])))
12.05%
Ann. Return
14.78%
Ann. Volatility
0.77
Sharpe (all-time)
1.08
Sortino (all-time)
1.64
Sharpe (36m)
0.90
Sharpe (60m)
1.02
Sharpe (120m)
1.00
Sharpe (180m)
In [14]:
palette = [FL_BLUE, FL_SLATE, FL_AMBER, FL_GREEN]

plt.figure(figsize=(8, 4.5))
for (w, rs), color in zip(roll_sharpes.items(), palette):
    plt.plot(
        rs.index,
        rs.values,
        color=color,
        linewidth=1.4,
        label=f'{w}m'
    )

plt.axhline(0, color=FL_GRID, linewidth=0.8)
plt.axhline(
    sharpe_all,
    color=FL_SLATE,
    linewidth=0.8,
    linestyle=':',
    label=f'All-time ({fmt_ratio(sharpe_all)})'
)
plt.ylabel('Sharpe ratio')
plt.title('Rolling Sharpe ratio (annualized) by window')
plt.gca().xaxis.set_major_locator(mdates.YearLocator(4))
plt.gca().xaxis.set_major_formatter(mdates.DateFormatter('%Y'))
plt.legend(title='Window', ncol=3, fontsize=9)
plt.tick_params(axis='both', which='both', length=0)
plt.tight_layout()
plt.show()

sharpe_summary_df = pd.DataFrame([
    {
        'Metric': 'All-time Sharpe',
        'Value': fmt_ratio(sharpe_all)
    },
    {
        'Metric': 'All-time Sortino',
        'Value': fmt_ratio(sortino_all)
    },
    {
        'Metric': 'Annualized return',
        'Value': fmt_pct(ann_ret_m, 1, frac=True)
    },
    {
        'Metric': 'Annualized volatility',
        'Value': fmt_pct(ann_vol_m, 1, frac=True)
    }
])

display(sharpe_summary_df)
No description has been provided for this image
Metric Value
0 All-time Sharpe 0.77
1 All-time Sortino 1.08
2 Annualized return 12.1%
3 Annualized volatility 14.8%

5. Bootstrap simulation - 5-year forward paths

1,000 forward paths are generated by resampling historical monthly returns with replacement. Each path runs 60 months (5 years) from the most recent close. The shaded band shows the 5th–95th percentile range; the solid line is the median path.

Assumption: returns are i.i.d. - the simulation does not capture volatility clustering, regime persistence, or structural market changes. It provides a distributional summary of outcomes implied by the historical return sample, not a forecast.

In [15]:
N_SIMS           = 1_000
HORIZON_MONTHS   = 60
SEED             = 42
last_price       = float(df_monthly['close'].iloc[-1])

rng      = np.random.default_rng(SEED)
sim_rets = rng.choice(monthly_ret.values, size=(HORIZON_MONTHS, N_SIMS), replace=True)
sim_paths = last_price * np.cumprod(1.0 + sim_rets, axis=0)

pct5  = np.percentile(sim_paths, 5,  axis=1)
pct50 = np.percentile(sim_paths, 50, axis=1)
pct95 = np.percentile(sim_paths, 95, axis=1)

future_idx = pd.date_range(
    start=df_monthly.index[-1] + pd.offsets.MonthEnd(1),
    periods=HORIZON_MONTHS, freq='ME'
)

# Terminal return distribution stats
final_rets = sim_paths[-1] / last_price - 1.0
p_loss     = float((final_rets < 0).mean())
cagr_med   = float((pct50[-1] / last_price) ** (1 / 5) - 1)

display(HTML(render_cards([
    {'label': 'Starting Price (SPY)', 'value': f'${last_price:,.2f}'},
    {'label': 'Median End Price',     'value': f'${pct50[-1]:,.0f}'},
    {'label': 'Median CAGR',          'value': fmt_pct(cagr_med, 1, frac=True)},
    {'label': '5th pct Return',       'value': fmt_pct(float(np.percentile(final_rets,  5)), 1, frac=True)},
    {'label': '25th pct Return',      'value': fmt_pct(float(np.percentile(final_rets, 25)), 1, frac=True)},
    {'label': '75th pct Return',      'value': fmt_pct(float(np.percentile(final_rets, 75)), 1, frac=True)},
    {'label': '95th pct Return',      'value': fmt_pct(float(np.percentile(final_rets, 95)), 1, frac=True)},
    {'label': 'P(Loss over 5y)',      'value': fmt_pct(p_loss, 1, frac=True)},
])))
$745.64
Starting Price (SPY)
$1,248
Median End Price
10.8%
Median CAGR
-7.2%
5th pct Return
34.6%
25th pct Return
108.7%
75th pct Return
175.3%
95th pct Return
6.8%
P(Loss over 5y)
In [16]:
plt.figure(figsize=(8, 4.5))

for (w, rs), color in zip(roll_sharpes.items(), palette):
    plt.plot(
        rs.index,
        rs.values,
        color=color,
        linewidth=1.4,
        label=f'{w}m'
    )

plt.axhline(0, color=FL_GRID, linewidth=0.8)
plt.axhline(
    sharpe_all,
    color=FL_SLATE,
    linewidth=0.8,
    linestyle=':',
    label=f'All-time ({fmt_ratio(sharpe_all)})'
)

plt.ylabel('Sharpe ratio')
plt.title('Rolling Sharpe ratio (annualized) by window')
plt.gca().xaxis.set_major_locator(mdates.YearLocator(4))
plt.gca().xaxis.set_major_formatter(mdates.DateFormatter('%Y'))
plt.legend(title='Window', ncol=3, fontsize=9)
plt.tick_params(axis='both', which='both', length=0)
plt.tight_layout()
plt.show()

rolling_sharpe_summary_df = pd.DataFrame([
    {
        'Metric': 'All-time Sharpe',
        'Value': fmt_ratio(sharpe_all)
    },
    {
        'Metric': 'All-time Sortino',
        'Value': fmt_ratio(sortino_all)
    },
    {
        'Metric': 'Annualized return',
        'Value': fmt_pct(ann_ret_m, 1, frac=True)
    },
    {
        'Metric': 'Annualized volatility',
        'Value': fmt_pct(ann_vol_m, 1, frac=True)
    }
])

display(rolling_sharpe_summary_df)
No description has been provided for this image
Metric Value
0 All-time Sharpe 0.77
1 All-time Sortino 1.08
2 Annualized return 12.1%
3 Annualized volatility 14.8%