Kalman
Engine
Documentation menu

Kalman stat-arb

Long-only pairs rotation between SOL and JitoSOL, driven by a Kalman filter's standardized innovation. The hedge relationship is re-estimated every bar rather than fitted once.

Intent

Two assets that share an economic anchor should track each other. When they drift apart, the classical trade is to go long the cheap one and short the rich one, and collect the convergence. Solana spot has no shorting, so this strategy trades the same signal a different way: it holds both legs in equal dollars by default, and when the relationship stretches it rotates the whole allocation into whichever leg is cheap, returning to the even split when the stretch resolves.

The pair is SOL against JitoSOL, a liquid staking token whose value is a claim on staked SOL plus accrued rewards. The link between them is mechanical rather than statistical, which is what makes it worth trading — and the reward accrual means the relationship is not constant, which is what makes a filter the right tool instead of a fixed regression.

Model

State space

Work in logs. Let xt=lnPtxx_t = \ln P^{x}_t be the SOL leg and yt=lnPtyy_t = \ln P^{y}_t the JitoSOL leg, both USD-denominated. The hedge ratio and intercept are treated not as constants to be estimated once but as an unobserved state following a random walk:

wt=[βtαt]=wt1+ηt,ηtN(0,Q)w_t = \begin{bmatrix} \beta_t \\ \alpha_t \end{bmatrix} = w_{t-1} + \eta_t, \qquad \eta_t \sim \mathcal{N}(0, Q)
yt=Htwt+εt,Ht=[xt1],εtN(0,R)y_t = H_t\,w_t + \varepsilon_t, \qquad H_t = \begin{bmatrix} x_t & 1 \end{bmatrix}, \qquad \varepsilon_t \sim \mathcal{N}(0, R)

The process and observation noise are both scalars in the shipped configuration. Process noise is parameterised the standard way, by a single "forgetting" constant δ\delta:

Q=δ1δI2    1.0001×104I2,R=103Q = \frac{\delta}{1-\delta}\,I_2 \;\approx\; 1.0001 \times 10^{-4}\, I_2, \qquad R = 10^{-3}

with the filter initialised uninformatively at w0=[00] ⁣w_0 = \begin{bmatrix} 0 & 0 \end{bmatrix}^{\!\top} and P0=I2P_0 = I_2.

Filter recursion

Every bar, in this order. The state transition is the identity — a random walk has no drift term — so the prediction step touches only the covariance:

predict:wˉt=wt1,Pˉt=Pt1+Q\textbf{predict:}\quad \bar{w}_t = w_{t-1}, \qquad \bar{P}_t = P_{t-1} + Q
innovation:et=ytHtwt1\textbf{innovation:}\quad e_t = y_t - H_t\,w_{t-1}
innovation variance:Ft=HtPˉtHt+R\textbf{innovation variance:}\quad F_t = H_t\,\bar{P}_t\,H_t^{\top} + R
gain:Kt=PˉtHtFt\textbf{gain:}\quad K_t = \frac{\bar{P}_t\,H_t^{\top}}{F_t}
update:wt=wt1+Ktet,Pt=PˉtKtHtPˉt\textbf{update:}\quad w_t = w_{t-1} + K_t\,e_t, \qquad P_t = \bar{P}_t - K_t H_t \bar{P}_t

The signal

The traded quantity is the innovation standardised by its own predicted standard deviation:

zt  =  etFtz_t \;=\; \frac{e_t}{\sqrt{F_t}}

Read it as: how surprising is today's JitoSOL price, given the relationship the filter currently believes in and given how uncertain it is about that belief. zt>0z_t > 0 means JitoSOL came in above prediction — rich — so the rotation goes into SOL. zt<0z_t < 0 means the reverse.

This is not a z-score of a fixed spread, and the difference matters. Because β\beta and α\alpha update every bar, a divergence that persists is gradually absorbed into the state and stops registering as surprise. The filter measures deviation from a relationship it is continuously re-learning. δ\delta is the dial: larger forgets faster and chases noise, smaller holds the old relationship longer and calls more of a move a signal.

The filter is due to R. E. Kalman, "A New Approach to Linear Filtering and Prediction Problems", J. Basic Eng. 82(1), 1960. Pair qualification uses Engle & Granger (1987) cointegration and an Ornstein–Uhlenbeck half-life; the pair explorer publishes both for every candidate pair, including the ones that failed.

Signal rules

The strategy holds one of three states. Entries fire only from balanced: there is no direct flip from one leg to the other, so a signal that swings straight through zero must pass back through the even split first.

StateConditionTransition
initFirst bar after warm-up→ balanced (50/50)
anyzt>4.0|z_t| > 4.0→ balanced, then freeze for a further 300 bars
balancedzt>2.0z_t > 2.0→ x: JitoSOL rich, hold SOL outright
balancedzt<2.0z_t < -2.0→ y: JitoSOL cheap, hold JitoSOL outright
x or yzt<0.5|z_t| < 0.5→ balanced: the stretch has resolved

Target holdings for each state, at the current prices:

