Skip to main content
Pro Feature. Requires a Pro or Ultra subscription. Get started at api.mathematicalcompany.com

Markov Regime Detection

Horizon includes a Hidden Markov Model (HMM) with Gaussian emissions, implemented entirely in Rust. It classifies the current market into regimes (e.g., calm vs. volatile) in real time, with a per-tick cost of O(N^2) where N is the number of states (typically 2-3).
Regime detection lets your strategy adapt its behavior to market conditions. Widen spreads in volatile regimes, reduce size in crisis regimes, or disable quoting entirely when the model signals a regime change.

Overview

Rust HMM

Full Baum-Welch EM training, Viterbi decoding, forward-backward smoothing. All in Rust with zero Python overhead.

O(N^2) Online Filter

Per-tick forward filter costs ~9 multiplies for 3 states. Effectively zero latency added to your pipeline.

Auto-Train Mode

No pre-trained model? Collect prices during warmup and train inline. Works in both live and backtest.

Pipeline Integration

Drop hz.markov_regime() into any pipeline. Injects regime info into ctx.params for downstream use.

Quick Start

Pre-Trained Model

Train offline on historical returns, then use in live trading:
import horizon as hz

# 1. Train on historical log returns
model = hz.MarkovRegimeModel(n_states=2)
model.fit(historical_returns, max_iters=100)

# 2. Use in pipeline
hz.run(
    name="regime_mm",
    markets=["election-winner"],
    feeds={"book": hz.PolymarketBook("election-winner")},
    pipeline=[
        hz.markov_regime(model=model, feed="book"),
        my_strategy,  # ctx.params["regime"] = 0 (calm) or 1 (volatile)
    ],
)

Auto-Train Mode

No historical data? Train inline during warmup:
hz.run(
    pipeline=[
        hz.markov_regime(n_states=2, warmup=200, feed="book"),
        my_strategy,
    ],
    ...
)
The model collects 200 price ticks, computes log returns, trains the HMM, then starts classifying. Before training completes, ctx.params["regime"] is not set.

MarkovRegimeModel (Rust)

The core HMM class. Use this directly for offline analysis, or pass it to hz.markov_regime() for pipeline use.

Constructor

model = hz.MarkovRegimeModel(n_states=2)
ParameterTypeDefaultDescription
n_statesintrequiredNumber of hidden states (2-8)

fit()

Train the model using Baum-Welch EM on a series of observations (log returns).
log_likelihood = model.fit(data, max_iters=100, tol=1e-6)
ParameterTypeDefaultDescription
datalist[float]requiredObservation sequence (log returns). Min 10 observations.
max_itersint100Maximum EM iterations
tolfloat1e-6Convergence tolerance on log-likelihood
Returns the final log-likelihood. States are automatically sorted by variance after training (state 0 = lowest variance = calmest regime).

decode()

Find the most likely state sequence using the Viterbi algorithm.
states = model.decode(data)
# [0, 0, 0, 1, 1, 1, 0, 0, ...]
Returns a list of state indices (0 to n_states-1) for each observation.

filter_step()

Online forward filter. Process one observation and update state probabilities. This is the hot path for live trading.
probs = model.filter_step(observation)
# [0.85, 0.15] -> 85% probability of state 0
Returns a list of state probabilities (sums to 1.0).

predict()

One-step-ahead prediction: given the current filtered state, what are the probabilities for the next time step?
next_probs = model.predict()
# [0.78, 0.22]

smooth()

Full forward-backward smoothing on a batch of observations. More accurate than filtering alone.
smoothed = model.smooth(data)
# [[0.9, 0.1], [0.85, 0.15], ...]  # one row per observation

Other Methods

MethodReturnsDescription
transition_matrix()list[list[float]]N x N state transition probabilities
emission_params()list[tuple[float, float]](mean, variance) for each state’s Gaussian
current_regime()intMost likely current state from the filter
filtered_probs()list[float]Current filtered state probabilities
reset_filter()NoneReset filter to initial state (for re-processing)
n_states()intNumber of states
is_trained()boolWhether fit() has been called

hz.prices_to_returns

Convenience function to convert a price series to log returns.
prices = [100.0, 101.0, 99.0, 102.0]
returns = hz.prices_to_returns(prices)
# [0.00995, -0.02005, 0.02985]
Handles zero prices gracefully (returns 0.0 for that period). Requires at least 2 prices.

hz.markov_regime() Pipeline Factory

