I expected a Hidden Markov Model to find hidden market states I could not identify by hand. The states it found were separated by how much the price was moving. Compressing the input into those states removed the directional signal the input carried.

This is a companion to my post on order-flow imbalance, where I reproduced a well-known academic paper on how the balance of buying and selling moves prices, and then found it couldn’t be traded at a profit. The HMM was the step meant to turn that weak signal into a tradeable one. This post covers what an HMM is, how I used it, what I expected, and what the fits produced.

Why reach for an HMM at all

A Hidden Markov Model assumes that what you can measure — the balance of buying against selling, the price changes — is produced by a small number of hidden states you cannot observe directly, and that the market moves between those states over time with fixed probabilities. The state at any moment is never observed; it has to be inferred from the measurements. The trading application: if market behaviour falls into regimes — trending, choppy, calm, panicked — and a model can identify the current one, you can act differently in each.

An HMM is built to work on many correlated features at once, finding latent structure across all of them: combinations of order flow, volatility, spread, and book imbalance that recur together and are followed by distinct behaviour. My plan was to feed it a set of microstructure features and let it find regimes I could not identify by inspection.

How I used it

The setup, concretely:

  • States: three (later four). Each state produces its measurements from its own bell curve, with its own centre and its own spread.
  • Training: fitted with Baum-Welch, the standard fitting procedure for these models. It guesses the states, measures how well they explain the data, adjusts, and repeats until the answer stops moving. Training is unsupervised: there are no labels and no target variable, so the states come from the shape of the input data alone.
  • Naming the states: the model only knows “state 0, 1, 2”. Those numbers carry no meaning on their own. So after fitting, I take every one-second bar in the training data, assign it to the state it most likely belongs to, and then measure the average price move that came next after the bars in each state. Highest average → “UP,” lowest → “DOWN,” middle → “FLAT.” That same average is also how big a move I predict whenever the model says we are in that state.
  • Split by time, never shuffled. The model learns from the first 70% of the timeline and is scored on the last 30%, which it never saw while it was learning. If I had shuffled the bars into random order instead, some of the bars it learned from would come from after the bars it was tested on, so it would be learning from a future it could not have known at the time.
flowchart LR
    X["OFI features<br/>(one or many)"] -->|Baum-Welch / EM| S["3 hidden states<br/>(unlabelled)"]
    S -->|avg forward move<br/>on TRAIN| N["UP / FLAT / DOWN<br/>+ magnitude + duration"]
    N -->|causal filter<br/>on TEST| P["per-bar prediction"]
    P --> V["validate:<br/>hit-rate vs 50%"]

Test-time inference must not use future data

The direct way to ask which state each test bar was in is one line: model.predict_proba(X_test) on the whole test block at once. That call works out each bar’s state using the entire stretch of data, including the bars that came after it, so the answer for 10:00:00 depends on what happened at 10:00:05. No future prices go into the model’s inputs, and future information still gets in, through the way the states are worked out. Those numbers cannot be reproduced in live trading, and a backtest built on them measures nothing.

The correct version works out the state at each bar i using only the bars from the start of the day up to and including that one (0…i), the past and the present and nothing after. I wrote it two ways. The first is a reference implementation: for every bar, go back to the start and walk forward again to that bar. It is easy to verify and it is slow, because the work grows with the square of the number of bars (O(n²)). The second walks through the data once, carrying a running summary from each bar to the next, so the work grows only in step with the number of bars (O(n)). Every single run checks that the two versions agree to the last decimal place on the first few hundred bars. If the fast one drifts from the slow one, the run stops and I throw the output away.

For any model fitted to data that arrives over time, test-time inference must use only the information available at that bar, and the implementation should be checked against a reference version that enforces it.

The data

Identical to the order-flow project: every change to the quoted buy and sell prices for SOXL (a semiconductor fund built to move 3× as much as the index it tracks), taken from Alpaca’s feed of the consolidated US market data stream. For each of those changes I worked out whether buyers or sellers were pushing harder, then added that up into one-second bars, using regular trading hours only and treating each day on its own. The runs with a single input used a 15-day, 319k-bar window (223k of those bars for training); the runs with several inputs used the full four-year set. The target — the price move over the next 10 bars, which is the next 10 seconds — is used for two things only: naming the states and scoring the predictions. It never goes into the model’s input.

