VaR by Filtered Historical Simulation#

Filtered historical simulation (FHS) rescales historical shocks using forecast volatility. We estimate one-day portfolio VaR and test the forecasts on subsequent returns.

Step

Calculation

Fit

Estimate each asset’s mean and GARCH volatility

Filter

Subtract the fitted mean and divide by conditional volatility

Resample

Draw each date’s standardized shocks jointly across assets

Rescale

Apply forecast volatility, add the mean, and weight the returns

Joint resampling preserves the empirical cross-asset dependence of the shocks. It assumes that this shock distribution remains useful for the next return.

import pandas as pd
import numpy as np
import matplotlib.pyplot as plt
from matplotlib.ticker import PercentFormatter
from arch import arch_model
from scipy.stats import norm
%matplotlib inline
plt.rcParams['figure.figsize'] = (10, 5)
plt.rcParams['font.size'] = 13
plt.rcParams['legend.fontsize'] = 11
rng = np.random.default_rng(2025)

def tail_mean(values, q):
    """Mean of the lowest q probability mass of an empirical distribution."""
    x = np.sort(np.asarray(values, dtype=float))
    mass = q * len(x)
    whole = int(np.floor(mass))
    fraction = mass - whole
    total = x[:whole].sum()
    if fraction > 0:
        total += fraction * x[whole]
    return total / mass

def fit_garch(series):
    # Normalize the estimation sample for the optimizer; return its unit conversion.
    scale = 1/series.std()
    result = arch_model(series*scale, mean='Constant', vol='GARCH', p=1, q=1,
                        rescale=False).fit(
        disp='off', show_warning=False, options={'maxiter': 1000})
    if result.convergence_flag != 0:
        raise RuntimeError(f'GARCH did not converge: {series.name}, {series.index[-1]}')
    return result, scale
LOADFILE = '../data/risk_etf_data.xlsx'
rets = pd.read_excel(LOADFILE, sheet_name='total returns').set_index('Date')
assets = rets.columns.drop('SHV')
R = rets[assets].dropna()
w = np.full(len(assets), 1/len(assets))

FREQ = 252
QUANTILE = .05
ESTIMATION_WINDOW = 504
REFIT_EVERY = 21
M = 50_000
AS_OF = R.index.max()

setup = pd.DataFrame({
    'Sample start': [R.index.min().strftime('%Y-%m-%d')],
    'Sample end': [AS_OF.strftime('%Y-%m-%d')],
    'Estimation days': [ESTIMATION_WINDOW],
    'Refit interval, days': [REFIT_EVERY],
})
display(setup.style.set_table_attributes('class="dataframe"').hide(axis='index').format(
    {'Estimation days': '{:,.0f}', 'Refit interval, days': '{:,.0f}'},
    na_rep='').set_caption('ETF backtest setup; intervals are trading days'))
ETF backtest setup; intervals are trading days
Sample start Sample end Estimation days Refit interval, days
2017-01-04 2026-06-30 504 21

The Portfolio#

An equal-weighted portfolio of ETFs and Bitcoin, using the common return sample. Weights are held constant each day. Risk estimates are reported as positive losses.

labels = {'SPY':'US equity', 'VEA':'Developed intl equity', 'UPRO':'US equity, 3x',
          'GLD':'Gold', 'USO':'Crude oil', 'FXE':'Euro FX', 'BTC':'Bitcoin',
          'HYG':'High-yield credit', 'IEF':'7-10y Treasury', 'TIP':'TIPS'}
holdings = pd.DataFrame({'Asset class': [labels[a] for a in assets],
                         'Weight': w,
                         'Annualized volatility': R.std().values*np.sqrt(FREQ)}, index=assets)
display(holdings.style.set_table_attributes('class="dataframe"').format({'Weight':'{:.0%}','Annualized volatility':'{:.0%}'}, na_rep='')
        .set_caption('Holdings'))

fig, ax = plt.subplots(figsize=(9,4))
(R.std()*np.sqrt(FREQ)).sort_values().plot.barh(ax=ax, color='gray')
ax.xaxis.set_major_formatter(PercentFormatter(1, decimals=0))
ax.set_xlabel('annualized volatility')
ax.set_title('Annualized Asset Volatility', loc='left')
plt.show()
Holdings
  Asset class Weight Annualized volatility
