Skip to main content
Pro Feature. Requires a Pro or Ultra subscription. Get started at api.mathematicalcompany.com
What is this? Change point detection tells you when the market regime has shifted - for example, when a stable 50/50 election market suddenly jumps to 70/30 after a debate. Instead of using fixed lookback windows, BOCPD continuously estimates whether the data-generating process has changed, letting you adapt your strategy in real time.

Change Point Detection

Horizon implements Bayesian Online Change Point Detection (BOCPD) using the Adams-MacKay algorithm, entirely in Rust. The detector maintains a full run-length distribution and updates it in O(T) per observation, making it suitable for real-time trading pipelines.

Bayesian BOCPD

Full posterior over run lengths. No fixed window size or threshold tuning required.

O(T) Online Updates

Per-observation update cost is linear in the current run length. Pruning keeps it bounded.

Conjugate Prior

Normal-Inverse-Gamma conjugate prior for Gaussian observations. Exact Bayesian inference.

Pipeline Integration

Drop hz.bocpd_detector() into any pipeline. Injects change probability and run lengths into ctx.params.

BocpdDetector

The core change point detector. It maintains a posterior distribution over run lengths (how many observations since the last change point) and updates it with each new observation.

Constructor

import horizon as hz

detector = hz.BocpdDetector(
    hazard_rate=250.0,   # Expected run length between change points
    mu0=0.0,             # Prior mean
    kappa0=1.0,          # Prior precision scaling
    alpha0=1.0,          # Prior shape (Inverse-Gamma)
    beta0=1.0,           # Prior rate (Inverse-Gamma)
)
ParameterTypeDefaultDescription
hazard_ratefloat250.0Expected number of observations between change points. Higher = fewer expected changes.
mu0float0.0Prior mean for the Normal-Inverse-Gamma conjugate
kappa0float1.0Prior precision scaling. Higher = stronger prior on the mean.
alpha0float1.0Shape parameter for the Inverse-Gamma variance prior
beta0float1.0Rate parameter for the Inverse-Gamma variance prior
The hazard rate controls sensitivity. A hazard rate of 250 means the detector expects roughly one change point every 250 observations. Lower values make the detector more sensitive (more false positives); higher values make it more conservative.

update()

Process a single observation and update the run-length distribution.
result = detector.update(0.55)
ParameterTypeDescription
observationfloatNew data point (e.g., price, return, or spread value)
Returns a BocpdResult object.

BocpdResult Type

FieldTypeDescription
change_probabilityfloatPosterior probability that a change point occurred at this observation
most_likely_run_lengthintMost probable run length (observations since last change)
run_length_distributionlist[float]Full posterior over run lengths (sums to 1.0)

State Access Methods

# Full run-length posterior
dist = detector.run_length_distribution()

# Most likely run length (MAP estimate)
rl = detector.most_likely_run_length()

# Probability of a change point at the current observation
cp = detector.change_probability()

# Reset the detector to its initial state
detector.reset()
MethodReturnsDescription
run_length_distribution()list[float]Current posterior over run lengths
most_likely_run_length()intMAP estimate of the current run length
change_probability()floatP(change point) at the most recent observation
reset()NoneReset detector to prior state, clearing all history

Pipeline Integration

hz.bocpd_detector

Creates a pipeline function that runs BOCPD on each tick and injects change point statistics into ctx.params.
import horizon as hz

def regime_adaptive_quoter(ctx):
    bocpd = ctx.params.get("bocpd")
    if bocpd is None:
        return []

    change_prob = bocpd["change_probability"]
    run_length = bocpd["run_length"]

    # Widen spread after detected change points
    if change_prob > 0.5:
        spread = 0.10  # Wide spread during regime transition
    elif run_length < 20:
        spread = 0.06  # Cautious in early regime
    else:
        spread = 0.04  # Tight spread in stable regime

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