What I expected

I expected regimes that persisted and carried a direction: an “UP” state the market would sit in for a stretch of bars and that was followed by the price rising, and a “DOWN” state followed by the price falling. I also expected that adding the size of the recent price swings, the gap between the best buy and sell prices, and the imbalance of unfilled orders would let the model find regimes a one-line rule could not, and raise the weak buying-versus-selling signal to a tradeable level.

Result 1 — one input: a size-based split of buying and selling pressure

Three states, fed nothing but the balance of buying against selling in the current second, and judged on whether the price then went the way the model said over the next 10 seconds: it called the direction right 50.0% of the time, which is the base rate. The fitted states:

State typical buying minus selling average move next label chance it stays put lasts
mostly buying +85.8 +0.0173 UP 0.46 ~2 bars
roughly balanced +0.5 +0.0028 DOWN 0.67 ~3 bars
mostly selling −10.6 +0.0067 FLAT 0.67 ~3 bars

Every state’s forward move is positive. The state I labelled “DOWN” was followed by +0.0028 on average. No state was followed by a fall, so “DOWN” identifies the bars that went up the least. The model has no falling regime to report. The states also persist for about two to three seconds, a duration short enough that the transition structure adds nothing to the input itself. Given a single input, a three-state model sorts that input into three buckets by size and adds a mild tendency to stay in whichever bucket it was in a second ago. The output is a smoothed version of the current balance between buyers and sellers.

Result 2 — many inputs: the states separate on volatility

The fuller set of inputs was the balance of buying against selling over the last 60 seconds (the part that carries directional information), how much the price had swung over the same 60 seconds, and the imbalance between unfilled buy and sell orders. The fitted states are below. The input columns are measured in standard deviations from the average, so 0 is a typical value, +1 is unusually high and −1 unusually low:

State trail-60 (flow) rvol-60 (vol) forward move stays put lasts
0 +0.04 +0.91 +0.000094 0.006 ~1 bar
1 +0.04 +0.90 +0.000095 0.024 ~1 bar
2 −0.03 −0.56 +0.000019 0.997 ~320 bars

The buying-versus-selling balance, the input I wanted the model to use, is near zero in every state (±0.04). The column that separates the states is the size of the price swings: two states where the price was moving a lot and one where it was quiet. Given a directional measure and a volatility measure in the same input vector, the fit separated on the volatility measure and left the directional one flat across states.

States 0 and 1 are nearly identical and alternate on every single bar: they stay put 0.6% and 2.4% of the time. This is the EM algorithm using two Gaussians to fit the fat tails of the high-volatility cluster, and the alternation between them is an artifact of that fit, not market dynamics. The labels were assigned by ranking forward moves of +0.000094, +0.000095 and +0.000019, differences far below the noise level, so out of sample the “UP” and “DOWN” labels carried no information and their ordering inverted.

On the same test bars, the raw trailing-OFI feature had a rank correlation with the forward return of +0.106. The HMM’s posterior-weighted prediction, built from that same feature, had a correlation of −0.003. Compressing a predictive continuous variable into three volatility bins removed the directional information it carried. A four-state version scored 52% across all bars and 55% in its best state, below a one-line selective rule that trades only the extreme tail of the raw signal.

Why it happened

An unsupervised model fits the shape of the data. It puts its states wherever the data varies the most. In second-by-second market data, almost all of that variation is in how much the price is moving. Those swings are large and they last for long stretches. The directional signal is real but small: a relationship of 0.1 on that same scale accounts for about 1% of what future prices do, and the other 99% is everything else. The states therefore partition the 99% and leave the 1% unresolved. The objective the fit optimises is describing the data, and the largest structure in the data was not the structure I could trade.

The verdict

Both fits gave the same result: the dominant structure in this data is the size of the price move, not its direction. That makes the model a usable volatility-regime detector. It does not call direction, and when the input contains a small directional signal, the state assignment overwrites it with volatility. I left the saved model file behind when I moved the project on.

The result matches the rest of this research: model complexity does not improve results when the binding constraint is how much information the data contains, not how well you extract it. The signal was weak and the cost of trading it was high, and latent-state machinery changes neither. The job the HMM can do here is as a gate: flag the high-volatility regime where the raw flow signal is strongest, and let a simple rule place the trades.