---
title: "Change Point Detection"
description: "Bayesian Online Change Point Detection (BOCPD) for real-time regime shifts, structural breaks, and probability resets in prediction markets."
icon: "bolt"
---

<Note>
  **Pro Feature.** Requires a Pro or Ultra subscription. [Get started at api.mathematicalcompany.com](https://api.mathematicalcompany.com)
</Note>

<Tip>
  **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.
</Tip>

# 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.

<CardGroup cols={2}>
  <Card title="Bayesian BOCPD" icon="chart-mixed">
    Full posterior over run lengths. No fixed window size or threshold tuning required.
  </Card>
  <Card title="O(T) Online Updates" icon="gauge-high">
    Per-observation update cost is linear in the current run length. Pruning keeps it bounded.
  </Card>
  <Card title="Conjugate Prior" icon="square-root-variable">
    Normal-Inverse-Gamma conjugate prior for Gaussian observations. Exact Bayesian inference.
  </Card>
  <Card title="Pipeline Integration" icon="diagram-project">
    Drop `hz.bocpd_detector()` into any pipeline. Injects change probability and run lengths into `ctx.params`.
  </Card>
</CardGroup>

---

## 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

```python
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)
)
```

| Parameter | Type | Default | Description |
|-----------|------|---------|-------------|
| `hazard_rate` | `float` | `250.0` | Expected number of observations between change points. Higher = fewer expected changes. |
| `mu0` | `float` | `0.0` | Prior mean for the Normal-Inverse-Gamma conjugate |
| `kappa0` | `float` | `1.0` | Prior precision scaling. Higher = stronger prior on the mean. |
| `alpha0` | `float` | `1.0` | Shape parameter for the Inverse-Gamma variance prior |
| `beta0` | `float` | `1.0` | Rate parameter for the Inverse-Gamma variance prior |

<Note>
  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.
</Note>

### update()

Process a single observation and update the run-length distribution.

```python
result = detector.update(0.55)
```

| Parameter | Type | Description |
|-----------|------|-------------|
| `observation` | `float` | New data point (e.g., price, return, or spread value) |

Returns a `BocpdResult` object.

### BocpdResult Type

| Field | Type | Description |
|-------|------|-------------|
| `change_probability` | `float` | Posterior probability that a change point occurred at this observation |
| `most_likely_run_length` | `int` | Most probable run length (observations since last change) |
| `run_length_distribution` | `list[float]` | Full posterior over run lengths (sums to 1.0) |

### State Access Methods

```python
# 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()
```

| Method | Returns | Description |
|--------|---------|-------------|
| `run_length_distribution()` | `list[float]` | Current posterior over run lengths |
| `most_likely_run_length()` | `int` | MAP estimate of the current run length |
| `change_probability()` | `float` | P(change point) at the most recent observation |
| `reset()` | `None` | Reset 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`.

```python
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),
)
```

| Parameter | Type | Default | Description |
|-----------|------|---------|-------------|
| `feed` | `str` | `None` | Feed name to read prices from. None = first available. |
| `hazard_rate` | `float` | `250.0` | Expected observations between change points |
| `mu0` | `float` | `0.0` | Prior mean |
| `kappa0` | `float` | `1.0` | Prior precision scaling |
| `alpha0` | `float` | `1.0` | Inverse-Gamma shape |
| `beta0` | `float` | `1.0` | Inverse-Gamma rate |
| `threshold` | `float` | `0.5` | Change probability threshold for flagging a change point |
| `param_name` | `str` | `"bocpd"` | Key in ctx.params |

### Injected Parameters

| Key | Type | Description |
|-----|------|-------------|
| `ctx.params["bocpd"]["change_probability"]` | `float` | Posterior probability of a change point |
| `ctx.params["bocpd"]["run_length"]` | `int` | Most likely run length |
| `ctx.params["bocpd"]["is_change_point"]` | `bool` | True if change_probability exceeds threshold |
| `ctx.params["bocpd"]["n_changes"]` | `int` | Cumulative count of detected change points |

---

## Examples

### Regime Change Detection

Use BOCPD to detect structural breaks in prediction market prices:

```python
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:

```python
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

<AccordionGroup>
  <Accordion title="Adams-MacKay Algorithm">
    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.
  </Accordion>
  <Accordion title="Normal-Inverse-Gamma Conjugate">
    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.
  </Accordion>
  <Accordion title="Hazard Rate Selection">
    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.
  </Accordion>
</AccordionGroup>

<Warning>
  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.
</Warning>
