Trading strategies

Statistical arbitrage with Kalman-based cross-pricing

How we estimate separate bid and ask price relationships across a cross rate with a scalar Kalman trend, update them incrementally and turn them into maker quotes.

When you quote passively on one market, the useful price signal often comes from another. A BTC/EUR book might track BTC/USDT perpetual futures through the EUR/USDT rate, but never exactly. The relationship drifts, so a quote built on a fixed offset slowly goes stale.

At 3Jane, market making and arbitrage on Binance are central to our trading, so we needed a way to follow that drift. Our answer is a cross-pricing model: a scalar Kalman trend of the price ratio, estimated separately for bid and ask, feeding a quoting strategy that translates fresh prices on every event. This article follows that model from research to quotes.

The relationship we estimate

The model uses three markets, each with its own bid and ask history:

  • Maker: the venue where we place passive quotes.
  • Cross: the conversion rate that brings the maker price into the taker's units.
  • Taker: the reference market we price against.

For each side, we track ratio = maker × cross / taker − 1. If converted maker prices sat exactly in line with the taker, the ratio would be zero. In practice it isn't zero and it doesn't stay put, so the model's job is to estimate where the relationship currently sits.

Bid and ask each get their own trend instead of sharing a mid-price estimate. A shift in one side's relationship can show up in that side's quotes without being averaged into the other. The rest of the pipeline keeps the two sides separate until each becomes a quote.

Batch research: building the trend

Our research notebook computes the model in batch, processing full price histories at once.

Aligning prices on a common grid

The notebook aligns the maker, cross and taker bid/ask histories on a one-second grid and forward-fills gaps. Every ratio needs all three markets at the same timestamp. Forward filling carries a market's last known quote through any second in which it didn't update.

It then converts maker prices into the taker's denomination and computes separate bid and ask ratios against the taker. The result is two time series. Each one measures how far the converted maker side sits from the reference market.

A scalar Kalman trend per side

We estimate each ratio's trend with a scalar Kalman filter used as a moving average. Each step predicts the state and its covariance, then updates both with the latest ratio. The Kalman gain decides how far one new observation moves the trend, based on the prediction's uncertainty relative to the observation noise.

Subtracting the trend leaves a detrended ratio. A second filter smooths the squared detrended ratios. The square root of that smoothed value gives a volatility estimate: a measure of how far the ratio typically strays from its trend.

From trend back to maker prices

Fair maker prices reverse the normalization. We adjust the taker price by the trend, (1 + ratio_trend) × taker, then translate the result back through the cross rate into maker units.

Bid and ask bands sit around those fair prices, each with its own configured spread. The bid band sits below fair bid and the ask band above fair ask. The bands, rather than the fair prices themselves, are the model's quote references.

What the recorded experiment showed

The same notebook also screens whether a pairing can cover its trading costs.

The pairing on record

The recorded research example pairs Binance BTC/EUR spot, converted through EUR/USDT spot, against BTC/USDT perpetual futures. In that example, BTC/EUR is the maker, EUR/USDT the cross and the BTC future the taker.

The worked example below uses synthetic numbers instead. They show the mechanism without attaching it to a particular historical run.

Costs and the minimum spread

The recorded cost example assumes a -0.5 bp maker fee, which is a rebate, plus a 2 bp taker fee and 2 bp slippage. Doubled for a round trip, the total is:

2 × (−0.5 + 2 + 2) = −1 + 4 + 4 = 7 bp

The notebook adds a 2 bp gain to that cost, giving a 9 bp minimum spread.

A screen, not a backtest

The notebook counts how often detrended ratios exceed a threshold and uses that frequency to extrapolate hourly, daily and monthly returns. It's a threshold-frequency heuristic with its own assumptions, not a backtest. It doesn't model:

  • order queues
  • independent trade arrivals
  • position holding times
  • a full fill ledger

That makes it useful for deciding whether a pairing deserves further work.

From batch arrays to incremental updates

Research works on whole arrays, while a live strategy processes one event at a time. The notebook bridges that gap with incremental versions of the same calculations.

Slow trend, fast translation

The notebook provides incremental functions for trend updates and separate cross-pricing functions for live quote calculation. Its comments set the operating pattern: update the trends once per second, then combine the latest trend with real-time market prices whenever a quote is computed.

Our interpretation of this design is an inference, not a statement from the code. A compact model state changes on a slower clock, while each market event only needs a translation through current prices. The same formulas serve both research and live quoting, keeping the two aligned.

Checking the incremental path against batch

Before relying on the incremental path, we compared its output with the batch arrays for four series:

  • fair bid
  • fair ask
  • bid band
  • ask band

All four saved comparisons returned True for that execution. That recorded check confirms the intended consistency between research and incremental calculations for the saved run. For our broader approach to testing research before relying on it, see financial cross-validation.

How the quoting strategy uses the model

At runtime, our statistical-arbitrage quoting strategy extends our maker strategy and consumes the model's output. The strategy doesn't run the Kalman filter itself; it receives trend values from a separate process.

Four input channels