SPY US equity 10% 18%
VEA Developed intl equity 10% 17%
UPRO US equity, 3x 10% 55%
GLD Gold 10% 16%
USO Crude oil 10% 39%
FXE Euro FX 10% 7%
BTC Bitcoin 10% 68%
HYG High-yield credit 10% 8%
IEF 7-10y Treasury 10% 7%
TIP TIPS 10% 6%
../_images/a7718bb76577034d25a9851e2eb5ee0a3f3811168f342d76f7b61be49f377d70.png

Volatility Forecasts#

Fit GARCH(1,1) to each asset over the estimation window. For a fitted mean \(\hat\mu_i\) and conditional volatility \(\hat\sigma_{i,s}\), the standardized shock is

\[z_{i,s}=\frac{r_{i,s}-\hat\mu_i}{\hat\sigma_{i,s}}.\]

The next-day forecast \(\hat\sigma_{i,t+1\mid t}\) sets the scale for simulated returns. Standardization does not guarantee iid shocks.

train = R.iloc[-ESTIMATION_WINDOW:]
Z = pd.DataFrame(index=train.index, columns=assets, dtype=float)
mu_fwd = pd.Series(index=assets, dtype=float)
sig_fwd = pd.Series(index=assets, dtype=float)
for a in assets:
    res, scale = fit_garch(train[a])
    Z[a] = res.std_resid
    fc = res.forecast(horizon=1, reindex=False)
    mu_fwd[a] = fc.mean.iloc[-1, 0]/scale
    sig_fwd[a] = np.sqrt(fc.variance.iloc[-1, 0])/scale

latest_vol = (sig_fwd*np.sqrt(FREQ)).sort_values(ascending=False).to_frame(
    'Annualized volatility forecast')
latest_vol.style.set_table_attributes('class="dataframe"').format({'Annualized volatility forecast': '{:.1%}'}, na_rep='').set_caption(
    f'Next-day volatility; information through {AS_OF:%Y-%m-%d}')
Next-day volatility; information through 2026-06-30
  Annualized volatility forecast
UPRO 47.5%
USO 41.1%
BTC 40.0%
GLD 25.5%
VEA 17.8%
SPY 16.0%
FXE 6.0%
IEF 5.3%
TIP 4.3%
HYG 3.3%

Simulated Returns#

One draw uses the standardized shocks from a single historical date \(s\):

\[r^{(s)}_{i,t+1}=\hat\mu_i+\hat\sigma_{i,t+1\mid t}z_{i,s}, \qquad r^{(s)}_{p,t+1}=\sum_i w_i r^{(s)}_{i,t+1}.\]

Draw dates jointly across assets, then calculate the portfolio quantile and tail mean. The simulated distribution retains the empirical shocks’ tail shape. It cannot generate standardized shocks beyond those in the sample.

draw = rng.integers(0, len(Z), size=M)
asset_sim = mu_fwd.values + Z.values[draw]*sig_fwd.values
port_sim = asset_sim @ w
VaR_fhs = -np.quantile(port_sim, QUANTILE, method='inverted_cdf')
CVaR_fhs = -tail_mean(port_sim, QUANTILE)
latest = pd.DataFrame({'Draws': [M], 'VaR (5%)': [VaR_fhs], 'CVaR (5%)': [CVaR_fhs]})
display(latest.style.set_table_attributes('class="dataframe"').hide(axis='index').format(
    {'Draws': '{:,.0f}', 'VaR (5%)': '{:.2%}', 'CVaR (5%)': '{:.2%}'},
    na_rep='').set_caption(f'Simulated one-day losses; information through {AS_OF:%Y-%m-%d}'))
Simulated one-day losses; information through 2026-06-30
Draws VaR (5%) CVaR (5%)
50,000 1.21% 1.87%

Latest Estimates#

Compare FHS with a sample quantile and a normal fit using the same estimation window. This is a comparison of current estimates; forecast performance is evaluated below.

rp = R @ w
rp_train = train @ w
latest_comparison = pd.DataFrame({'VaR (5%)': [
    VaR_fhs,
    -np.quantile(rp_train, QUANTILE, method='inverted_cdf'),
    -(rp_train.mean() + norm.ppf(QUANTILE)*rp_train.std()),
]}, index=['Filtered historical', 'Sample quantile (rolling)', 'Normal (rolling)'])
display(latest_comparison.style.set_table_attributes('class="dataframe"').format({'VaR (5%)': '{:.2%}'}, na_rep='').set_caption(
    f'One-day loss VaR; information through {AS_OF:%Y-%m-%d}'))
One-day loss VaR; information through 2026-06-30
  VaR (5%)
Filtered historical 1.21%
Sample quantile (rolling) 1.23%
Normal (rolling) 1.26%