Creates a pipeline function that classifies the current regime on each tick.
fn = hz.markov_regime(
    model=None,           # Pre-trained model (or None for auto-train)
    n_states=2,           # States for auto-train (ignored if model provided)
    warmup=100,           # Ticks before auto-training
    feed="book",          # Feed name to read prices from
    param_name="regime",  # Key in ctx.params
)
ParameterTypeDefaultDescription
modelMarkovRegimeModelNonePre-trained model. If None, auto-trains after warmup.
n_statesint2Number of states (only used if model is None)
warmupint100Ticks to collect before auto-training
feedstrNoneFeed name to read price from. None = first available.
param_namestr"regime"Key prefix in ctx.params

Injected Parameters

After training and sufficient data, the function injects:
KeyTypeDescription
ctx.params["regime"]intMost likely state (0 = calm, highest = volatile)
ctx.params["regime_probs"]list[float]State probabilities
ctx.params["regime_vol_state"]floatP(highest-volatility state)

Examples

Regime-Adaptive Market Maker

Widen spreads in volatile regimes:
import horizon as hz

# Train on historical data
model = hz.MarkovRegimeModel(n_states=2)
model.fit(historical_returns, max_iters=100)

def adaptive_quoter(ctx):
    regime = ctx.params.get("regime", 0)
    vol_prob = ctx.params.get("regime_vol_state", 0.0)

    # Base spread widens with volatility
    base_spread = 0.04
    spread = base_spread + vol_prob * 0.06  # 4-10 cents

    # Reduce size in volatile regime
    size = 10.0 if regime == 0 else 3.0

    fair = ctx.feed.price
    return hz.quotes(fair=fair, spread=spread, size=size)

hz.run(
    name="regime_mm",
    markets=["election-winner"],
    feeds={"book": hz.PolymarketBook("election-winner")},
    pipeline=[
        hz.markov_regime(model=model, feed="book"),
        adaptive_quoter,
    ],
    risk=hz.Risk(max_position=100),
)

Regime-Gated Trading

Only trade in calm regimes:
def regime_gate(ctx):
    regime = ctx.params.get("regime", 0)
    if regime != 0:
        return None  # Skip quoting in volatile regime
    return True  # Pass through to next pipeline stage

hz.run(
    pipeline=[
        hz.markov_regime(n_states=3, warmup=200, feed="book"),
        regime_gate,
        my_quoter,
    ],
    ...
)

Backtest with Regime Detection

import horizon as hz

# Train model on historical returns
model = hz.MarkovRegimeModel(n_states=2)
model.fit(historical_returns, max_iters=100)

def regime_quoter(ctx):
    regime = ctx.params.get("regime", 0)
    spread = 0.06 if regime == 1 else 0.04
    return hz.quotes(fair=ctx.feed.price, spread=spread, size=5)

result = hz.backtest(
    data=tick_data,
    pipeline=[hz.markov_regime(model=model), regime_quoter],
)
print(result.summary())

Offline Analysis

Use the HMM directly for research without a pipeline:
import horizon as hz

# Fit model
model = hz.MarkovRegimeModel(n_states=3)
model.fit(returns, max_iters=200)

# Decode most likely state sequence
states = model.decode(returns)
print(f"Regime sequence: {states[:20]}")

# Inspect learned parameters
for i, (mean, var) in enumerate(model.emission_params()):
    print(f"State {i}: mean={mean:.6f}, std={var**0.5:.6f}")

# Transition matrix
tm = model.transition_matrix()
for row in tm:
    print([f"{p:.3f}" for p in row])

# Smooth for full posterior
smoothed = model.smooth(returns)
# smoothed[t] = [P(state_0|all_data), P(state_1|all_data), ...]

Mathematical Background

An HMM models a system that transitions between N hidden states. At each time step:
  1. The system transitions from state i to state j with probability A[i][j] (transition matrix)
  2. In state j, it emits an observation from a Gaussian distribution N(mu_j, sigma^2_j)
The model parameters are: initial state probabilities (pi), transition matrix (A), and emission parameters (mu, sigma^2) for each state.
The Baum-Welch algorithm (a special case of EM) iteratively:
  1. E-step: Run forward-backward to compute state occupation probabilities given current parameters
  2. M-step: Re-estimate parameters (A, mu, sigma^2) from the occupation probabilities
Converges to a local maximum of the observation likelihood. Horizon uses quantile-based initialization to improve convergence.
The Viterbi algorithm finds the single most likely state sequence using dynamic programming. Runs in O(T * N^2) time where T is the sequence length and N is the number of states.
The forward filter recursively computes P(state_t | observations_1..t):
alpha_t(j) = sum_i [alpha_{t-1}(i) * A[i][j]] * B(j, o_t)
Then normalize: P(state_t = j) = alpha_t(j) / sum(alpha_t). This is O(N^2) per time step. For N=3 (typical), that’s 9 multiplies plus normalization.
HMMs assume stationary dynamics. If market regimes shift structurally (e.g., new regulation), retrain the model periodically. Use the auto-train mode with a reasonable warmup for adaptive behavior.