arch
Two distinct libraries share one package. The volatility half is well known; arch.bootstrap is the most under-used
correct tool in quantitative finance. 8.0.0 is a compatibility release (Python 3.14 wheels, NumPy 2.4 / pandas 3) —
not an API break from 7.x. statsmodels has no equivalent.
|
|
| pip / import |
arch / arch |
| Version |
8.0.0 (2025-10-21) · Python >=3.10 |
| Licence |
NCSA — permissive and BSD-like, but not one of the usual three; GitHub reports NOASSERTION. Flag it in a licence audit. |
| Status |
✅ actively maintained; the reference GARCH implementation in Python — bashtage/arch (Kevin Sheppard, Oxford), 1,558★ / 51 issues, pushed 2026-08-10 |
The trap that costs you money
🚨 SPA, RealityCheck, StepM and MCS all take LOSSES — lower is better. Verified against
arch/bootstrap/multiple_comparison.py: SPA/StepM document benchmark as "T element array of benchmark model
losses" and models as "T by k element array of alternative model
losses"; MCS's parameter is literally named losses.
Passing returns does not raise and does not warn. It inverts the test — the worst strategy is identified as the
best, and the p-value you report is for the opposite hypothesis. For a return series the whole conversion is losses = -returns. Anything already loss-shaped (squared forecast error, negative log-likelihood, absolute error, drawdown)
goes in as-is. A ratio — a Sharpe, an information ratio — is not accepted at all; these tests need the per-period
loss column.
Which procedure answers which question
| Question |
Class |
Input |
| Is the best of N better than a benchmark, correcting for the search? |
SPA (Hansen 2005) |
benchmark losses + (T×k) model losses |
| Same question, older and less powerful |
RealityCheck (White 2000) |
identical |
| Which strategies beat the benchmark, under FWER control? |
StepM (Romano-Wolf) |
benchmark + (T×k) losses |
| Which models are indistinguishable from the best? |
MCS (Hansen-Lunde-Nason) |
(T×k) losses, no benchmark |
Verified signatures: SPA(benchmark, models, block_size=None, reps=1000, bootstrap="stationary", studentize=True, nested=False, *, seed=None); StepM(benchmark, models, size=0.05, ...); MCS(losses, size, reps=1000, block_size=None, method="R"|"max", ...).
- ✅
RealityCheck is literally class RealityCheck(SPA): pass — a zero-code subclass; White's
Reality Check is SPA without studentization or recentring. Use SPA, and mention
RealityCheck only when reproducing a pre-2005 paper.
- 🚨
SPA.pvalues returns three values — lower, consistent, upper. Report consistent.
They bracket the influence of dominated models on the null; lower is liberal, upper
conservative. Also .critical_values(pvalue=0.05) and .better_models(pvalue=0.05).
StepM.superior_models names the survivors under FWER control; internally it builds an SPA.
MCS takes no benchmark, raises with fewer than 2 columns, and gives .included/.excluded.
- ⚠️
block_size defaults to int(sqrt(T)). Set it from optimal_block_length (Politis-White) —
preserving serial dependence is the entire reason for a block bootstrap. Cost is
O(reps × T × k): 1000 reps over hundreds of candidates is minutes, not seconds.
- ⚠️ These control FWER over the models you pass, and know nothing about the variants you tried
and discarded — that is what a trial ledger and the Deflated Sharpe Ratio are for. Use both.
Volatility models: fit on returns × 100
🚨 arch expects returns in PERCENT. Decimals give tiny variances, poor optimizer conditioning and convergence
warnings — the single most common arch support question, and a usage issue rather than a bug.
Rescale the forecast back out of percent before you use it.
forecast() returns variance, not volatility — square-root it, then undo the ×100.
reindex=False is what you almost always want; the default reindexes to the full sample.
- ⚠️
o=1 is what gives you the leverage effect. Plain GARCH(1,1) treats a −5% day and a +5%
day identically, empirically false for equities. GJR (o=1) or EGARCH is the honest default.
Covers GARCH, EGARCH, GJR-GARCH/TARCH, APARCH, HARCH, HAR-RV, FIGARCH, RiskMetrics, ARCH; Normal / Student-t / skew-t
/ GED innovations; AR and HAR mean models; plus arch.unitroot (ADF, DF-GLS, Phillips-Perron, KPSS, Zivot-Andrews,
Variance Ratio), cointegration and long-run covariance estimators. HAR on realized volatility remains a very strong
baseline.
Minimal correct call
from arch import arch_model
from arch.bootstrap import SPA, StepM, MCS, optimal_block_length
am = arch_model(returns * 100, # 🚨 PERCENT, not decimals
vol="GARCH", p=1, o=1, q=1, # o=1 -> GJR asymmetry (leverage effect)
dist="skewt", mean="Constant")
res = am.fit(disp="off")
f = res.forecast(horizon=10, reindex=False) # variance, not vol
sigma_next = (f.variance.iloc[-1] ** 0.5) / 100 # 🚨 rescale back out of percent
z = (res.resid / res.conditional_volatility).dropna() # GARCH-filtered VaR: a CONDITIONAL quantile
var_1d = (res.params["mu"] + sigma_next.iloc[0] * 100 * z.quantile(0.05)) / 100
losses_bm = -bench_returns # 🚨 NEGATE. (T,)
losses_mdl = -model_returns # 🚨 NEGATE. (T, k)
bs = int(optimal_block_length(losses_bm).iloc[0, 0])
spa = SPA(losses_bm, losses_mdl, reps=1000, block_size=bs, seed=7); spa.compute()
print(spa.pvalues["consistent"]) # 🚨 the one to report
stepm = StepM(losses_bm, losses_mdl, size=0.05, reps=1000, block_size=bs); stepm.compute()
print(stepm.superior_models) # WHICH ones survive FWER control
mcs = MCS(losses_mdl, size=0.10, reps=1000, block_size=bs); mcs.compute()
print(mcs.included, mcs.excluded) # no benchmark argument
Scripts
✅ scripts/spa_direction.py runs the inversion end to end. Six synthetic strategies whose realised annualised
Sharpes are pinned to -1.5, -0.9, 0.0, +0.5, +1.0, +1.6 against a +0.3 benchmark (seed 0, T=1500 daily observations,
reps=1000, seed=7). Measured, with arch 8.0.0:
| call |
SPA.better_models |
StepM.superior_models |
MCS.included |
losses = -returns (correct) |
['S6_excellent'] |
['S6_excellent'] |
['S5_good', 'S6_excellent'] |
| returns passed as losses (trap) |
['S1_terrible', 'S2_bad'] |
['S1_terrible', 'S2_bad'] |
['S1_terrible', 'S2_bad'] |
The trap names the two worst strategies in the set as the superior ones — a swing of 3.1 Sharpe between what it
crowns and what actually won. Nothing raised and nothing warned. 🚨 The consistent p-value is 0.0050 the correct
way round and 0.0000 the wrong way round, so it rejects either way and cannot tell you which input you passed.
The script's numpy-only reference SPA reproduces arch's p-values to max|diff| = 0.000000 and its StepM sets
exactly, so the demonstration still runs on a machine without arch.
See also
../../../fin-core/skills/backtest-validation/SKILL.md — the domain skill for the arch.bootstrap half
../../../fin-core/skills/factor-and-timeseries-research/SKILL.md — the domain skill for the GARCH half
../../../fin-core/skills/factor-and-timeseries-research/references/arch.md — the source card
../../../fin-core/skills/backtest-validation/references/significance-tests.md — which test answers which question
Where this sits
This file is the deep dive on one library and assumes the choice is already made.
For which library to pick, how it compares with the alternatives, and the traps that span
several of them, the entry point is the domain skill backtest-validation (../../../fin-core/skills/backtest-validation/SKILL.md).
1---2name: lib-arch3description: The reference GARCH implementation in Python, and the home of SPA/StepM/MCS - which all take LOSSES, so passing returns silently inverts the test and names your worst strategy as the best. TRIGGER - arch, arch_model, arch.bootstrap, arch.univariate, SPA, RealityCheck, StepM, MCS, optimal_block_length, StationaryBootstrap, superior_models, spa.pvalues, mcs.included, arch.unitroot, GARCH, EGARCH, GJR-GARCH, TARCH, APARCH, FIGARCH, HARCH, HAR-RV, skewt, conditional_volatility, or a GARCH fit emitting convergence warnings. Memory is stale on licence and version - it is 8.0.0 (2025-10-21) under NCSA, not one of the three usual permissive licences. SKIP for PSR and DSR (backtest-validation), for PBO and CPCV (lib-purgedcv) and for reporting Sharpe (lib-quantstats). SKIP for choosing between libraries, or when no library is named - the domain skill's job.4license: MIT5---67# arch89Two distinct libraries share one package. The volatility half is well known; **`arch.bootstrap` is the most under-used10correct tool in quantitative finance.** 8.0.0 is a compatibility release (Python 3.14 wheels, NumPy 2.4 / pandas 3) —11**not an API break** from 7.x. statsmodels has no equivalent.1213| | |14|---|---|15| pip / import | `arch` / `arch` |16| Version | **8.0.0** (2025-10-21) · Python `>=3.10` |17| Licence | **NCSA** — permissive and BSD-like, but *not* one of the usual three; GitHub reports `NOASSERTION`. **Flag it in a licence audit.** |18| Status | ✅ **actively maintained; the reference GARCH implementation in Python** — `bashtage/arch` (Kevin Sheppard, Oxford), 1,558★ / 51 issues, pushed 2026-08-10 |1920## The trap that costs you money2122🚨 **`SPA`, `RealityCheck`, `StepM` and `MCS` all take LOSSES — lower is better.** Verified against23`arch/bootstrap/multiple_comparison.py`: `SPA`/`StepM` document `benchmark` as *"T element array of benchmark model24**losses**"* and `models` as *"T by k element array of alternative model25**losses**"*; `MCS`'s parameter is literally named `losses`.2627Passing returns does not raise and does not warn. **It inverts the test** — the worst strategy is identified as the28best, and the p-value you report is for the opposite hypothesis. For a return series the whole conversion is `losses =29-returns`. Anything already loss-shaped (squared forecast error, negative log-likelihood, absolute error, drawdown)30goes in as-is. A *ratio* — a Sharpe, an information ratio — is not accepted at all; these tests need the per-period31loss column.3233## Which procedure answers which question3435| Question | Class | Input |36|---|---|---|37| Is the **best of N** better than a benchmark, correcting for the search? | **`SPA`** (Hansen 2005) | benchmark losses + (T×k) model losses |38| Same question, older and less powerful | `RealityCheck` (White 2000) | identical |39| ***Which*** strategies beat the benchmark, under FWER control? | **`StepM`** (Romano-Wolf) | benchmark + (T×k) losses |40| Which models are indistinguishable from the best? | **`MCS`** (Hansen-Lunde-Nason) | (T×k) losses, **no benchmark** |4142Verified signatures: `SPA(benchmark, models, block_size=None, reps=1000, bootstrap="stationary", studentize=True,43nested=False, *, seed=None)`; `StepM(benchmark, models, size=0.05, ...)`; `MCS(losses, size, reps=1000,44block_size=None, method="R"|"max", ...)`.4546- ✅ **`RealityCheck` is literally `class RealityCheck(SPA): pass`** — a zero-code subclass; White's47 Reality Check is SPA without studentization or recentring. **Use `SPA`**, and mention48 `RealityCheck` only when reproducing a pre-2005 paper.49- 🚨 **`SPA.pvalues` returns three values — `lower`, `consistent`, `upper`. Report `consistent`.**50 They bracket the influence of dominated models on the null; `lower` is liberal, `upper`51 conservative. Also `.critical_values(pvalue=0.05)` and `.better_models(pvalue=0.05)`.52- `StepM.superior_models` names the survivors under FWER control; internally it builds an `SPA`.53 `MCS` takes **no benchmark**, raises with fewer than 2 columns, and gives `.included`/`.excluded`.54- ⚠️ `block_size` defaults to `int(sqrt(T))`. Set it from `optimal_block_length` (Politis-White) —55 preserving serial dependence is the entire reason for a block bootstrap. Cost is56 **O(reps × T × k)**: 1000 reps over hundreds of candidates is minutes, not seconds.57- ⚠️ These control FWER over **the models you pass**, and know nothing about the variants you tried58 and discarded — that is what a trial ledger and the Deflated Sharpe Ratio are for. Use both.5960## Volatility models: fit on returns × 1006162🚨 **`arch` expects returns in PERCENT.** Decimals give tiny variances, poor optimizer conditioning and convergence63warnings — the single most common `arch` support question, and a usage issue rather than a bug.64**Rescale the forecast back out of percent before you use it.**6566- `forecast()` returns **variance**, not volatility — square-root it, then undo the ×100.67 `reindex=False` is what you almost always want; the default reindexes to the full sample.68- ⚠️ **`o=1` is what gives you the leverage effect.** Plain `GARCH(1,1)` treats a −5% day and a +5%69 day identically, empirically false for equities. GJR (`o=1`) or EGARCH is the honest default.7071Covers GARCH, EGARCH, GJR-GARCH/TARCH, APARCH, HARCH, HAR-RV, FIGARCH, RiskMetrics, ARCH; Normal / Student-t / skew-t72/ GED innovations; AR and HAR mean models; plus `arch.unitroot` (ADF, DF-GLS, Phillips-Perron, KPSS, Zivot-Andrews,73Variance Ratio), cointegration and long-run covariance estimators. **HAR on realized volatility remains a very strong74baseline.**7576## Minimal correct call7778```python79from arch import arch_model80from arch.bootstrap import SPA, StepM, MCS, optimal_block_length81am = arch_model(returns * 100, # 🚨 PERCENT, not decimals82 vol="GARCH", p=1, o=1, q=1, # o=1 -> GJR asymmetry (leverage effect)83 dist="skewt", mean="Constant")84res = am.fit(disp="off")85f = res.forecast(horizon=10, reindex=False) # variance, not vol86sigma_next = (f.variance.iloc[-1] ** 0.5) / 100 # 🚨 rescale back out of percent8788z = (res.resid / res.conditional_volatility).dropna() # GARCH-filtered VaR: a CONDITIONAL quantile89var_1d = (res.params["mu"] + sigma_next.iloc[0] * 100 * z.quantile(0.05)) / 1009091losses_bm = -bench_returns # 🚨 NEGATE. (T,)92losses_mdl = -model_returns # 🚨 NEGATE. (T, k)93bs = int(optimal_block_length(losses_bm).iloc[0, 0])9495spa = SPA(losses_bm, losses_mdl, reps=1000, block_size=bs, seed=7); spa.compute()96print(spa.pvalues["consistent"]) # 🚨 the one to report9798stepm = StepM(losses_bm, losses_mdl, size=0.05, reps=1000, block_size=bs); stepm.compute()99print(stepm.superior_models) # WHICH ones survive FWER control100101mcs = MCS(losses_mdl, size=0.10, reps=1000, block_size=bs); mcs.compute()102print(mcs.included, mcs.excluded) # no benchmark argument103```104105## Scripts106107✅ **`scripts/spa_direction.py`** runs the inversion end to end. Six synthetic strategies whose *realised* annualised108Sharpes are pinned to -1.5, -0.9, 0.0, +0.5, +1.0, +1.6 against a +0.3 benchmark (seed 0, T=1500 daily observations,109`reps=1000`, `seed=7`). Measured, with `arch` 8.0.0:110111| call | `SPA.better_models` | `StepM.superior_models` | `MCS.included` |112|---|---|---|---|113| `losses = -returns` (correct) | `['S6_excellent']` | `['S6_excellent']` | `['S5_good', 'S6_excellent']` |114| returns passed as losses (trap) | `['S1_terrible', 'S2_bad']` | `['S1_terrible', 'S2_bad']` | `['S1_terrible', 'S2_bad']` |115116The trap names the two **worst** strategies in the set as the superior ones — a swing of 3.1 Sharpe between what it117crowns and what actually won. Nothing raised and nothing warned. 🚨 **The consistent p-value is 0.0050 the correct118way round and 0.0000 the wrong way round**, so it rejects either way and cannot tell you which input you passed.119The script's numpy-only reference SPA reproduces `arch`'s p-values to `max|diff| = 0.000000` and its StepM sets120exactly, so the demonstration still runs on a machine without `arch`.121122## See also123124- `../../../fin-core/skills/backtest-validation/SKILL.md` — the domain skill for the `arch.bootstrap` half125- `../../../fin-core/skills/factor-and-timeseries-research/SKILL.md` — the domain skill for the GARCH half126- `../../../fin-core/skills/factor-and-timeseries-research/references/arch.md` — the source card127- `../../../fin-core/skills/backtest-validation/references/significance-tests.md` — which test answers which question128129## Where this sits130131This file is the deep dive on **one** library and assumes the choice is already made.132For which library to pick, how it compares with the alternatives, and the traps that span133several of them, the entry point is the domain skill **`backtest-validation`** (`../../../fin-core/skills/backtest-validation/SKILL.md`).