vike
Tutorials

Pairs stat-arb with a cost gate

Run the pairs_zscore template over two real Binance symbols with its round-trip cost floor armed, and read the negative result it exists to produce.

pairs_zscore is the shipped statistical-arbitrage template: a rolling z-score of the spread A − β·B, entered when the spread is stretched and exited when it reverts. What makes it worth running is the gate in front of the entry — it refuses a trade whose expected convergence does not exceed a full round trip INCLUDING carry.

This is a measurement instrument, not a product

crates/vike-strategy/src/pairs.rs' own module doc says so. Published evidence, measured on ten Binance perpetuals at 2 bp per crossing — an order of magnitude cheaper than a retail taker pays — decomposes a round trip as gross +0.15%, trading cost −0.12%, funding −0.18%, net −0.15%. The signal is real and it is about 15 bp. The purpose of the template is to reproduce that verdict on your own data, with your own fees and venues, through your own overfitting gates. Expect a negative; that is the successful outcome.

1. A two-symbol profile

The strategy needs both legs' bars, so the profile's data slice names two symbols:

name = "pairs_zscore — BTCUSDT / ETHUSDT 1h"

[data]
kind = "bar"
interval = "1h"
venue = "binance"
symbols = ["BTCUSDT", "ETHUSDT"]
from = "2025-01-01T00"
to = "2026-01-01T00"

[engine]
cash = 10000.0
fee_rate = 0.0005

[strategy]
name = "pairs_zscore"

[strategy.params]
symbol_a = "BTCUSDT"
symbol_b = "ETHUSDT"
period = 20
entry_z = 2.0
exit_z = 0.5
notional = 1000.0

Every key this table does not set keeps the value in PairsZScore::default — beta 1.0, taker_fee 0.0005, half_spread_bps 1.0, hold_intervals 3.0, no funding assumption and no half-life gate. Sections 5 and 6 are about what those defaults assert.

Both slice forms reach the same code path — DataCfg::resolved_series expands the frozen venue + symbols pair exactly as it resolves a cross-venue [[data.series]] array — so a pair whose legs live on different venues is the same profile with the longer spelling.

PARAM_KEYS' pairs_zscore row declares every key the reader takes and its TOML type: symbol_a, symbol_b, period, entry_z, exit_z, beta, notional, taker_fee, half_spread_bps, hold_intervals, funding_a, funding_b, max_half_life. period is a COUNT read through as_f64 and then rounded, so it is declared Number and period = 2.6 is legal input that resolves to 3 — a coercion no type check can see.

2. Fetch both legs

Public klines, no credentials, no venue account:

backtest --fetch binance:BTCUSDT:1h --from 2025-01-01T00 --to 2026-01-01T00
  8760 bars returned, covering 1735689600000..=1767222000000; 8760 rows written

The second leg is the same command with the other symbol. A pair needs both spans present before the profile runs — a leg that is short does not error, it simply never completes a step.

backtest --fetch binance:ETHUSDT:1h --from 2025-01-01T00 --to 2026-01-01T00
  8760 bars returned, covering 1735689600000..=1767222000000; 8760 rows written

3. The first run traded nothing, and the profile never said so

The first attempt at this tutorial guessed its parameter names instead of reading them off the registry. That produced a complete run and no orders at all:

backtest --profile tut_pairs.toml
name:          pairs_zscore — BTCUSDT / ETHUSDT 1h
final_equity:  10000.00
n_trades:      0
no trades executed - probable cause(s), most likely first:
  [1] No order-blocking cause was detected.
      Over 8760 bar(s)/tick(s) nothing was denied, deferred, or gated and no trade closed, so no gate explains the flat result. The strategy most likely never submitted an order - check its entry condition and that it reads the SAME symbol the data is tagged with (bar-mode `snap_to_properties` re-tags bars as `SYMBOL.VENUE`, which a strategy routing on the bare symbol will miss).

The diagnosis is right, and it is the only signal you get. PairsZScore::from_params is a READER: it ignores a key it does not know and falls back to a default for a key that is absent. Both symbols default to the EMPTY string, and that constructor's own doc states the consequence — symbol_a and symbol_b are required in practice, because with either empty the strategy never routes an order.

Nothing upstream catches it either. StrategyCfg types params as an arbitrary toml::Value, so deny_unknown_fields never looks inside it, and vike_strategy::unknown_params — the check that would have named the misspelling — runs in vike-tradehub at mount time, not in a backtest. A backtest's whole defence here is ZeroTradeReport::analyze, which is why a flat run prints a ranked diagnosis instead of an all-zero table.

The key names are not a guess anywhere. crates/vike-ops/src/docs_data.rs's template_record exports one record per PORTABLE_STRATEGIES name carrying that name's PARAM_KEYS row and its LIVE_CAPABLE verdict. The six keys the profile above sets come from there.

4. The corrected run

Same tape, same span, the keys spelled as the registry exports them:

backtest --profile tut_pairs.toml
name:          pairs_zscore — BTCUSDT / ETHUSDT 1h
final_equity:  9984.62
total_return:  -0.1538%
n_trades:      90
win_rate:      57.7778%
sharpe:        -0.0851
max_drawdown:  2.0435%
per_symbol_pnl:
  BTCUSDT: 21.90
  ETHUSDT: 9.09