hz.run(
    name="bocpd_mm",
    markets=["election-winner"],
    feeds={"book": hz.PolymarketBook("election-winner")},
    pipeline=[
        hz.bocpd_detector(
            feed="book",
            hazard_rate=250.0,
            threshold=0.5,
        ),
        regime_adaptive_quoter,
    ],
    risk=hz.Risk(max_position=100),
)
ParameterTypeDefaultDescription
feedstrNoneFeed name to read prices from. None = first available.
hazard_ratefloat250.0Expected observations between change points
mu0float0.0Prior mean
kappa0float1.0Prior precision scaling
alpha0float1.0Inverse-Gamma shape
beta0float1.0Inverse-Gamma rate
thresholdfloat0.5Change probability threshold for flagging a change point
param_namestr"bocpd"Key in ctx.params

Injected Parameters

KeyTypeDescription
ctx.params["bocpd"]["change_probability"]floatPosterior probability of a change point
ctx.params["bocpd"]["run_length"]intMost likely run length
ctx.params["bocpd"]["is_change_point"]boolTrue if change_probability exceeds threshold
ctx.params["bocpd"]["n_changes"]intCumulative count of detected change points

Examples

Regime Change Detection

Use BOCPD to detect structural breaks in prediction market prices:
import horizon as hz

# Offline analysis
detector = hz.BocpdDetector(hazard_rate=100.0, mu0=0.0, kappa0=1.0, alpha0=1.0, beta0=0.1)

prices = [0.45, 0.46, 0.44, 0.45, 0.47, 0.70, 0.72, 0.71, 0.73, 0.74]
returns = hz.prices_to_returns(prices)

change_points = []
for i, r in enumerate(returns):
    result = detector.update(r)
    if result.change_probability > 0.5:
        change_points.append(i)
        print(f"Change point at index {i}: prob={result.change_probability:.3f}, "
              f"run_length={result.most_likely_run_length}")

print(f"Detected {len(change_points)} change points")

BOCPD with Markov Regime Detection

Combine BOCPD with HMM for robust regime classification:
import horizon as hz

def dual_regime(ctx):
    bocpd = ctx.params.get("bocpd")
    regime = ctx.params.get("regime", 0)

    if bocpd and bocpd["is_change_point"]:
        return None  # Skip trading during transitions

    spread = 0.06 if regime == 1 else 0.04
    return hz.quotes(fair=ctx.feed.price, spread=spread, size=5)

model = hz.MarkovRegimeModel(n_states=2)
model.fit(historical_returns, max_iters=100)

hz.run(
    name="dual_regime_mm",
    markets=["election-winner"],
    feeds={"book": hz.PolymarketBook("election-winner")},
    pipeline=[
        hz.bocpd_detector(feed="book", hazard_rate=200.0),
        hz.markov_regime(model=model, feed="book"),
        dual_regime,
    ],
)

Mathematical Background

BOCPD maintains a distribution over run lengths r_t (the number of observations since the last change point). At each step:
  1. Growth probability: P(r_t = r_{t-1} + 1) — the run continues
  2. Change probability: P(r_t = 0) — a new segment begins
The hazard function H(r) = 1/lambda gives the prior probability of a change point at any given time, where lambda is the hazard rate parameter.
Within each run, observations are modeled as draws from a Gaussian with unknown mean and variance. The Normal-Inverse-Gamma prior (mu0, kappa0, alpha0, beta0) is conjugate, meaning the posterior after observing data has the same parametric form with updated parameters:
  • kappa_n = kappa0 + n
  • mu_n = (kappa0 * mu0 + sum(x)) / kappa_n
  • alpha_n = alpha0 + n/2
  • beta_n = beta0 + 0.5 * (sum(x^2) - kappa_n * mu_n^2 + kappa0 * mu0^2)
The predictive distribution (Student-t) is computed analytically for each run length.
The hazard rate lambda sets the prior expected run length. For prediction markets:
  • lambda = 50-100: Sensitive to rapid shifts (news events, poll releases)
  • lambda = 200-500: Moderate sensitivity for daily regime changes
  • lambda = 1000+: Conservative, only detects major structural breaks
The detector is robust to moderate mis-specification of lambda because the full posterior is maintained.
BOCPD memory grows linearly with the number of observations (one entry per possible run length). For very long streams, the detector automatically prunes low-probability run lengths to keep memory bounded.