Forecast Backtest#

For each forecast date:

  1. Estimate parameters from the preceding window on a refit date.

  2. Update volatility daily using the most recent observed return.

  3. Rescale the preceding window’s standardized shocks to forecast the next return.

  4. Compare the forecast with the return when it is observed.

The window contains two trading years. GARCH parameters are refitted every 21 trading days; volatility updates between refits. These settings are fixed before comparing results.

The rolling sample-quantile and normal benchmarks use the same window. Their expanding counterparts use all prior observations. All methods share forecast dates and include a fitted mean where applicable.

For each FHS forecast, enumerate every date in the shock window. This computes the empirical scenario quantile directly, without Monte Carlo sampling error.

def backtest_fhs(returns, weights, window, refit_every, q):
    """Next-return forecasts. Every fit and shock pool ends before its target."""
    if not returns.index.is_monotonic_increasing or not returns.index.is_unique:
        raise ValueError('Returns must have unique, increasing dates')
    if returns.isna().any().any() or len(returns) <= window:
        raise ValueError('A complete panel and an initial estimation window are required')
    portfolio = returns.to_numpy() @ weights
    values = returns.to_numpy()
    rows, trace, fits = [], [], []
    zscore = norm.ppf(q)
    for i in range(window, len(returns)):
        if (i-window) % refit_every == 0:
            train = returns.iloc[i-window:i]
            means, omega, alpha, beta, sigma, shocks = [], [], [], [], [], []
            for a in returns:
                fit, scale = fit_garch(train[a])
                p = fit.params
                means.append(p['mu']/scale)
                omega.append(p['omega']/scale**2)
                alpha.append(p['alpha[1]'])
                beta.append(p['beta[1]'])
                sigma.append(np.sqrt(fit.forecast(horizon=1, reindex=False).variance.iloc[-1, 0])/scale)
                shocks.append(fit.std_resid.to_numpy())
                fits.append({'Target date': returns.index[i], 'Asset': a,
                             'Converged': fit.convergence_flag == 0})
            means, omega, alpha, beta, sigma = map(np.asarray, (means, omega, alpha, beta, sigma))
            pool = np.column_stack(shocks)
            fit_end = returns.index[i-1]
        else:
            residual = values[i-1]-means
            pool = np.vstack((pool[1:], residual/sigma))
            sigma = np.sqrt(omega + alpha*residual**2 + beta*sigma**2)
        scenarios = (means + pool*sigma) @ weights
        prior = portfolio[:i]
        recent = portfolio[i-window:i]
        rows.append({
            'Filtered historical': -np.quantile(scenarios, q, method='inverted_cdf'),
            'Sample quantile (rolling)': -np.quantile(recent, q, method='inverted_cdf'),
            'Sample quantile (expanding)': -np.quantile(prior, q, method='inverted_cdf'),
            'Normal (rolling)': -(recent.mean()+zscore*recent.std(ddof=1)),
            'Normal (expanding)': -(prior.mean()+zscore*prior.std(ddof=1)),
        })
        trace.append({'Target date': returns.index[i], 'Fit end': fit_end,
                      'Shock start': returns.index[i-window], 'Shock end': returns.index[i-1],
                      'Shock count': len(pool)})
    forecasts = pd.DataFrame(rows, index=returns.index[window:])
    return forecasts, pd.DataFrame(trace).set_index('Target date'), pd.DataFrame(fits)
forecasts, forecast_trace, fit_log = backtest_fhs(
    R, w, ESTIMATION_WINDOW, REFIT_EVERY, QUANTILE)
assert (forecast_trace['Fit end'] < forecast_trace.index).all()
assert (forecast_trace['Shock end'] < forecast_trace.index).all()
assert np.isfinite(forecasts.to_numpy()).all()
assert fit_log['Converged'].all()

forecast_setup = pd.DataFrame({
    'First forecast': [forecasts.index.min().strftime('%Y-%m-%d')],
    'Last forecast': [forecasts.index.max().strftime('%Y-%m-%d')],
    'Forecast days': [len(forecasts)], 'GARCH fits': [len(fit_log)],
})
display(forecast_setup.style.set_table_attributes('class="dataframe"').hide(axis='index').format(
    {'Forecast days': '{:,.0f}', 'GARCH fits': '{:,.0f}'}, na_rep='').set_caption(
    'Common evaluation sample'))