balanced:    qx=C2Px,  qy=C2Pyx:    qx=CPx,  qy=0y:    qx=0,  qy=CPy\text{balanced:}\;\; q_x = \frac{C}{2P^{x}},\; q_y = \frac{C}{2P^{y}} \qquad \text{x:}\;\; q_x = \frac{C}{P^{x}},\; q_y = 0 \qquad \text{y:}\;\; q_x = 0,\; q_y = \frac{C}{P^{y}}

A transition is executed as a rebalance to those targets, with sells emitted before buys so the sale funds the purchase. The strategy is fully invested at all times: it never holds cash as a position, only as the momentary gap between one leg settling and the other filling.

Warm-up and freeze

For the first 300 bars the filter updates on every observation but emits nothing — the covariance starts at the identity, which is a statement of near-total ignorance, and the first several hundred innovations are dominated by that rather than by anything about the pair. A stop-level reading restarts the same 300-bar wait, on the theory that a four-sigma innovation means the relationship the filter believes in is wrong, and the correct response is to hold the neutral split and re-learn rather than to trade the deviation.

Those counters are deliberately not restored when a bot restarts. The filter is rebuilt from scratch, so the bar count is rebuilt from scratch too — a restored count against a factory-fresh covariance would let the strategy trade on an unwarmed filter, which is the specific mistake the warm-up exists to prevent. A restart costs a full 300 bars of sitting out. Note also that the count is in bars the strategy actually receives: a bar withheld for staleness is never delivered and never counted.

Parameters

ParameterLive valueMeaning
capital_usd10Allocation; fully deployed across the two legs.
entry_z2.0Rotation threshold. Lower trades more and pays more cost.
exit_z0.5Band for returning to the balanced 50/50 holding.
stop_z4.0Relationship-broken guard; flattens to balanced and freezes.
warmup300Bars before the first order, and the freeze length.
delta1e-4Process noise (δ). Higher adapts faster and tracks noise.
r1e-3Observation noise (R).
max_order_pct60Order-size cap: 60% of $10 = $6; a rotation leg is about $5.

Risk behaviour

  • Always long, never short. Every state is a long position in one or both legs. There is no leverage, no borrow, and no way for the strategy to owe anything.
  • The stop is a relationship stop, not a price stop. Nothing here closes on a loss in dollars. The four-sigma guard fires on the statistics of the pair, and its effect is to return to neutral, not to go to cash.
  • Sells before buys, with deferral. A rotation is fully invested by definition, so the buy leg is unaffordable until the sell settles. The runner defers such a buy and re-gates it when the sell fills rather than clipping it to whatever cash happens to be free. If the sell has not filled within two bar closes the buy is dropped and the event recorded.
  • Position truth is the ledger. The strategy jumps its own belief to the target at rotation time; the runner reconciles that belief against fills actually recorded and corrects it when they disagree.
  • Stale prices. Both legs must arrive as an aligned pair before a bar reaches the strategy — a half-populated pair is never delivered — and a bar too old at receipt is withheld entirely. The bound is 90 seconds past the close, but a source that is polled rather than streamed has its structural lag subtracted first: the JitoSOL leg comes from a polled pool feed that legitimately serves a candle up to a minute after it closed, so a full minute is allowed for before the clock starts and the effective bound on that leg is about 150 seconds. A healthy polled feed should not look stale merely for being polled.

Failure modes

It is not market neutral. This is the most important thing on the page. Every state is fully long two assets that are both, in substance, SOL. Rotation changes which one, never how much SOL exposure the bot carries. A 30% drawdown in SOL is a roughly 30% drawdown here regardless of how well the spread signal performs. The strategy is a small relative-value overlay on a large directional position, and its published equity curve should be read that way.

Rotation costs two fills. Every signal is a sell and a buy, both paying fee and slippage, on a pair whose entire edge is a few tens of basis points of relative mispricing. A zz that oscillates around the entry threshold bleeds. The 0.5 exit band exists to stop the strategy round-tripping on noise, and it is the parameter most likely to need widening rather than tightening.

The filter can learn away a real break. Adaptivity cuts both ways. JitoSOL structurally appreciates against SOL as staking rewards accrue, and the filter absorbing that drift into α\alpha and β\beta is exactly right. But a genuine loss of peg — a slow, sustained divergence for a bad reason — has the same shape as accrual, and the filter will happily learn that too, quietly re-baselining instead of signalling. The four-sigma guard catches sudden breaks. It does not catch slow ones.

Thin evidence on the LST leg. The JitoSOL minute history available to us caps at roughly 75 days, so this strategy's backtest covers a materially shorter window than the others. Less evidence, said plainly rather than buried.

Restart cost. Every restart means 300 bars of not trading, and a crash in the middle of a rotation can leave one leg in cash until the next state transition. Reconciliation makes the accounting correct; it does not make the position what it was supposed to be.

What would make us turn it off

A cointegration p-value on the trailing window above 0.05 for a sustained period; a half-life extending beyond the holding horizon; or repeated four-sigma freezes, which say the pair has stopped mean-reverting rather than merely stretched. Any of the three kills the pair publicly on the pair explorer, with the reason recorded.