The strategy subscribes to four ZeroMQ channels and keeps the latest values from each:

  • maker quotes
  • taker quotes
  • cross quotes
  • Kalman bid and ask ratio trends

When a trend update arrives, the strategy stores the new bid and ask values. It then recomputes its bands from the current taker and cross prices. The research design updates trends once per second; the live update rate depends on the process publishing the trends.

The pricing equations

Each band follows the research formula. The strategy applies the trend to the taker price, multiplies or divides by the cross price according to the configured orientation, then applies that side's spread:

bid_band  = cross_translate((1 + bid_trend) × taker_bid) × (1 − bid_spread)
ask_band  = cross_translate((1 + ask_trend) × taker_ask) × (1 + ask_spread)
price_bid = min(local_maker_bid, bid_band)
price_ask = max(local_maker_ask, ask_band)

The minimum and maximum rules cap the bid at the local maker bid and floor the ask at the local maker ask. The model can pull a quote back from the local market, but it can't push the quote beyond it.

When quotes move

Two events schedule order replacement:

  • a taker-price change larger than the configured tolerance
  • a new Kalman trend update

The tolerance keeps small taker moves from triggering replacement. The strategy inherits balance-aware maker handling, and its readiness check confirms setup, balance, instrument and a usable bid. This model addresses a moving basis; futures hedges that close asset exposure are separate strategies.

Worked example: from observation to quotes

The numbers below are illustrative.

The observation behind the update

Start with one illustrative row of paired prices. The maker book shows 80.16 bid and 80.272192 ask, the cross shows 1.25 on both sides and the taker shows 100 bid and 100.1 ask. The zero-spread cross is a simplifying assumption for this example.

Each side's ratio follows the formula from earlier:

  • Bid: 80.16 × 1.25 / 100 − 1 = 0.002
  • Ask: 80.272192 × 1.25 / 100.1 − 1 = 0.0024

These two ratios are the observations the bid and ask filters receive. The bid value becomes z = 0.002 in the update below.

One Kalman update

Take a prior bid trend x = 0.001 with covariance P = 0.0001, process variance Q = 0.0001 and observation variance R = 0.0008. The transition and observation matrices are identity, and the bid observation from that row is z = 0.002.

  1. Predict: P_prior = P + Q = 0.0002.
  2. Gain: K = 0.0002 / (0.0002 + 0.0008) = 0.2.
  3. Update: x = 0.001 + 0.2 × (0.002 − 0.001) = 0.0012.

The observation sat 0.001 above the trend, and the trend moved one-fifth of that distance. A larger observation variance would reduce the gain, so the trend would move less.

From trend to submitted bid

At the next pricing event, the taker and cross prices are unchanged, but the local maker book has moved to 80.04 bid and 80.06 ask. The earlier row updated the model; this fresh book constrains the quotes. Use the taker bid of 100, cross bid of 1.25 and a 5 bp bid spread, with reversed cross orientation so the strategy divides by the cross.

  1. Trend-adjusted taker: 100 × 1.0012 = 100.12.
  2. Fair maker bid: 100.12 / 1.25 = 80.096.
  3. Bid band: 80.096 × 0.9995 = 80.055952.
  4. With the local maker bid of 80.04: min(80.04, 80.055952) = 80.04.

The minimum rule caps the submitted reference at 80.04. The strategy's subsequent order handling takes over from there.

The same steps for the ask

The ask filter keeps its own trend. Take a prior ask trend of 0.0015 with the same illustrative P, Q and R, so the gain is again 0.2, and the ask observation of 0.0024 from the paired-price row.

  1. Update: 0.0015 + 0.2 × (0.0024 − 0.0015) = 0.00168.
  2. Fair maker ask: 100.1 × 1.00168 / 1.25 = 80.2145344.
  3. Ask band with a 5 bp spread: 80.2145344 × 1.0005 = 80.2546416672.
  4. With the local maker ask of 80.06: max(80.06, 80.2546416672) = 80.2546416672.

Here the band, not the local book, sets the reference, because it sits above the local ask. Order handling takes over from there, as on the bid side.

What moved, and what to ask

The "before" column applies the same market inputs to the prior trend.

Before update After update
Bid trend 0.001 0.0012
Fair maker bid 80.08 80.096
Bid band 80.03996 80.055952
Reference after min rule 80.03996 80.04

The band rose by about 0.016, while the reference rose by only 0.00004 because the local bid capped it. A series like this raises evaluation questions:

  • Did the observation reflect a real shift in the relationship, or noise the filter should mostly discount?
  • How often does the cap bind, and how does that affect the strategy's quoting?
  • How do the configured spread and residual volatility compare with the round-trip cost?

Building your own cross-pricing model

If you're building something similar, start with the batch arrays, write the incremental functions, then prove both produce identical fair prices and bands before anything reaches an order book. Compare candidate spreads with round-trip costs, as in the 7 bp example, rather than with a return extrapolation.

From there, decide how your quoting strategy should react to model updates and local prices. To see how this model fits with inventory and hedging decisions, read our article on market making and arbitrage.