Regime detection
A strategy is rarely good or bad in the abstract — it is good in one market regimeand bad in another. Trend-following prints money in a persistent bull and bleeds in a choppy range; a mean-reversion pair reverts in calm and gets run over in a crisis. If you could reliably know which regime you are in, you could switch models on and off, or scale risk up and down, and let each edge trade only where it works. This chapter is the statistical machinery for asking “what regime is this?” — Markov chains, transition matrices, and a two-state hidden Markov model — and, just as importantly, the ways that question quietly cheats.
Markets switch regimes#
The observable symptom of a regime is a shift in the statistics of returns, not the returns themselves. Two regimes worth separating recur everywhere:
- Calm vs volatile. The clearest and most robust split. Volatility clusters (you saw this with GARCH), so the market spends stretches in a low-variance state and stretches in a high-variance state, with sharp transitions between them. Risk sizing lives or dies on getting this right.
- Trending vs choppy. A directional-persistence split: in a trend, moves autocorrelate and momentum pays; in a range, moves reverse and fading pays. Harder to detect cleanly than the vol split, because the difference is in the sign structure, not the magnitude.
The modelling assumption that makes this tractable: the regime is a hidden discrete state that you cannot observe directly, but which changes the distribution of the returns you can observe. Estimate the state from the observations and you have a regime detector.

Markov chains and transition matrices#
The mathematical backbone is a Markov chain: a system that hops between a finite set of states, where the probability of the next state depends only on the current state — not the whole history. That memoryless property is the Markov assumption:
All the dynamics live in the transition matrix , whose entry is the probability of moving from state to state . Each row sums to 1. For a two-state calm/volatile model it is just:
The diagonal entries are the persistence of each regime. A calm state with stays calm 97% of bars, so its expected duration is bars. High diagonal values are what make regimes sticky — long runs rather than bar-to-bar flip-flopping — which is exactly the realistic behaviour you want a detector to reproduce.
The two-state Gaussian HMM#
A hidden Markov modelmakes the state unobserved and ties each state to a distribution over the observations. In the Gaussian two-state version, each bar's return is drawn from one of two normal distributions — one for each regime — and which one is chosen follows the Markov chain above:
So the model has three sets of parameters: the two state means , the two state variances , and the transition matrix . Fit to returns, it typically discovers one low-variance state and one high-variance state — the calm/volatile split — without ever being told the labels.
Baum-Welch: fitting without labels#
You never observe the states, so you cannot fit by counting. The Baum-Welch algorithm (the expectation-maximisation instance for HMMs) solves the chicken-and-egg problem by iterating:
- E-step (forward-backward). Given the current parameters, compute the posterior probability that each bar was in each state — the responsibilities .
- M-step. Given those soft state assignments, re-estimate the means, variances, and transition matrix as probability-weighted averages.
Iterate to convergence and the parameters settle on a local maximum of the likelihood. edgekit's hmm_two_state implements this in pure numpy with scaled forward-backward for numerical stability, initialises the two means at the low and high quantiles of the data (so state 0 = low, state 1 = high for identifiability), and uses sticky initial transitions so it prefers long runs over rapid flipping:
import edgekit as ek
rets = ek.timeseries.log_returns(bars.close)
h = ek.timeseries.hmm_two_state(rets, iters=100, seed=0)
# h == {"states": ndarray of 0/1, # the argmax-posterior regime path
# "means": [mu0, mu1], # state means (state 0 low, state 1 high)
# "vars": [var0, var1], # state variances
# "trans": 2x2 row-stochastic transition matrix A,
# "prob": ndarray} # posterior P(state 1) per bar, in [0,1]
print("regime means:", h["means"])
print("persistence (diagonal):", h["trans"][0][0], h["trans"][1][1])The prob array — the posterior probability of being in the high state at each bar — is more useful than the hard states path, because it is a continuous confidence you can threshold or size against rather than a brittle 0/1 flip.

hmm_two_stateto daily S&P 500 returns and, without ever being told the labels, it discovers two states: state 0 with (calm, gently drifting up) and state 1 with (volatile, negative-drift — the crash signature). Through most of the sample h["prob"] — the posterior probability of the high-variance state — sits near 0.05. Then, in one month, it ramps over three or four days from 0.1 to above 0.9 and stays pinned there for ~15 bars before decaying back. That elevated stretch is the volatile month, flagged bar by bar from the returns alone. Note the ramp: the continuous prob starts warning you during the transition, which is exactly the early signal you want — far more useful than the hard 0/1 states flip that only commits after the fact.Using the regime#
Once you have a regime label or probability, there are two clean ways to spend it, both risk overlays on an already-validated edge:
- Gate the strategy. Turn a model on only in the regime where it has an edge — e.g. allow a mean-reversion pair to trade only when , and stand aside in the high-vol state where reversion breaks down.
- Scale the risk.Keep trading in both regimes but shrink size when the volatile-state probability is high. Because the HMM's high state is the high-variance state, this is a discrete cousin of the continuous volatility targeting from the GARCH chapter.
# gate a signal by the inferred regime (posterior of the high-vol state)
h = ek.timeseries.hmm_two_state(rets, iters=100, seed=0)
p_high = h["prob"] # P(volatile) per bar
# inside a BaseStrategy: only take entries when the calm-regime probability is high
def regime_ok(t):
return p_high[t] < 0.5 # stand aside in the volatile regime
# or scale exposure continuously: size_multiplier = 1 - p_high[t]p_high ramped above 0.5 heading into that ugly autumn week, regime_ok would have returned False and stood the pair down — sitting out precisely the trending stretch that ran it over. The continuous alternative, , is gentler: it does not slam the book shut, it fades exposure toward zero as conviction that the regime has turned rises. Either way the regime label is spent as a risk overlay on an edge you already validated — never as the edge itself.Change-point detection: the other framing#
An HMM answers “which of these recurring states am I in?” A complementary question is “when did the statistics break?” — change-point detection. Instead of a fixed set of states with return transitions, you scan for the moment the mean or variance of the series shifts, by comparing the likelihood of “one distribution throughout” against “a distribution that switches at time .” The two views are close cousins: a change point is a transition in a model with non-recurring states. Change-point framing is the natural tool when regimes do not recur — a one-way structural break like a market microstructure change — whereas the HMM is right when calm and volatile keep alternating.
The two ways regime models lie to you#
Regime detection is uniquely seductive and uniquely dangerous, for two reasons that both amount to smuggling the future into the past.
Next: Risk management — regimes tell you when risk is elevated; this final chapter of the part turns that into concrete controls: Value-at-Risk and its critiques, Expected Shortfall as a coherent tail measure, drawdown and the ulcer index, risk budgeting, and sizing to a risk target.