# The table below compares all methods; the time plot isolates filtering and window choice.
shown = ['Filtered historical', 'Sample quantile (rolling)', 'Sample quantile (expanding)']
ax = forecasts[shown].plot(color=['#14181e', '#1f4e79', '#8a8f98'],
                           style=['-', '--', ':'], lw=1.2)
ax.set_title('One-Day VaR Forecasts', loc='left')
ax.set_ylabel('Loss VaR (5%)')
ax.set_xlabel('Forecast date')
ax.yaxis.set_major_formatter(PercentFormatter(1, decimals=0))
ax.legend(loc='upper center', bbox_to_anchor=(.5, 1.24), ncol=1, frameon=False)
plt.show()
Common evaluation sample
First forecast Last forecast Forecast days GARCH fits
2019-01-07 2026-06-30 1,880 900
../_images/5dfd7e66f67589ae5c4086b814c10b7a3422eeb986240d3f241253f67aa1d586.png

Breach Rates#

A breach occurs when the realized portfolio return is below the negative of the loss VaR forecast. Compare the breach rate with the target probability. Both excessive and unusually infrequent breaches can indicate miscalibration.

These forecasts use only prior returns. The table reports realized coverage; it does not test the independence of breaches.

realized = rp.reindex(forecasts.index)
breaches = forecasts.lt(-realized, axis=0)
full_sample = pd.DataFrame({'Forecast days': len(forecasts),
                            'Breaches': breaches.sum(),
                            'Breach rate': breaches.mean()})
display(full_sample.style.set_table_attributes('class="dataframe"').format({'Forecast days': '{:,.0f}', 'Breaches': '{:,.0f}',
                                  'Breach rate': '{:.1%}'}, na_rep='').set_caption(
    'Out-of-sample one-day VaR breaches; target 5%'))
Out-of-sample one-day VaR breaches; target 5%
  Forecast days Breaches Breach rate
Filtered historical 1,880 96 5.1%
Sample quantile (rolling) 1,880 91 4.8%
Sample quantile (expanding) 1,880 103 5.5%
Normal (rolling) 1,880 78 4.1%
Normal (expanding) 1,880 78 4.1%

Market Episodes#

Compare the same forecast methods over stress and calmer periods. The episode windows are descriptive comparisons selected with knowledge of market events; they were not used to choose the model settings.

periods = {'COVID 2020': slice('2020-02-19', '2020-04-30'),
           'Tariffs 2025': slice('2025-01-01', '2025-06-30'),
           '2023': slice('2023-01-01', '2023-12-31')}
episode_sample = pd.DataFrame({label: {
    'Start': breaches.loc[sl].index.min().strftime('%Y-%m-%d'),
    'End': breaches.loc[sl].index.max().strftime('%Y-%m-%d'),
    'Forecast days': len(breaches.loc[sl]),
} for label, sl in periods.items()}).T
display(episode_sample.style.set_table_attributes('class="dataframe"').format({'Forecast days': '{:,.0f}'}, na_rep='').set_caption(
    'Episode evaluation windows'))
hits = pd.DataFrame({label: breaches.loc[sl].mean() for label, sl in periods.items()})
display(hits.style.set_table_attributes('class="dataframe"').format({label: '{:.1%}' for label in periods}, na_rep='').set_caption(
    'Out-of-sample breach rates by episode; target 5%'))

ax = hits.T.plot.bar(figsize=(10, 5), rot=0,
    color=['#14181e', '#1f4e79', '#8a8f98', '#8a1f3d', '#c7ccd2'])
ax.axhline(QUANTILE, color='#b64c33', ls='--', lw=1.3, label='target')
ax.set_ylabel('Breach rate')
ax.yaxis.set_major_formatter(PercentFormatter(1, decimals=0))
ax.set_title('VaR Breaches by Episode', loc='left')
ax.legend(loc='upper center', bbox_to_anchor=(.5, 1.42), ncol=2, frameon=False)
plt.show()
Episode evaluation windows
  Start End Forecast days
COVID 2020 2020-02-19 2020-04-30 51
Tariffs 2025 2025-01-02 2025-06-30 122
2023 2023-01-03 2023-12-29 250
Out-of-sample breach rates by episode; target 5%
  COVID 2020 Tariffs 2025 2023
Filtered historical 13.7% 4.9% 1.2%
Sample quantile (rolling) 31.4% 8.2% 0.4%
Sample quantile (expanding) 31.4% 4.9% 0.8%
Normal (rolling) 29.4% 7.4% 0.0%
Normal (expanding) 31.4% 4.1% 0.4%
../_images/9d8564eda9b2fb63e8a95d54bf6b8d735269789abfc9081fd5eaf76984efbaba.png

