> ## Documentation Index
> Fetch the complete documentation index at: https://mathematicalcompany.mintlify.site/llms.txt
> Use this file to discover all available pages before exploring further.

# Change Point Detection

> Bayesian Online Change Point Detection (BOCPD) for real-time regime shifts, structural breaks, and probability resets in prediction markets.

<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 theme={null}
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 theme={null}
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 theme={null}
# 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 theme={null}
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 theme={null}
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 theme={null}
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>
