Statistical Arbitrage
A more rigorous version of Spread Convergence. Uses OLS hedge ratio, ADF stationarity testing, and half-life filtering to identify truly cointegrated pairs.
How It Works
- Collect price histories for both markets
- Compute OLS hedge ratio:
beta = cov(a,b) / var(b)
- Compute residuals:
r = a - beta * b
- Run ADF test on residuals (more negative = more stationary)
- Check
signal_half_life() on residuals (reject if outside bounds)
- Compute z-score of current residual
- Generate entry/exit/stop signals
Rust Functions
cointegration_test
ratio, residuals, adf = hz.cointegration_test(prices_a, prices_b)
# ratio: OLS hedge ratio
# residuals: a - ratio * b
# adf: ADF statistic (more negative = more stationary)
spread_zscore
z = hz.spread_zscore(residuals, lookback=50)
# Z-score of the last residual over a rolling window
StatArbConfig
config = hz.StatArbConfig(
pair=("btc-100k", "eth-5k"),
feeds=("btc_feed", "eth_feed"),
lookback=200,
entry_zscore=2.0,
exit_zscore=0.5,
stop_zscore=4.0,
recalibrate_every=50,
min_half_life=5.0,
max_half_life=200.0,
)
| Parameter | Type | Default | Description |
|---|
pair | tuple[str, str] | required | Market IDs |
feeds | tuple[str, str] | required | Feed names |
lookback | int | 200 | Price history window |
entry_zscore | float | 2.0 | Entry threshold |
exit_zscore | float | 0.5 | Exit threshold |
stop_zscore | float | 4.0 | Stop-loss threshold |
recalibrate_every | int | 50 | Ticks between recalibration |
min_half_life | float | 5.0 | Min half-life (reject if faster) |
max_half_life | float | 200.0 | Max half-life (reject if slower) |
Pipeline: stat_arb
scanner = hz.stat_arb(
config=hz.StatArbConfig(
pair=("btc-100k", "eth-5k"),
feeds=("btc_feed", "eth_feed"),
),
size=10.0,
auto_execute=False,
cooldown=30.0,
)
hz.run(pipeline=[scanner], ...)
Stores StatArbResult in ctx.params["last_stat_arb"].
StatArbResult
| Field | Type | Description |
|---|
pair | tuple[str, str] | Market pair |
hedge_ratio | float | OLS hedge ratio |
zscore | float | Current residual z-score |
half_life | float | Mean-reversion half-life |
adf_stat | float | ADF test statistic |
signal | str | "long_a_short_b", "long_b_short_a", "exit", "stop", or "hold" |
Signal Logic
| Condition | Position | Signal | | |
|---|
z > entry_zscore | flat | long_b_short_a | | |
z < -entry_zscore | flat | long_a_short_b | | |
| ` | z | < exit_zscore` | positioned | exit |
| ` | z | > stop_zscore` | positioned | stop |
| otherwise | any | hold | | |
Example
import horizon as hz
config = hz.StatArbConfig(
pair=("gop-senate", "gop-house"),
feeds=("gop_senate_feed", "gop_house_feed"),
lookback=300,
entry_zscore=2.5,
recalibrate_every=100,
min_half_life=10.0,
max_half_life=150.0,
)
scanner = hz.stat_arb(config, size=5.0, auto_execute=True, cooldown=60.0)
hz.run(
name="stat_arb_pairs",
exchanges=[hz.Polymarket(private_key="0x...")],
markets=["gop-senate", "gop-house"],
feeds={
"gop_senate_feed": hz.PolymarketBook("gop-senate"),
"gop_house_feed": hz.PolymarketBook("gop-house"),
},
pipeline=[scanner],
interval=1.0,
)
ADF critical values (n > 100): 1% = -3.43, 5% = -2.862, 10% = -2.567. More negative values indicate stronger stationarity evidence.