Short episode windows contain few expected breaches. Differences in these rates require more evidence before ranking the methods or attributing their errors.

Factor-Based Simulation#

A factor model reduces the number of volatility models for a large equity portfolio. Fit GARCH to the factors and resample stock residuals jointly with the factor shocks:

\[r_{i,t}=\alpha_i+\beta_i'f_t+\epsilon_{i,t}, \qquad r_t^{\mathrm{book}}=w'\alpha+(Bw)'f_t+w'\epsilon_t.\]

Here \(B\) has one row per factor and one column per stock. The stock residuals are resampled without a volatility adjustment. The dimension-reduction example examines the remaining dependence after a factor regression.

Fixed Equity Portfolio#

Select long and short positions from trailing returns at the end of the sample, then hold those weights fixed in the historical reconstruction. Factor loadings, GARCH parameters, and shock pools use the full sample. The panel retains stocks with complete histories.

This is a retrospective illustration of the fitted risk model. It does not represent forecasts or a momentum strategy traded at the earlier dates.

Rs = pd.read_parquet('../data/spx_returns_daily_full.parquet')
S  = pd.read_excel('../data/sector_etf_data_daily.xlsx',
                   sheet_name='total returns').set_index('date')

# a rectangular panel: dates where every sector exists, names with a full history
S  = S.loc[Rs.index.intersection(S.index)].dropna()
Rs = Rs.loc[S.index].dropna(axis=1)
S  = S.loc[Rs.index]

# dollar-neutral momentum book: long the top quintile of trailing 12-1 return, short the bottom
mom = Rs.iloc[-273:-21].sum()
n_leg = len(mom)//5
wb = pd.Series(0.0, index=Rs.columns)
wb[mom.nlargest(n_leg).index]  =  1/n_leg
wb[mom.nsmallest(n_leg).index] = -1/n_leg


FACTOR_ROLL_WINDOW = 252
book_setup = pd.DataFrame({
    'Sample start': [Rs.index.min().strftime('%Y-%m-%d')],
    'Formation date': [Rs.index.max().strftime('%Y-%m-%d')],
    'Stocks': [Rs.shape[1]], 'Factors': [S.shape[1]],
    'Long positions': [n_leg], 'Short positions': [n_leg],
})
display(book_setup.style.set_table_attributes('class="dataframe"').hide(axis='index').format(
    {c: '{:,.0f}' for c in ['Stocks', 'Factors', 'Long positions', 'Short positions']},
    na_rep='').set_caption('Fixed portfolio for the retrospective illustration'))
Fixed portfolio for the retrospective illustration
Sample start Formation date Stocks Factors Long positions Short positions
2018-06-20 2026-06-22 475 11 95 95

Sector Weights#

Group each stock by its largest sector loading and sum the portfolio weights within each group. These groups are a loading-based classification, rather than official sector assignments or the portfolio’s factor exposures.

X = np.column_stack([np.ones(len(S)), S.values])
coef = np.linalg.lstsq(X, Rs.values, rcond=None)[0]
alpha, B = coef[0], coef[1:]                 # alpha: n,  B: k x n loadings
eps = Rs.values - X @ coef                   # T x n idiosyncratic

dominant = pd.Series(S.columns[np.argmax(B, axis=0)], index=Rs.columns)
tilt = wb.groupby(dominant).sum().sort_values()

fig, ax = plt.subplots(figsize=(9,4.5))
tilt.plot.barh(ax=ax, color=['#b64c33' if x < 0 else '#1f4e79' for x in tilt])
ax.axvline(0, color='black', lw=0.8)
ax.xaxis.set_major_formatter(PercentFormatter(1, decimals=0))
ax.set_ylabel('')
ax.set_xlabel('net book weight  (long - short)')
ax.set_title('Net Portfolio Weight by Largest Sector Loading', loc='left')
plt.show()

r2 = 1 - eps.var(0)/Rs.values.var(0)
(pd.Series(r2, index=Rs.columns).describe()[['mean','50%','min','max']]
 .rename(index={'mean': 'Mean', '50%': 'Median', 'min': 'Minimum', 'max': 'Maximum'})
 .to_frame('Regression R²').style.set_table_attributes('class="dataframe"').format({'Regression R²': '{:.0%}'}, na_rep='')
 .set_caption('Share of stock return variance explained by sector factors'))
