---
myst:
html_meta:
description: >-
Regime-conditional performance in qis: benchmark-quantile Bear, Normal and Bull regimes,
regime frequencies and conditional means, and the additive decomposition of Sharpe ratios
and per-annum returns into regime contributions.
---
# Regime-conditional performance
*Author: [Artur Sepp](https://github.com/ArturSepp)*
Implemented in [qis — Quantitative Investment Strategies](https://github.com/ArturSepp/QuantInvestStrats).
Software citation: [CITATION.cff](https://github.com/ArturSepp/QuantInvestStrats/blob/main/CITATION.cff).
Regime-conditional performance partitions the periods of a sample by what a benchmark did in
each period and reports an asset's performance within each part. In qis the default partition
labels every quarter Bear, Normal or Bull by the full-sample 16% and 84% quantiles of the
benchmark's quarterly return, and the reported statistics are the regime frequencies, the
conditional mean returns, and each regime's contribution to the per-annum return and to the
Sharpe ratio. Under the arithmetic and log conventions the Sharpe contributions add up exactly
to the Sharpe ratio on the regime grid; under the default per-annum convention a residual is
allocated so that the regime returns add up to the per-annum return.
## Overview
A full-sample Sharpe ratio says how much return a strategy earned per unit of risk; it does not
say *when* the return was earned. Two strategies with the same Sharpe ratio can earn it in
opposite states of the world: one in the quarters in which equities fall, the other in quiet
quarters, giving part of it back when equities fall.
[Sepp (2019)](https://thehedgefundjournal.com/trend-following-ctas-vs-alternative-risk-premia/)
uses this distinction to separate the *crisis beta* of trend-following managers from the
*risk-premia alpha* of alternative risk premia, and the regime decomposition of this chapter is
the tool that makes the distinction measurable.
The calculation has three steps:
1. **Classify.** Resample the benchmark to a regime grid (quarter-ends by default) and label
each period Bear, Normal or Bull by the quantile band its return falls into.
2. **Condition.** For each asset, compute the fraction of periods in each regime and the mean
return within each regime.
3. **Decompose.** Multiply frequency by conditional mean. Divided by the full-sample standard
deviation, these products are Sharpe ratio contributions that add up to the Sharpe ratio on
the regime grid; compounded and patched, they are per-annum return contributions that add up
to the per-annum return.
The chapter proves when the contributions add up exactly, what the per-annum patch allocates,
how large its residual is, how a linear benchmark exposure propagates into the regime profile,
and how noisy a regime contribution is. The classification uses full-sample quantiles and the
same-period benchmark return, so the exhibit is descriptive: it attributes realised
performance and does not forecast regimes.
## Inputs, notation, and assumptions
| Convention | This article |
|---|---|
| Return basis | Simple total returns on the regime grid (`ReturnTypes.RELATIVE`); the `LOG` convention uses $\log(1+r)$ of the same returns; no cash is deducted |
| Sampling grid | Classifier `freq`, default quarter-ends `QE`, with a stub period at each end of an off-grid history; table volatility on `PerfParams.freq_vol`, default `ME` |
| Annualisation | $\mathrm{AN}$ of the regime grid (4 for `QE`) for regime contributions: $\mathrm{AN}$ for means, $\sqrt{\mathrm{AN}}$ for Sharpe ratios; the per-annum branch divides by the table volatility annualised on `freq_vol` |
| Mean adjustment | Conditional means are raw regime averages; the Sharpe denominator $s(r)$ is the unconditional, demeaned standard deviation with `ddof=1` |
| Timing | Descriptive and full sample: quantile edges use the whole history, and period $(t-1,t]$ is labelled by the benchmark return over the same period |
| Output units | Average and P.a. columns in decimals; Sharpe contributions dimensionless and additive to a total |
| qis default | `compute_bnb_regimes_pa_perf_table(freq='QE', return_type=ReturnTypes.RELATIVE, q=None, perf_params=None)`: edges at 0.16 and 0.84, `SharpeConvention.PA` |
| Symbol or input | Meaning | Units and convention |
|---|---|---|
| $t_0\hat Q_b(0.84).
$$
The 16% and 84% probabilities are the one-sigma cut of a normal distribution,
$\Phi(-1)=0.1587$, so the Normal band holds the central 68%.
**Definition (the quantile rule of qis).** Every quantile classification in qis, including the
return and volatility regimes, the regime Sharpe decomposition, the analytics of `qis.regimes`
and the quantile hue buckets of the plots, goes through `qis.utils.quantile_buckets`, so a period
is Bear in one exhibit exactly when it is Bear in every other:
- *Edges.* An interior edge is the sample quantile by linear interpolation between order
statistics at position $(T-1)\,q$ (Hyndman and Fan's type 7, the numpy and pandas default). A
position within $10^{-9}$ of a whole number is taken as that number, so the order statistic
itself is the edge and floating-point noise cannot decide a bucket.
- *Assignment.* Buckets are closed on the right with open outer ends,
$(-\infty,e_1],(e_1,e_2],\dots,(e_{G-1},+\infty)$: the bucket of an observation is the number of
interior edges strictly below it. The sample minimum is in the first bucket and the maximum in
the last.
- *Ties.* An observation equal to an interior edge falls in the lower bucket. Block-bootstrap
resamples repeat observations and put them on edges routinely.
- *Missing values.* NaN and infinite observations are left out of the edges and get no bucket.
- *Occupancy.* When the edges are estimated from the data, every bucket must hold an
observation, else `qis.utils.quantile_buckets.EmptyQuantileBucketError`, a `ValueError`,
reports how many buckets are occupied.
Within these rules the classification equals
`pd.qcut(x=r_b, q=[0.0, 0.16, 0.84, 1.0], labels=['Bear', 'Normal', 'Bull'])` of pandas 3.
pandas 2 computes the `qcut` edges with `np.percentile` at $100\,q$, which can leave an edge one
unit in the last place off the order statistic at a whole-number position; there `pd.qcut` puts
that observation in the upper bucket and qis in the lower. This happens for tertiles, deciles
and seven buckets, not for the one-sigma, 10/90 or 5/95 cuts, quartiles or quintiles.
With $T$ distinct returns, the edges sit at positions $0.16(T-1)$ and $0.84(T-1)$ of the
sorted sample, counted from zero, so Bear holds $\lfloor 0.16(T-1)\rfloor+1$ periods and Bull
holds $T-\lfloor 0.84(T-1)\rfloor-1$. For $T=40$ quarters, $0.16\times 39=6.24$ and
$0.84\times 39=32.76$, so the counts are 7, 26 and 7, and $p_g=(0.175,\,0.65,\,0.175)$ rather
than exactly $(0.16,\,0.68,\,0.16)$. The two tails need not be equal: $T=26$ gives 5 Bear and
4 Bull periods.
The classifier resamples prices to its grid with `include_start_date=True` and
`include_end_date=True`. The grid is therefore the first observation date, every quarter-end in
between, and the last observation date. The first date has no return and stays unclassified.
A history that starts or ends between quarter-ends contributes a *stub* period at each end,
classified against full quarters and annualised as a quarter.
> **Pitfall.** The quantile edges are estimated on the whole sample, so the label of a quarter
> depends on returns that came after it. Extending the sample moves the edges and relabels past
> quarters: in the worked example, classifying 2015–2019 alone labels two of those twenty
> quarters differently from the 2015–2024 classification. The labels are a descriptive device
> for attribution. Used as a trading signal inside a backtest they are look-ahead; a
> point-in-time version would need expanding-window edges, which qis does not provide.
**Definition (sign regimes).** `qis.BenchmarkReturnsPositiveNegativeRegime` labels period $t$
Negative if $r_{b,t}<0$ and Positive if $r_{b,t}\ge 0$. It estimates nothing from the sample, so
a label does not depend on other periods; it is still contemporaneous with the returns it
conditions. Both categories are kept when one is empty, and a missing benchmark return stays
unclassified.
**Definition (volatility regimes).** `qis.BenchmarkVolsQuantilesRegime(q=4)` computes, for each
regime period, the realised volatility of the benchmark's native-frequency returns within the
period (after the previous regime date, up to and including its own, so a boundary return is
counted once), annualised with the native grid's factor, and splits these volatilities into `q`
equal-count buckets at full-sample quantiles. The labels carry the thresholds, for example
`'SPY vol<12%'`, so they are known only after classification. The volatility is measured over
the same period whose returns are conditioned on, and the thresholds use the full sample.
The volatility buckets follow the same quantile rule as the return regimes, so a volatility
equal to an edge falls in the lower bucket. A classifier whose benchmark cannot fill every band,
such as a constant or back-padded zero-return block, raises `EmptyQuantileBucketError`, whose
message counts the occupied bands: a constant volatility reports 1 of `q` bands.
### Regime frequencies and conditional means
**Definition.** For regime $g$ with dates $\mathcal{T}_g$ and $T_g=\lvert\mathcal{T}_g\rvert$,
$$
p_g=\frac{T_g}{T},
\qquad
m_g=\frac{1}{T_g}\sum_{t\in\mathcal{T}_g} r_t .
$$
The frequencies count benchmark-classified dates, so every asset in the panel shares one set of
$p_g$. The conditional means average each asset's observed returns in the regime. Both are
computed by `qis.compute_mean_freq_regimes` with a pandas `groupby` on the regime column.
### The additive Sharpe decomposition
**Definition.** The arithmetic Sharpe contribution of regime $g$ is
$$
\mathrm{SR}_g=\frac{\sqrt{\mathrm{AN}}\;p_g\,m_g}{s(r)},
$$
where $s(r)$ is the unconditional sample standard deviation of the asset's returns on the
regime grid and $\mathrm{AN}$ is the annualisation factor of that grid.
**Proposition (additivity, Sepp 2019).** If the asset is observed on every classified date and
$s(r)$ is computed on those dates, then
$$
\sum_{g}\mathrm{SR}_g=\frac{\sqrt{\mathrm{AN}}\;\bar r}{s(r)},
$$
the arithmetic Sharpe ratio of the asset's returns on the regime grid.
**Proof.** $p_g m_g=(T_g/T)(1/T_g)\sum_{t\in\mathcal{T}_g}r_t=(1/T)\sum_{t\in\mathcal{T}_g}r_t$.
The regimes partition the classified dates, so summing over $g$ gives $\bar r$: the law of
total expectation, $\mathbb{E}[r]=\sum_g \Pr(g)\,\mathbb{E}[r\mid g]$, under the empirical
measure. The denominator is common to all terms. $\square$
The decomposition splits the numerator only. The denominator is the full-sample standard
deviation, so $\mathrm{SR}_g$ is a *contribution* to the Sharpe ratio, not the Sharpe ratio
*within* regime $g$, which would be $\sqrt{\mathrm{AN}}\,m_g/\sigma_g$ and would not add up to
anything. The same argument on log returns gives the log decomposition.
**Proposition (log decomposition).** With $m^{\ell}_g$ the mean of $\ell_t=\log(1+r_t)$ in
regime $g$,
$$
\sum_g \mathrm{SR}^{\ell}_g=\sum_g\frac{\sqrt{\mathrm{AN}}\;p_g\,m^{\ell}_g}{s(\ell)}=\frac{\sqrt{\mathrm{AN}}\;\bar\ell}{s(\ell)} .
$$
**Proof.** Apply the previous proof to $\ell_t$. The labels do not change, because
$\log(1+x)$ is increasing and so preserves the benchmark's quantile bands. $\square$
The total is the Sharpe ratio *on the regime grid*. It need not equal any Sharpe column of the
attached performance table: `PerfStat.SHARPE_ARITH` is computed on `freq_vol` (month-ends by
default), `PerfStat.SHARPE_LOG_AN` divides $\log(1+R_{\mathrm{pa}})$ by the table volatility,
and `PerfStat.SHARPE_RF0` is per-annum. Quarterly and monthly estimates of the same
Sharpe ratio differ by sampling error and by any serial correlation of monthly returns; in the
worked example the benchmark's quarterly arithmetic Sharpe ratio is 0.248 and its monthly
`SHARPE_ARITH` is 0.265.
> **Insight.** A regime contribution is frequency times conditional mean. The Normal regime
> holds about two thirds of the periods, so a modest Normal mean can outweigh a large Bear or
> Bull mean. Reading the Bear bar as "performance in crises" is right; reading it as "Sharpe
> ratio in crises" is not, because it is scaled by $p_g$ and by the full-sample risk.

[Open full-resolution preview](images/handbook_regime_sharpe.png).
The exhibit classifies quarters by the 16% and 84% quantiles of synthetic US equity returns and
plots $\mathrm{SR}_g$ for four assets under `SharpeConvention.ARITHMETIC`. The diamonds are the
sums, and each equals the asset's quarterly arithmetic Sharpe ratio, as the proposition requires.
Equity earns −0.40 in Bear quarters and +0.58 in Bull quarters, for a total of 0.30. Treasuries
earn +0.29 of their 0.49 in Bear quarters: the profile of a crisis hedge. Gold and the hedge-fund
index lose in Bear quarters and earn most of their totals in Normal quarters, so their similar
totals of 0.37 and 0.44 come from different regime profiles.
### The per-annum convention
The default `SharpeConvention.PA` reports per-annum regime returns that add up to the table's
per-annum return, and divides them by the table volatility.
**Definition.** Let $R_{\mathrm{pa}}$ be the table's `PerfStat.PA_RETURN`: the compound return
$(P_{\mathrm{end}}/P_{\mathrm{start}})^{1/Y}-1$ between the asset's native first and last
observations, with $Y$ in years of 365.25 days, or the total return when $Y\le 1$. With
$x_g=\mathrm{AN}\,p_g m_g$ built from mean *simple* returns,
$$
\begin{aligned}
C_g&=e^{x_g}-1,
\qquad
\Delta=R_{\mathrm{pa}}-\sum_g C_g,\\
\tilde C_g&=C_g+p_g\,\Delta,
\qquad
\mathrm{SR}^{\mathrm{pa}}_g=\frac{\tilde C_g}{\hat\sigma_{\mathrm{ann}}},
\qquad
\hat\sigma_{\mathrm{ann}}=\sqrt{\mathrm{AN}_{\mathrm{vol}}}\;s(v).
\end{aligned}
$$
Here $v_t$ are the `PerfParams.return_type` returns (log by default) on `freq_vol`
(month-ends by default), and $\hat\sigma_{\mathrm{ann}}$ is the `PerfStat.VOL` column.
**Identity (per-annum additivity).** $\sum_g\tilde C_g=R_{\mathrm{pa}}$ and
$\sum_g\mathrm{SR}^{\mathrm{pa}}_g=R_{\mathrm{pa}}/\hat\sigma_{\mathrm{ann}}$.
**Proof.** $\sum_g\tilde C_g=\sum_g C_g+\Delta\sum_g p_g=\sum_g C_g+\Delta=R_{\mathrm{pa}}$,
because the frequencies sum to one. Divide by the common $\hat\sigma_{\mathrm{ann}}$. $\square$
The additivity holds by construction, so it carries no information; the content is in how the
residual is allocated. qis allocates $\Delta$ in proportion to $p_g$. The packaged Sharpe note
described an equal split, $\tilde C_g=C_g+\Delta/G$, which also restores the total but is not
what the code computes.
**Proposition (anatomy of the residual).** Suppose the native endpoints lie on the regime grid
and $T/Y=\mathrm{AN}$, and write $X=\sum_g x_g=\mathrm{AN}\,\bar r$. Then
$$
\Delta=\big(e^{\mathrm{AN}\,\bar\ell}-e^{X}\big)+\sum_{g **Insight.** The drag accrues where squared returns are large, which is the Bear and Bull
> tails, but the $p_g$ allocation charges the residual by time spent, so the Normal regime
> absorbs 65% of it. In the log decomposition each term $\mathrm{AN}\,p_g m^{\ell}_g$ carries
> the drag of its own periods, because $\ell_t\approx r_t-r_t^2/2$ period by period, and the
> terms add up to $\mathrm{AN}\,\bar\ell=\log(1+R_{\mathrm{pa}})$. In the worked example 78% of
> the benchmark's drag accrues in the 35% of quarters labelled Bear or Bull, and its Normal
> contribution is 1.1% under the $p_g$ patch against 2.5%, in log units, under the log
> decomposition.
The per-annum Sharpe contributions add up to $R_{\mathrm{pa}}/\hat\sigma_{\mathrm{ann}}$. That
equals `PerfStat.SHARPE_RF0` only when the native endpoints lie on the `freq_vol` grid,
because `SHARPE_RF0` divides a per-annum return taken between complete `freq_vol` boundaries.
> **Pitfall.** The per-annum branch mixes grids and return bases: quarterly simple-return
> contributions on the numerator, the volatility of monthly log returns in the denominator.
> The resulting bars are neither the arithmetic nor the log decomposition, and they differ
> from both by the volatility drag. Use `SharpeConvention.ARITHMETIC` or
> `SharpeConvention.LOG` when the regime bars must be an exact decomposition of a Sharpe ratio
> computed on one series.
### Linear benchmark exposure
**Proposition (beta propagation).** Fit $r_t=\hat\alpha+\hat\beta\,r_{b,t}+\hat\varepsilon_t$
by OLS with an intercept on the classified dates, and let $\bar\varepsilon_g$ be the mean
residual in regime $g$. Then
$$
m_g=\hat\alpha+\hat\beta\,m_{b,g}+\bar\varepsilon_g,
\qquad
\sum_g p_g\,\bar\varepsilon_g=0,
\qquad
\mathrm{SR}_g=\frac{\sqrt{\mathrm{AN}}\,p_g\big(\hat\alpha+\hat\beta\,m_{b,g}+\bar\varepsilon_g\big)}{s(r)},
$$
where $m_{b,g}$ is the benchmark's own conditional mean.
**Proof.** Average the fitted identity over $\mathcal{T}_g$. With an intercept, OLS residuals
sum to zero over the sample, and $\sum_g p_g\bar\varepsilon_g=T^{-1}\sum_t\hat\varepsilon_t$.
$\square$
A regime profile therefore has three parts: alpha, spread over the regimes by time;
beta, a scaled copy of the benchmark's regime means; and $\bar\varepsilon_g$, which is zero on
average and captures what a straight line misses. A negative beta produces a positive Bear
contribution with no skill in timing. Convexity, a payoff that gains in both tails, appears as
$\bar\varepsilon_{\mathrm{Bear}}>0$ and $\bar\varepsilon_{\mathrm{Bull}}>0$ with
$\bar\varepsilon_{\mathrm{Normal}}<0$.
### Sampling error of a contribution
**Proposition.** Condition on the labels, suppose the returns in regime $g$ are independent with
variance $\sigma_g^2$, treat $s(r)$ as fixed, and let the sample span $Y=T/\mathrm{AN}$ years.
Then
$$
\operatorname{se}(\mathrm{SR}_g)=\frac{\sqrt{\mathrm{AN}}\,p_g\,\sigma_g}{\sqrt{T_g}\;s(r)}=\sqrt{\frac{p_g}{Y}}\;\frac{\sigma_g}{s(r)} .
$$
**Proof.** $\operatorname{Var}(m_g)=\sigma_g^2/T_g$, and $\mathrm{SR}_g$ is $m_g$ times the
constant $\sqrt{\mathrm{AN}}\,p_g/s(r)$. Substitute $T_g=p_gT=p_g\,\mathrm{AN}\,Y$. $\square$
For a Bear regime with $p_g=0.175$ over ten years, $\sqrt{p_g/Y}=0.13$: a Bear contribution
carries a standard error of about 0.13 times the ratio of within-regime to total volatility.
For comparison, the standard error of a full-sample Sharpe ratio is about $\sqrt{1/Y}=0.32$
over ten years under IID returns ([Lo, 2002](https://doi.org/10.2469/faj.v58.n4.2453)). The
formula ignores the randomness of the labels and of $s(r)$, so it understates the uncertainty.
## Worked example
The example builds ten years of monthly returns from a fixed seed: a benchmark with a monthly
mean of 0.7% and volatility of 4.5%, and a *hedge* that returns 0.3% a month, has a beta of
$-0.30$ to the benchmark and 1.5% of idiosyncratic volatility. The classifier resamples the
month-end prices to 41 quarter-end dates, 2014-12-31 to 2024-12-31, and so 40 quarterly
returns. These are synthetic inputs, not market data.
The first block checks the classification. The 40 quarters split 7, 26 and 7, so
$p_g=(0.175,\,0.65,\,0.175)$. The benchmark's conditional quarterly means are −12.62%, 1.11%
and 15.38%; the hedge's are 4.75%, 1.41% and −3.62%. The block recomputes the quarterly
returns by compounding three monthly returns with numpy and the frequencies and means with a
direct `pd.qcut` and `groupby`.
```python
import numpy as np
import pandas as pd
import qis
rng = np.random.default_rng(20260725)
n_months = 120
bench = 0.007 + 0.045 * rng.standard_normal(n_months)
hedge = 0.003 - 0.30 * bench + 0.015 * rng.standard_normal(n_months)
monthly = np.column_stack([bench, hedge])
dates = pd.date_range('2014-12-31', periods=n_months + 1, freq='ME')
growth = np.vstack([np.ones((1, 2)), np.cumprod(1.0 + monthly, axis=0)])
prices = pd.DataFrame(100.0 * growth, index=dates, columns=['Benchmark', 'Hedge'])
classifier = qis.BenchmarkReturnsQuantilesRegime() # freq='QE', edges at 0.16 and 0.84
sampled = classifier.compute_sampled_returns_with_regime_id(prices=prices, benchmark='Benchmark')
# independent quarterly returns: compound three monthly returns
quarterly = (1.0 + monthly).reshape(40, 3, 2).prod(axis=1) - 1.0
np.testing.assert_allclose(sampled[['Benchmark', 'Hedge']].iloc[1:], quarterly, atol=1e-14)
assert pd.isna(sampled['regime'].iloc[0]) # the first grid date has no return
# p_g and m_g from qis against a direct qcut and groupby
means, freqs = qis.compute_mean_freq_regimes(sampled)
q_returns = pd.DataFrame(quarterly, index=sampled.index[1:], columns=['Benchmark', 'Hedge'])
labels = pd.qcut(q_returns['Benchmark'], q=[0.0, 0.16, 0.84, 1.0],
labels=['Bear', 'Normal', 'Bull'])
direct_freqs = labels.value_counts(normalize=True).reindex(['Bear', 'Normal', 'Bull'])
np.testing.assert_allclose(freqs, direct_freqs, atol=1e-15)
np.testing.assert_allclose(freqs, [7 / 40, 26 / 40, 7 / 40], atol=1e-15)
np.testing.assert_allclose(means, q_returns.groupby(labels, observed=False).mean(), atol=1e-15)
np.testing.assert_allclose(means['Benchmark'], [-0.1262, 0.0111, 0.1538], atol=5e-5)
np.testing.assert_allclose(means['Hedge'], [0.0475, 0.0141, -0.0362], atol=5e-5)
```
Under `SharpeConvention.ARITHMETIC` the regime table's Sharpe contributions of the hedge are
0.430 (Bear), 0.472 (Normal) and −0.327 (Bull). They add up to 0.575, the arithmetic Sharpe
ratio $2\,\bar r/s(r)$ of the 40 quarterly returns; the benchmark's add up to 0.248. The
table's `SHARPE_ARITH` column, computed on month-end returns, is 0.588 for the hedge and 0.265
for the benchmark: a different number, on a different grid. The log decomposition adds up to
the quarterly log Sharpe ratios, 0.156 and 0.542.
```python
arith = qis.PerfParams(sharpe_convention=qis.SharpeConvention.ARITHMETIC)
table, regime_datas = classifier.compute_regimes_pa_perf_table(
prices=prices, benchmark='Benchmark', perf_params=arith)
sharpe = regime_datas[qis.RegimeData.REGIME_SHARPE]
q_std = quarterly.std(axis=0, ddof=1)
sr_quarterly = np.sqrt(4.0) * quarterly.mean(axis=0) / q_std
np.testing.assert_allclose(sharpe.sum(axis=1), sr_quarterly, atol=1e-14)
np.testing.assert_allclose(sharpe, 2.0 * means.T.to_numpy() * freqs.to_numpy() / q_std[:, None],
atol=1e-14)
np.testing.assert_allclose(sr_quarterly, [0.2475, 0.5745], atol=5e-5)
np.testing.assert_allclose(sharpe.loc['Hedge'], [0.4297, 0.4719, -0.3271], atol=5e-5)
np.testing.assert_allclose(sharpe.loc['Benchmark'], [-0.4533, 0.1482, 0.5527], atol=5e-5)
# the table's SHARPE_ARITH is estimated on freq_vol='ME', not on the regime grid
sr_monthly = np.sqrt(12.0) * monthly.mean(axis=0) / monthly.std(axis=0, ddof=1)
np.testing.assert_allclose(table[qis.PerfStat.SHARPE_ARITH.to_str()], sr_monthly, atol=1e-14)
np.testing.assert_allclose(sr_monthly, [0.2655, 0.5882], atol=5e-5)
# the returns-level counterpart (internal helper) reproduces the table branch
from qis.perfstats.regime_classifier import compute_regime_sharpe_decomposition
standalone = compute_regime_sharpe_decomposition(returns=q_returns,
benchmark_returns=q_returns['Benchmark'], af=4.0)
np.testing.assert_allclose(standalone[sharpe.columns], sharpe, atol=1e-14)
# LOG: the same identity on log(1 + r)
log_params = qis.PerfParams(sharpe_convention=qis.SharpeConvention.LOG)
_, log_datas = classifier.compute_regimes_pa_perf_table(
prices=prices, benchmark='Benchmark', perf_params=log_params)
log_q = np.log1p(quarterly)
sr_log = 2.0 * log_q.mean(axis=0) / log_q.std(axis=0, ddof=1)
np.testing.assert_allclose(log_datas[qis.RegimeData.REGIME_SHARPE].sum(axis=1), sr_log,
atol=1e-14)
np.testing.assert_allclose(sr_log, [0.1565, 0.5420], atol=5e-5)
```
The hedge's regime profile is almost entirely linear. The quarterly OLS gives
$\hat\alpha=1.44\%$ a quarter and $\hat\beta=-0.276$, the table's `Beta` column. The Bear mean of
4.75% is $1.44\%$ of alpha plus $3.48\%$ of beta times the benchmark's −12.62%, less a residual
of 0.17%. Conditional on the labels, the standard errors of the three contributions are 0.117,
0.201 and 0.076: the Bear contribution of 0.43 is large relative to its error, the Normal one
of 0.47 much less so.
```python
beta, alpha = np.polyfit(quarterly[:, 0], quarterly[:, 1], deg=1)
np.testing.assert_allclose(beta, table.loc['Hedge', qis.PerfStat.BETA.to_str()], atol=1e-12)
residual_means = means['Hedge'] - (alpha + beta * means['Benchmark'])
np.testing.assert_allclose([alpha, beta], [0.0144, -0.2758], atol=5e-5)
np.testing.assert_allclose(residual_means, [-0.0017, 0.0027, -0.0082], atol=5e-5)
np.testing.assert_allclose((freqs * residual_means).sum(), 0.0, atol=1e-15)
# conditional standard errors of the contributions: sqrt(AN) p_g sigma_g / (sqrt(T_g) s(r))
within_std = q_returns['Hedge'].groupby(labels, observed=False).std()
se = 2.0 * freqs * within_std / (np.sqrt(40 * freqs) * q_returns['Hedge'].std())
np.testing.assert_allclose(se, np.sqrt(freqs / 10.0) * within_std / q_returns['Hedge'].std())
np.testing.assert_allclose(se, [0.1168, 0.2011, 0.0759], atol=5e-5)
```
Under the default per-annum convention the benchmark's per-annum return is 3.04%. The
compounded regime returns $C_g$ are −8.45%, 2.93% and 11.37%, which add up to 5.84%, so
$\Delta=-2.80\%$: −1.90% of volatility drag and −0.90% of cross-products. Allocated by $p_g$,
the patched regime returns are −8.94%, 1.11% and 10.88%; an equal split would give −9.39%,
2.00% and 10.43%. Divided by the monthly log-return volatility of 16.40%, they give per-annum
Sharpe contributions of −0.545, 0.068 and 0.663, which add up to `SHARPE_RF0`, 0.186, because
the history starts and ends on month-ends. The log decomposition of the per-annum return,
in log units, is −9.45%, 2.49% and 9.96%. The block also checks that the classifier honours
`additive_pa_returns_to_pa_total=False`, which returns the unpatched $C_g$, and that
`PerfStat.BEAR_AVG` and its analogues select the table's average columns.
```python
pa_table, pa_datas = classifier.compute_regimes_pa_perf_table(
prices=prices, benchmark='Benchmark', perf_params=qis.PerfParams()) # SharpeConvention.PA
years = (dates[-1] - dates[0]).days / 365.25
r_pa = ((prices.iloc[-1] / prices.iloc[0]) ** (1.0 / years) - 1.0).to_numpy()
np.testing.assert_allclose(pa_table[qis.PerfStat.PA_RETURN.to_str()], r_pa, atol=1e-14)
x = 4.0 * means.T.to_numpy() * freqs.to_numpy() # rows: assets, columns: regimes
c_g = np.expm1(x)
delta = r_pa - c_g.sum(axis=1)
patched = c_g + delta[:, None] * freqs.to_numpy()
np.testing.assert_allclose(pa_datas[qis.RegimeData.REGIME_PA], patched, atol=1e-14)
np.testing.assert_allclose(pa_datas[qis.RegimeData.REGIME_PA].sum(axis=1), r_pa, atol=1e-14)
np.testing.assert_allclose(c_g[0], [-0.0845, 0.0293, 0.1137], atol=5e-5)
np.testing.assert_allclose(patched[0], [-0.0894, 0.0111, 0.1088], atol=5e-5)
np.testing.assert_allclose(c_g[0] + delta[0] / 3.0, [-0.0939, 0.0200, 0.1043], atol=5e-5)
# the classifier passes the patch switch on: False returns the unpatched C_g
_, raw_datas = classifier.compute_regimes_pa_perf_table(
prices=prices, benchmark='Benchmark', perf_params=qis.PerfParams(),
additive_pa_returns_to_pa_total=False)
np.testing.assert_allclose(raw_datas[qis.RegimeData.REGIME_PA], c_g, atol=1e-14)
# the regime members select the table's columns
averages = pa_table[[stat.to_str() for stat in (qis.PerfStat.BEAR_AVG, qis.PerfStat.NORMAL_AVG,
qis.PerfStat.BULL_AVG)]]
np.testing.assert_allclose(averages, means.T, atol=1e-15)
# anatomy of the residual: volatility drag plus cross-products of the contributions
drag = r_pa - np.expm1(x.sum(axis=1))
cross = (x.sum(axis=1) ** 2 - (x ** 2).sum(axis=1)) / 2.0
np.testing.assert_allclose([delta[0], drag[0], cross[0]], [-0.0280, -0.0190, -0.0090], atol=5e-5)
np.testing.assert_allclose(delta, drag + cross, atol=2e-4)
# per-annum Sharpe contributions divide by the monthly log-return volatility
vol = np.sqrt(12.0) * np.log1p(monthly).std(axis=0, ddof=1)
np.testing.assert_allclose(pa_table[qis.PerfStat.VOL.to_str()], vol, atol=1e-14)
pa_sharpe = pa_datas[qis.RegimeData.REGIME_SHARPE]
np.testing.assert_allclose(pa_sharpe, patched / vol[:, None], atol=1e-14)
np.testing.assert_allclose(pa_sharpe.sum(axis=1), pa_table[qis.PerfStat.SHARPE_RF0.to_str()],
atol=1e-14)
np.testing.assert_allclose(pa_sharpe.loc['Benchmark'], [-0.5453, 0.0676, 0.6633], atol=5e-5)
# the log decomposition allocates the drag where it accrues
log_means_b = pd.Series(log_q[:, 0], index=q_returns.index).groupby(labels, observed=False).mean()
log_contrib = 4.0 * freqs * log_means_b
np.testing.assert_allclose(log_contrib, [-0.0945, 0.0249, 0.0996], atol=5e-5)
np.testing.assert_allclose(log_contrib.sum(), np.log1p(r_pa[0]), atol=1e-4)
log_drag = log_contrib.to_numpy() - x[0] # AN p_g (m_g^log - m_g)
np.testing.assert_allclose((log_drag[0] + log_drag[2]) / log_drag.sum(), 0.78, atol=5e-3)
```
The last block shows the two sampling caveats and checks the conditioning bias of the
interpretation section. Classifying the first five years alone relabels two of those twenty
quarters (2016-06-30 and 2018-12-31, Bull on the short sample and Normal on the full one). A
history cut to 2015-02-15 to 2024-11-15 starts and ends with one-month stubs; the first stub,
March 2015 alone, returns −8.74% and is classified Bear against full quarters. Finally, a
million draws of a bivariate normal with correlation 0.5 give a correlation of about 0.25 in the
Bear band and 0.30 in the Normal band, as the conditioning-bias proposition predicts.
```python
first_half = classifier.compute_sampled_returns_with_regime_id(
prices=prices.loc[:'2019-12-31'], benchmark='Benchmark')
short_labels = first_half['regime'].iloc[1:].astype(str)
full_labels = sampled['regime'].loc[short_labels.index].astype(str)
relabelled = short_labels.index[short_labels != full_labels]
assert list(relabelled) == [pd.Timestamp('2016-06-30'), pd.Timestamp('2018-12-31')]
stubbed = classifier.compute_sampled_returns_with_regime_id(
prices=prices.loc['2015-02-15':'2024-11-15'], benchmark='Benchmark')
assert stubbed.index[0] == pd.Timestamp('2015-02-28')
assert stubbed.index[-1] == pd.Timestamp('2024-10-31')
np.testing.assert_allclose(stubbed['Benchmark'].iloc[1], monthly[2, 0], atol=1e-14)
np.testing.assert_allclose(monthly[2, 0], -0.0874, atol=5e-5)
assert stubbed['regime'].iloc[1] == 'Bear'
# conditioning bias: constant correlation 0.5, measured within the return bands
from scipy.stats import norm, truncnorm
draws = np.random.default_rng(1999).standard_normal((1_000_000, 2))
x_b, x_a = draws[:, 0], 0.5 * draws[:, 0] + np.sqrt(0.75) * draws[:, 1]
lower, upper = np.quantile(x_b, [0.16, 0.84])
bear, normal = x_b <= lower, (x_b > lower) & (x_b <= upper)
cut = norm.ppf(0.16)
theta = np.array([truncnorm(-np.inf, cut).var(), truncnorm(cut, -cut).var()])
predicted = 0.5 / np.sqrt(0.25 + 0.75 / theta)
np.testing.assert_allclose(theta, [0.20, 0.29], atol=5e-3)
np.testing.assert_allclose(predicted, [0.25, 0.30], atol=5e-3)
simulated = [np.corrcoef(x_b[mask], x_a[mask])[0, 1] for mask in (bear, normal)]
np.testing.assert_allclose(simulated, predicted, atol=0.01)
```
The quantile rule reproduces the `pd.qcut` labels of the first block. On six returns with a
tie at the median, the median edge is 2 and all three returns equal to it fall in the lower
half. A benchmark with five zero returns and one positive return cannot fill three bands: the
16% and 84% edges are 0 and 0.2, and the error reports two occupied bands of three.
```python
from qis.utils.quantile_buckets import (EmptyQuantileBucketError, classify_quantile_buckets,
compute_bucket_codes, compute_quantile_edges)
rule = classify_quantile_buckets(q_returns['Benchmark'], q=[0.0, 0.16, 0.84, 1.0],
labels=['Bear', 'Normal', 'Bull'])
assert (rule.astype(str) == labels.astype(str)).all()
ties = np.array([1.0, 2.0, 2.0, 2.0, 3.0, 4.0])
np.testing.assert_allclose(compute_quantile_edges(ties, q=2), [2.0])
np.testing.assert_array_equal(compute_bucket_codes(ties, q=2), [0, 0, 0, 0, 1, 1])
degenerate = np.array([0.0, 0.0, 0.0, 0.0, 0.0, 1.0])
np.testing.assert_allclose(compute_quantile_edges(degenerate, q=[0.0, 0.16, 0.84, 1.0]), [0.0, 0.2])
try:
compute_bucket_codes(degenerate, q=[0.0, 0.16, 0.84, 1.0])
except EmptyQuantileBucketError as error:
assert (error.num_occupied, error.num_buckets) == (2, 3)
else:
raise AssertionError('a degenerate benchmark must be rejected')
```
## Implementation in qis
| Quantity | Formula | qis entry point |
|---|---|---|
| Return-quantile regimes | Bear, Normal, Bull at $\hat Q_b(0.16)$, $\hat Q_b(0.84)$ | `qis.BenchmarkReturnsQuantilesRegime(freq='QE', q=None)`, method `compute_sampled_returns_with_regime_id` |
| Sign regimes | Negative if $r_{b,t}<0$, else Positive | `qis.BenchmarkReturnsPositiveNegativeRegime(freq='QE')` |
| Volatility regimes | `q` equal-count buckets of within-period realised volatility | `qis.BenchmarkVolsQuantilesRegime(freq='QE', q=4)` |
| Frequencies and means | $p_g=T_g/T$, $m_g$ | `qis.compute_mean_freq_regimes` |
| Regime averages and contributions | $m_g$; $C_g=e^{x_g}-1$, or $x_g$ with `is_report_pa_returns=False` | `qis.compute_regime_avg(freq=...)` |
| Patched per-annum returns | $\tilde C_g=C_g+p_g\Delta$ | `additive_pa_returns_to_pa_total=True` (default) in the classifiers' `compute_regimes_pa_perf_table` and in `qis.compute_regimes_pa_perf_table_from_sampled_returns` |
| Benchmark display row | $m_g$ in place of $\tilde C_g$ for the benchmark, display only | `is_use_benchmark_means=True` in `qis.compute_regimes_pa_perf_table_from_sampled_returns` |
| Arithmetic and log contributions | $\mathrm{SR}_g$, $\mathrm{SR}^{\ell}_g$ | `PerfParams(sharpe_convention=SharpeConvention.ARITHMETIC)` or `LOG` |
| Per-annum contributions | $\mathrm{SR}^{\mathrm{pa}}_g=\tilde C_g/\hat\sigma_{\mathrm{ann}}$ | `PerfParams()`, `SharpeConvention.PA` |
| Regime table | all of the above plus the risk-adjusted table | `qis.RegimeClassifier.compute_regimes_pa_perf_table`, `qis.compute_bnb_regimes_pa_perf_table` |
| Returns-level contributions | $\mathrm{SR}_g$, $\mathrm{SR}^{\ell}_g$ and their total | internal `qis.perfstats.regime_classifier.compute_regime_sharpe_decomposition(returns, benchmark_returns, af)` |
| Panels | $m_g$; $\tilde C_g$; $\mathrm{SR}_g$ in the selected convention | `qis.RegimeData.REGIME_AVG`, `REGIME_PA`, `REGIME_SHARPE` |
| Table columns | $m_g$, $\tilde C_g$, $\mathrm{SR}_g$ | `PerfStat.BEAR_AVG` (`'Bear Average'`), `PerfStat.BEAR_PA`, `PerfStat.BEAR_SHARPE`, and the Normal and Bull analogues; `qis.SD_PERF_COLUMNS` carries the three Sharpe columns |
| Exhibits | stacked regime bars; boxplots; shading | `qis.plot_regime_data`, `qis.plot_regime_boxplot`, `qis.add_bnb_regime_shadows` |
| Quantile rule | edges, right-closed buckets, ties in the lower bucket | `qis.utils.quantile_buckets.classify_quantile_buckets`, `compute_bucket_codes`, `compute_quantile_edges` |
| Nulls, premia, regime betas | Gaussian null of each contribution and the convexity premium | `qis.regimes`; see [the convexity premium](convexity_premium.md) |
The classification and tables are in
[regime_classifier.py](https://github.com/ArturSepp/QuantInvestStrats/blob/main/src/qis/perfstats/regime_classifier.py),
the exhibits in
[regime_data.py](https://github.com/ArturSepp/QuantInvestStrats/blob/main/src/qis/plots/derived/regime_data.py),
and `RegimeData`, `SharpeConvention`, `PerfParams` and the regime `PerfStat` members in
[config.py](https://github.com/ArturSepp/QuantInvestStrats/blob/main/src/qis/perfstats/config.py).
API pages: {doc}`regime table `,
{doc}`quantile classifier ` and
{doc}`regime exhibit `.
Implementation contracts that affect the numbers:
- **Call path.** `compute_bnb_regimes_pa_perf_table` builds a `BenchmarkReturnsQuantilesRegime`
from `freq`, `return_type` and `q`, unless a `regime_classifier` keyword supplies one, and
returns only the table. The classifier's `compute_regimes_pa_perf_table` classifies, then
calls `compute_regimes_pa_perf_table_from_sampled_returns` and returns the table together with
a dictionary keyed by `RegimeData.REGIME_AVG`, `REGIME_PA` and `REGIME_SHARPE`.
- **Frequency and convention.** $\mathrm{AN}$ is `get_annualization_factor(freq)` of the
classifier's `freq`. The Sharpe convention is `perf_params.sharpe_convention`;
`perf_params=None` means `SharpeConvention.PA`, and the attached table then infers its
frequency from the price index.
- **Options on the classifier path.** The classifiers' `compute_regimes_pa_perf_table` accept
`additive_pa_returns_to_pa_total` (default True) and pass it on, so
`additive_pa_returns_to_pa_total=False` reports the unpatched $C_g$. The base
`RegimeClassifier.compute_regimes_pa_perf_table` also passes its remaining keywords on, among
them `is_report_pa_returns=False` for the linear contributions $x_g$. The classifiers' own
methods fix `is_use_benchmark_means=False`; that option is set on
`compute_regimes_pa_perf_table_from_sampled_returns` called directly. Earlier versions
accepted `additive_pa_returns_to_pa_total` on the classifier path but did not forward it.
- **`is_use_benchmark_means=True`** replaces the benchmark row of the P.a. columns by its
periodic conditional means $m_g$, a display choice. The regime Sharpe values are computed
before the substitution, so the benchmark's per-annum Sharpe contributions stay
$\tilde C_g/\hat\sigma_{\mathrm{ann}}$. Earlier versions divided the displayed periodic means
by the annualised volatility.
- **Missing values.** $p_g$ counts benchmark-classified dates, $m_g$ averages the asset's
observed returns and $s(r)$ uses all of them, so the table's contributions add up exactly only
when the asset is observed on the classified dates and on no others. The internal
returns-level function computes every moment per asset over the dates where both the asset
and the benchmark are observed, and is exact for any gap pattern. An empty regime has no
mean: the table and the internal function both report a missing contribution, and the plot's
totals and the function's total skip it, since an empty regime contributes nothing to the
mean.
- **Return type.** The classifier's `return_type` sets the returns behind $m_g$ and $C_g$. With
the default `ReturnTypes.RELATIVE` they are simple returns: the `ARITHMETIC` branch uses them
and the `LOG` branch takes $\log(1+r)$. With `ReturnTypes.LOG` they are log returns: the `LOG`
branch uses them as they are and the `ARITHMETIC` branch converts them with $e^{\ell}-1$, so
each convention decomposes its own Sharpe ratio whatever the classifier's basis.
- **Column labels.** The average columns are labelled `'Bear Average'`, `'Normal Average'` and
`'Bull Average'`, the labels of the `PerfStat.BEAR_AVG` family; the P.a. and Sharpe columns
match `PerfStat.BEAR_PA` and `PerfStat.BEAR_SHARPE`. Every regime member therefore selects a
column, for example in `qis.plot_ra_perf_scatter`. The labels do not carry the Sharpe
convention; state it with the table.
- **Exhibits.** `plot_regime_data` passes `prices`, `benchmark` and `perf_params` through to
the classifier and stacks the chosen `RegimeData` panel; the bar totals are the row sums.
`add_bnb_regime_shadows` shades each grid interval $(t-1,t]$ in the colour of the regime of
the period ending at $t$, which matches the return timing.
## Interpretation and limitations
### Crisis beta and risk-premia alpha
[Sepp (2019)](https://thehedgefundjournal.com/trend-following-ctas-vs-alternative-risk-premia/)
reads the regime profile as a classification of strategies. A strategy whose Bear contribution
is positive earns part of its Sharpe ratio when the benchmark falls: crisis beta, the profile
of trend-following managers. A strategy that earns its Sharpe ratio in the Normal regime and
loses in the Bear regime is paid a premium for bearing crisis risk: risk-premia alpha, the
profile of many alternative risk premia. An equity-like benchmark has a negative Bear, a small
Normal and a large Bull contribution, as in the worked example.
The beta-propagation proposition sharpens the reading. A positive Bear contribution can come
from a negative beta, which a short benchmark position delivers at the cost of a negative Bull
contribution, or from convexity, $\bar\varepsilon_{\mathrm{Bear}}>0$ together with
$\bar\varepsilon_{\mathrm{Bull}}>0$, which pays in the Bear regime without giving up the Bull
regime. The hedge
of the worked example is the first kind: its regime means are the line
$\hat\alpha+\hat\beta\,m_{b,g}$ to within 0.9% a quarter.
[The convexity premium](convexity_premium.md) turns this distinction into one number per asset.
Under a jointly Gaussian null each regime contribution is a closed form in the Sharpe ratio and
the correlation with the benchmark, and the excess of the realised Bear
contribution over its null, net of the benchmark's own departure, is the Bear-regime residual
mean in Sharpe units. The premium adds up across a portfolio with risk weights, which is how
[Sepp and Kastenholz (2026)](bibliography.md) rank overlays of a principal portfolio.
### Conditional correlations and the conditioning bias
The chapter's statistics are conditional *means*. Conditional *correlations* and betas
estimated within a regime behave differently, because selecting periods by the value of one
variable changes that variable's variance.
**Proposition (conditioning bias).** Let $(r_b,r)$ be bivariate normal with correlation $\rho$,
and let $\mathcal{A}$ be an event defined by $r_b$ alone, with variance ratio
$\theta_{\mathcal{A}}=\operatorname{Var}(r_b\mid\mathcal{A})/\operatorname{Var}(r_b)$. Then
$$
\rho_{\mathcal{A}}=\frac{\rho}{\sqrt{\rho^2+(1-\rho^2)/\theta_{\mathcal{A}}}} ,
$$
so $\lvert\rho_{\mathcal{A}}\rvert<\lvert\rho\rvert$ when $\theta_{\mathcal{A}}<1$ and
$\lvert\rho_{\mathcal{A}}\rvert>\lvert\rho\rvert$ when $\theta_{\mathcal{A}}>1$.
**Proof.** Standardise both returns and write $r=\rho r_b+\sqrt{1-\rho^2}\,u$ with $u$ standard
normal and independent of $r_b$, so conditioning on $\mathcal{A}$ leaves $u$ unchanged. Then
$\operatorname{Cov}(r_b,r\mid\mathcal{A})=\rho\,\theta_{\mathcal{A}}$ and
$\operatorname{Var}(r\mid\mathcal{A})=\rho^2\theta_{\mathcal{A}}+1-\rho^2$. Divide the
covariance by the square root of the product of the variances. $\square$
This is the pitfall of [Boyer, Gibson and Loretan (1999)](https://www.federalreserve.gov/pubs/ifdp/1997/597/ifdp597.pdf):
a constant-correlation model produces "correlation breakdowns" in subsamples selected by the
size of one return. Truncating a standard normal below $\Phi^{-1}(0.16)=-0.994$ leaves a
variance ratio of about 0.20, and the central band about 0.29. With $\rho=0.5$ the
within-regime correlations are about 0.25 in the Bear band and 0.30 in the Normal band,
although nothing has changed. Conditioning on large absolute returns, $\theta_{\mathcal{A}}>1$,
inflates correlation instead. [Longin and Solnik (2001)](https://doi.org/10.1111/0022-1082.00340)
account for the bias by comparing exceedance correlations, the correlations of returns that
jointly exceed a threshold, with their values under normality, and model the tails with extreme
value theory. Under normality the exceedance correlation goes to zero as the threshold moves
into the tails; they find that it does not for large negative returns, and that equity-market
correlation rises in bear markets but not in bull markets.
> **Insight.** Conditional means do not suffer the variance-truncation bias. Under the model of
> the proposition, $\mathbb{E}[r\mid\mathcal{A}]-\mathbb{E}[r]=\beta\,\big(\mathbb{E}[r_b\mid\mathcal{A}]-\mathbb{E}[r_b]\big)$
> for every $\mathcal{A}$, with the full-sample $\beta$. This is why a mean-based regime profile is
> interpretable with a constant beta while a within-regime beta or correlation needs the
> correction above before it can be read as a change in dependence.
### Model-based regimes
The qis regimes are labels on observed benchmark returns, not a model. A regime-switching
model instead treats the regime as a latent Markov state with its own means, volatilities and
correlations, estimated by maximum likelihood with the filter described in Hamilton (1994).
[Ang and Bekaert (2002)](https://doi.org/10.1093/rfs/15.4.1137) use such a model for
international asset allocation; they find a high-volatility, high-correlation bear regime in
which international diversification still pays. A
model gives state probabilities, persistence and transition probabilities that labels cannot;
its *filtered* probabilities use only past data and can drive a backtest, while its
*smoothed* probabilities use the full sample, like the qis labels. qis does not implement
regime-switching estimation.
### Limitations
- **Look-ahead.** Full-sample edges and contemporaneous labels make every regime statistic
descriptive. Do not feed the labels, or statistics conditioned on them, into a backtest.
- **Few observations in the tails.** Ten years of quarters leave seven Bear and seven Bull
observations. The conditional standard error is about $\sqrt{p_g/Y}$ times the ratio of
within-regime to total volatility, before accounting for label uncertainty.
- **Stubs.** An off-grid history adds a short first and last period, classified against full
quarters and annualised as quarters. Trim prices to quarter-ends for a clean quarterly
sample.
- **Conventions differ.** Arithmetic, log and per-annum regime bars are three different numbers
on the same data. In the worked example the benchmark totals are 0.248, 0.156 and 0.186. State
the convention with the exhibit: `plot_regime_data`'s default title, *Conditional Sharpe
ratio*, does not name it. No convention deducts cash.
- **Benchmark dependence.** The regime is a property of the benchmark, and a different
benchmark gives a different partition. A benchmark back-padded with constant prices adds zero
returns that crowd the Normal band, and raises `ValueError` if they collapse a quantile edge;
clip the panel to the benchmark's live window first.
## See also
- [The convexity premium and smart diversification](convexity_premium.md)
- [Sharpe ratios: conventions and inference](performance_analytics_and_sharpe.md)
- [The performance-statistic catalogue: every PerfStat column](performance_statistics.md)
- [Alpha, beta and benchmark-relative performance](benchmark_relative_performance.md)
- [Covariance, correlation and principal components](covariance_correlation_pca.md)
- [Notation and conventions](notation_and_conventions.md)
- [Reporting frequency and annualisation](frequency_convention_note.md)
- [Factsheets and reporting](factsheets_and_reporting.md)
- [Bibliography](bibliography.md)
## References
1. Sepp, A. (2019). Trend-Following CTAs vs Alternative Risk-Premia: Crisis Beta vs Risk-Premia Alpha. *The Hedge Fund Journal*. [Article](https://thehedgefundjournal.com/trend-following-ctas-vs-alternative-risk-premia/). Uses the Bear, Normal and Bull decomposition of Sharpe ratios to separate crisis beta from risk-premia alpha.
2. Sharpe, W. F. (1994). The Sharpe Ratio. *The Journal of Portfolio Management*, 21(1), 49–58. [Author's copy](https://web.stanford.edu/~wfsharpe/art/sr/SR.htm). The arithmetic Sharpe ratio of periodic returns that the additive decomposition splits.
3. Lo, A. W. (2002). The Statistics of Sharpe Ratios. *Financial Analysts Journal*, 58(4), 36–52. [DOI: 10.2469/faj.v58.n4.2453](https://doi.org/10.2469/faj.v58.n4.2453). The sampling error of a full-sample Sharpe ratio used as the comparison for regime contributions.
4. Boyer, B. H., Gibson, M. S., and Loretan, M. (1999). Pitfalls in tests for changes in correlations. Federal Reserve Board, International Finance Discussion Papers 597. [PDF](https://www.federalreserve.gov/pubs/ifdp/1997/597/ifdp597.pdf). The conditioning bias of correlations estimated in subsamples.
5. Longin, F., and Solnik, B. (2001). Extreme Correlation of International Equity Markets. *The Journal of Finance*, 56(2), 649–676. [DOI: 10.1111/0022-1082.00340](https://doi.org/10.1111/0022-1082.00340). Exceedance correlations and the asymmetry between bear and bull markets.
6. Ang, A., and Bekaert, G. (2002). International Asset Allocation with Regime Shifts. *The Review of Financial Studies*, 15(4), 1137–1187. [DOI: 10.1093/rfs/15.4.1137](https://doi.org/10.1093/rfs/15.4.1137). The regime-switching alternative to descriptive labels.
7. Hamilton, J. D. (1994). *Time Series Analysis*. Princeton University Press. The filter and smoother of Markov-switching models.
8. Sepp, A., and Kastenholz, M. (2026). The Convexity Premium of Portfolio Overlays. *Journal of Investment Management*, forthcoming. The Gaussian null of the regime contributions and the convexity premium.
9. Sepp, A. qis: Performance analytics, portfolio backtesting, risk analysis, and factsheet reporting in Python. [Software citation metadata](https://github.com/ArturSepp/QuantInvestStrats/blob/main/CITATION.cff).