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:
- Current snapshot: daily and monthly risk-return metrics, rolling Sharpe and Sortino
- Risk horizon: annual returns, loss probability by horizon, rolling returns, wealth index
- Long-term volatility: rolling vol windows, calibrated regime bands, time in each regime
- Risk-adjusted returns: rolling Sharpe across multiple windows
- 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.
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.
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.
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.
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)},
])))
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)
| 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.
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)})'},
])))
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())
| Year | Annual return (%) | |
|---|---|---|
| 29 | 2022 | -18.17 |
| 30 | 2023 | 26.19 |
| 31 | 2024 | 24.89 |
| 32 | 2025 | 17.72 |
| 33 | 2026 | 9.65 |
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)
| 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 |
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)
| 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.
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)
| 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% |
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()
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)
| 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.
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,
])))
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)
| 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.
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)},
])))
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)
| Metric | Value | |
|---|---|---|
| 0 | All-time Sharpe | 0.77 |
| 1 | All-time Sortino | 1.08 |
| 2 | Annualized return | 12.1% |
| 3 | Annualized volatility | 14.8% |