../_images/855b7d8d0189a3d23e496691c65e6b24ea782437044fc07514440573b2a204dd.png
Share of stock return variance explained by sector factors
  Regression R²
Mean 50%
Median 50%
Minimum 9%
Maximum 87%

Factor and Residual Shocks#

Asset simulation

Factor simulation

Fit GARCH to each asset

Fit GARCH to each factor

Resample one date’s standardized asset shocks

Resample one date’s standardized factor shocks and stock residuals jointly

Rescale by asset volatility forecasts

Rescale the factors; leave stock residuals unscaled

Weight asset returns

Combine factor exposure and weighted stock residuals

The estimate below uses next-day factor forecasts at the sample end.

k = S.shape[1]
Zf = np.empty((len(S), k))
CVf = np.empty((len(S), k))
mu_factor = np.empty(k)
sig_factor = np.empty(k)
for j, s in enumerate(S.columns):
    res, scale = fit_garch(S[s])
    Zf[:, j] = res.std_resid
    CVf[:, j] = res.conditional_volatility/scale
    fc = res.forecast(horizon=1, reindex=False)
    mu_factor[j] = fc.mean.iloc[-1, 0]/scale
    sig_factor[j] = np.sqrt(fc.variance.iloc[-1, 0])/scale

bw = B @ wb.values
epsw = eps @ wb.values
const = float(wb.values @ alpha)
rpb = pd.Series(Rs.values @ wb.values, index=Rs.index)
draw = rng.integers(0, len(Zf), size=M)
book_sim = const + (mu_factor + Zf[draw]*sig_factor) @ bw + epsw[draw]
book_risk = pd.DataFrame({'VaR (5%)': [-np.quantile(book_sim, QUANTILE, method='inverted_cdf')],
                          'CVaR (5%)': [-tail_mean(book_sim, QUANTILE)]})
display(book_risk.style.set_table_attributes('class="dataframe"').hide(axis='index').format(
    {'VaR (5%)': '{:.2%}', 'CVaR (5%)': '{:.2%}'}, na_rep='').set_caption(
    f'Fixed equity portfolio: next-day loss estimates as of {Rs.index.max():%Y-%m-%d}'))
Fixed equity portfolio: next-day loss estimates as of 2026-06-22
VaR (5%) CVaR (5%)
1.38% 2.01%

In-Sample VaR and Breaches#

Apply the full-sample factor model and shock pool to the historical volatility series. Compare the reconstructed returns with those fitted thresholds, a rolling sample quantile, and the full-sample quantile.

All rows use common dates after the rolling window is available. These are in-sample diagnostics of the fixed portfolio. Future information enters the holdings and fitted model, so the results do not measure historical forecasting performance.

fhs_b = pd.Series([
    -np.quantile(const + mu_factor @ bw + Zf @ (bw*CVf[i]) + epsw,
                 QUANTILE, method='inverted_cdf')
    for i in range(len(Rs))], index=Rs.index)
roll_b = -rpb.rolling(FACTOR_ROLL_WINDOW).apply(
    lambda x: np.quantile(x, QUANTILE, method='inverted_cdf'), raw=True).shift(1)
unc_b = pd.Series(-np.quantile(rpb, QUANTILE, method='inverted_cdf'), index=Rs.index)
# Conditional volatility is already indexed by the return date; do not shift it again.
book_var = pd.DataFrame({'Factor FHS (full-sample fit)': fhs_b,
                         'Sample quantile (rolling)': roll_b,
                         'Sample quantile (full sample)': unc_b}).dropna()
book_breaches = book_var.lt(-rpb.reindex(book_var.index), axis=0)
book_periods = {'Full sample': slice(None), **periods}
book_hits = pd.DataFrame({label: book_breaches.loc[sl].mean()
                          for label, sl in book_periods.items()})
display(book_hits.style.set_table_attributes('class="dataframe"').format({label: '{:.1%}' for label in book_periods}, na_rep='').set_caption(
    'In-sample breach rates for fixed end-of-sample holdings; target 5%'))
In-sample breach rates for fixed end-of-sample holdings; target 5%
  Full sample COVID 2020 Tariffs 2025 2023
Factor FHS (full-sample fit) 5.1% 3.9% 7.4% 2.0%
Sample quantile (rolling) 5.9% 21.6% 9.8% 2.8%
Sample quantile (full sample) 5.2% 19.6% 6.6% 1.6%

The residual component has no volatility model. Its contribution to the episode errors requires a separate test. The breach table alone does not identify whether factor volatility, residual volatility, dependence, or the portfolio’s exposures explain a difference.