A while ago I wrote up two ways to detect market regimes: hidden Markov models and clustering on Wasserstein distance. That post was research on a toy S&P 500 series. This one is what actually runs, every trading day, inside the trading system behind Ansaar.
The short version: the model turned out to be the small part. What made it safe to run without me watching was everything around it.
Three states, three features
The model is a GaussianHMM from hmmlearn with three states. I tried more. The Bayesian information criterion kept picking three, and three is also the number a human can act on.
| State | What it looks like |
|---|---|
| Bull trend | Positive returns, moderate volatility |
| Bear trend | Negative returns, elevated volatility |
| Sideways | Near-zero returns, volatility all over the place |
It sees three features, and only three:
-
ret_5d, the 5-day log return -
realized_vol_21d, the 21-day rolling standard deviation, annualised -
return_vol_ratio, the first divided by the second
I had a longer list at one point. Every feature I added made the fit look better in-sample and the labels worse out of it. Three features is enough to separate "going up calmly" from "going down violently" from "going nowhere", and that is the whole job.
The bug that scaling fixed
The first version fed those three features to the model raw. It trained fine, the states had names, and then I looked at the state statistics and the bear trend had a positive average return.
The cause was scale. return_vol_ratio moves over a much wider range than a 5-day return, and unscaled it had about 19 times the influence of ret_5d. The model was clustering on the ratio and mostly ignoring the return. The tell was March 2020: the COVID crash, the most obvious bear regime in the training window, was classified as sideways.
A StandardScaler in front of the model fixed it, and the verification is the part I keep:
- March 2020: 100% of days classified as bear trend
- 23 March 2020, the worst single day: bear trend, a -13.93% return at 70.3% volatility, with 100% state probability
from hmmlearn.hmm import GaussianHMM
from sklearn.preprocessing import StandardScaler
scaler = StandardScaler()
X = scaler.fit_transform(features[["ret_5d", "realized_vol_21d", "return_vol_ratio"]])
model = GaussianHMM(n_components=3, covariance_type="full", random_state=42)
model.fit(X)
# Keep the scaler with the model. Scoring unscaled data later is the same bug again.
If you take one thing from this post, take that: check the model against a day you already know the answer to.
This is the first part. The full post — including the rest of the working details — is on my site: Market regime detection in production: what the model actually changes
Top comments (0)