It lost money. A year of hourly bars on the two largest crypto instruments, a cost-gated spread, and the account finished 15.38 down on 10,000.

Ninety closed trades is forty-five spread round trips: flatten exits both legs together, so a completed trip books one closed trade on each symbol. Out of a year of hourly z-scores, forty-five entries is the cost floor rejecting most band breaks rather than a strategy sitting idle.

Now the line worth stopping on. Both legs are positive — +21.90 on BTCUSDT and +9.09 on ETHUSDT, about +31 between them — while the account is down. That is neither a contradiction nor a reporting bug.

per_symbol_pnl is built from each symbol's running realized, which accumulates FillOutcome::realized_pnl — documented as GROSS price PnL on the closing quantity. The fee is carried separately, and SimBroker::apply_fill takes it straight out of cash with cash -= fee. Fees never enter the per-symbol column. The roughly 46 dollars between the two numbers is what the crossings cost.

So the run is a picture of a strategy that was right more often than not, on both legs, and paid more for the privilege than being right was worth. That is the template's thesis reproduced on one pair.

What it does not say. It is not evidence that pairs trading loses money: one pair, one venue, one year, one parameter set, one fee rate. A Sharpe of -0.0851 is a hair below zero, so this run is statistically indistinguishable from flat, and reading a verdict of either sign into it would be the overfitting the template exists to expose.

It is also not a market-neutral result. beta stayed at its default 1.0, which sizes the hedge in equal UNITS rather than equal dollars, so one unit of the BTC leg was hedged with one unit of ETH — a few percent of the first leg's dollar value. Fit your own beta before reading the sign.

And it does not validate the cost model, because the cost model here is an assumption: half_spread_bps was 1.0 and hold_intervals 3.0 because nothing set them otherwise. A wider real book refuses more entries; a cheaper one lets more through. Section 6 is the knob you owe a number to.

5. The two things this template fixes

Beta SIZES the hedge leg. The structure is adapted from a published implementation that fits beta by OLS, uses it only to define the residual, and then splits the legs 50/50 by dollar value — beta-blind, so the position is not beta-neutral despite beta being computed. Here beta_neutral_qtys(notional, price_a, price_b, beta) returns (notional/price_a, β·qty_a), and refuses outright (None) on a non-finite or non-positive beta rather than inverting a leg behind your back. With beta = 1 that is equal UNITS, not equal dollars — section 4 is what that default looks like in a report.

The barriers are symmetric and cost-gated. The prior art's +1% / −5% global barriers are EV-neutral by construction on a driftless process (5/6·1% − 1/6·5% = 0 exactly) while looking excellent over a short sample. Here should_enter requires the z-score outside entry_z AND the expected convergence edge to exceed the total cost, and should_exit fires on reversion into exit_z or on a sign flip relative to the z the position was opened at. The edge is deliberately (|z| − exit_z)·sd, not the full |z|·sd — you exit at the exit band, not at the mean.

6. Where the cost number comes from

vike_model::spread_quote prices what a multi-leg spread actually costs to trade rather than how its mid looks. Its sign rule is the load-bearing part: BUYING a spread means lifting the ask of every positive-ratio leg and hitting the bid of every negative-ratio leg. A naive bid_A − bid_B / ask_A − ask_B construction takes the same side on every leg, which understates crossing cost and produces a CROSSED quote whenever the short leg is wider than the long one.

When the cost is unknowable — a crossed or missing book — should_enter is false. An unpriceable trade is not a free one.

Two knobs decide how honest the gate is:

  • half_spread_bps is the assumed book width, since bars carry no L2. It is not optional in spirit: leaving it at zero asserts a frictionless book, which is the assumption this strategy exists to test.
  • hold_intervals is how many funding intervals the carry half of the floor is charged for. It is always used on an entry check — nothing a bar carries replaces it. funding_a / funding_b are the rates it multiplies, until a bar for that leg carries a funding rate of its own; that rate is then remembered and used for every later entry check on that leg, funding-less bars included.

max_half_life is an opt-in regime gate on top: refuse entry when the spread's OU half-life exceeds that many bars, or is NaN — not mean-reverting at all is a stand-down, not a pass. <= 0.0 disables it.

7. What else the report carries

The z-score kernel is bit-identical to vike_indicators::pairs::spread_zscore — not merely the same formula but the same running-sum recurrence, asserted bitwise by inline_zscore_matches_the_indicator_seam — so a spread you plot on a chart and a spread the strategy trades are the same number over the same window.

zero_trade is present ONLY when the run closed no trades AND its equity never moved, which is why section 3's output carries a diagnosis and section 4's carries a metrics table. Once trades exist, the reading is yours to do.

pairs_zscore does not trade live today

vike_strategy::LIVE_CAPABLE refuses the name with its reason: it is TWO-LEG and needs symbol_a + symbol_b as declared mount legs admitted into the venue engine's extra_symbols. A mount declares no legs today, so the second leg's bars would never arrive and its orders would be dropped at ExecutionEngine::accepts_symbol. vike-tradehub refuses it at profile LOAD rather than mounting a strategy that would silently never submit. Everything above is a backtest of a strategy you cannot currently mount, and the registry export says so in the same record that names its keys.

Where next

On this page