November 3, 2026 · execution

We Paper-Traded a Basis Trade. One Leg Filled.

We Paper-Traded a Basis Trade. One Leg Filled.

The function was called enter_pack. That should have been the warning. A research agent had written a BTC cash-and-carry: when the perpetual traded more than eight basis points over spot, buy spot, short the perp, sit still. Funding and basis would do the rest. The backtest filled both legs in the same millisecond, at mid, plus a tick of politeness. The equity curve was a staircase. Nobody would call it exciting. That was the selling point.

We promoted it to paper on a Monday.

Monday: the pack that always filled

The first signal landed at 11:17 UTC. Basis on the screen was 9.4 bps, perp rich. In the backtest, enter_pack returned two fills and a residual of zero. In paper it returned a spot fill of 0.40 BTC at the ask, and a resting short on the perp that did not trade.

Spot had paid a 4 bp taker fee and about a 1 bp spread. The perp price we wanted was still on the book, then it wasn't. Twenty seconds later the quoted basis was 5 bps, because we had just lifted spot. Forty seconds later the perp filled, after the move. We were packed. We were packed at a basis that no longer paid for getting in and out.

The backtest would have booked "+9.4 bps carry, residual 0." The blotter had 4 bps of spot taker cost, a 40-second unhedged long, and an entry gap of about 5. I had a sample of one and, still, too much confidence in the staircase.

Tuesday: we opened the function

enter_pack took a list of legs and a target mid. It looped, filled each leg at mid * (1 ± slip), and wrote one portfolio row. There was no second timestamp. There was no branch in which leg two did not exist. The unit test asserted residual == 0.

That is a test that makes you feel like an adult. It will never fail.

We broke the pack into two orders with their own state: submitted, partial, filled, canceled. Incomplete became a first-class object. The historical replay still didn't have a real book, so we cheated. Delay the second fill by a draw from Monday's wait (mean 26 seconds, a tail I did not like), and drift its price by whatever spot did in that window. Crude, but the staircase grew notches before lunch: packs that entered at 3 bps, packs that never became packs.

Wednesday: three seconds, then dump it

If the second leg isn't on within three seconds, flatten the first. We should have started there. It sounds conservative until you price the abort. Flattening means you crossed spot to get in and you cross it to get out. Two taker fees, two spreads, no carry, no funding. In the rewritten replay those aborts were not rare. They clustered on the bars where the basis looked most "real," which is usually the bars where something is already flowing through spot.

By Wednesday night we had seventeen paper signals to argue with.

17paper signals in four days
7packs with both legs filled
18 bpslater move against a leftover long
Backtest, Monday morningPaper, by Wednesday
EntryBoth legs at mid, one timestampSpot at the ask; perp 5–40s later, or never
ResidualAlways 0Unhedged on 10 of 17 signals
Fees on a working tradeTwo takersTwo takers, or four if we abort
PnL driverBasis + fundingWhatever spot did while we waited
A pack is complete when both orders are filled, or it is a directional position you did not size for. Mark the residual against the live book the whole time it exists. If you cannot mark it, you cannot run the strategy.

A colleague still has a notebook that treats the pack as a listed product, BTC_CARRY, with a flat 2 bp "execution tax." I liked that notebook. It produced the same staircase the agent did. A tax is a mean. Orphans are a distribution. Means do not get run over in a thin hour; 0.40 BTC of leftover spot can.

Thursday: leftover, then coffee

Three seconds aborted almost everything once we were also waiting on a queue. We loosened paper to fifteen. At 02:11 UTC Thursday, spot filled and the perp did not. I was still in the "it will rest, it will lift" phase of grief when I went to make coffee.

02:11:08 filled BTCUSDT 0.40 buy
02:11:08 submitted BTCUSDT-PERP 0.40 sell, post-only
02:11:41 still working
02:26:00 residual mark −18.1 bps

BTC sold off 18 bps in a quiet window. The residual was the whole trade. Funding, when it printed, credited a perp we did not have. Spot just sat there being long. The original backtest's overnight path never contained a one-legged position, so it had never asked the question we ask of every other book: what is the worst bar this leftover can print, and where is the stop for a position we did not mean to hold?

The seven full packs were not heroes either. By the time the second leg printed, our own spot lift had already leaned on the index, and the entry basis was 3 to 5 bps worse than the snapshot that had fired the signal. The eight-bps threshold was a number about a screen.

What we'd skip next time

We would not ship a multi-leg as a single fill function. Same rule for a perp-spot carry, a calendar, or the call vertical an agent submits as two limits and then celebrates when both "fill" at mid.

  1. Per-leg order state, including partials. Treat the pack as a parent of two tickets.
  2. A max-orphan timer that actually fires, and a flatten path that pays two spreads on the way out.
  3. Mark-to-book on the residual, with a stop sized for the leftover. The packed net is the wrong notional.
  4. No carry credited, no funding accrued, until both legs are on. Incomplete is incomplete.
  5. Refuse mid-on-mid as a default for anything that has two tickets. If you must guess, guess worse on the thin leg and later on the second fill.

And we would not let test_pack_is_atomic into the repo. Atomic is a property of a matching engine, not a helper function. BTC spot and BTC perpetual are two books that sometimes rhyme. Paper is where they stop rhyming, which is the only reason the staircase was ever interesting.

backtestingcash-and-carryexecutionpaper tradingcrypto futures
← All posts