6 · The book Draft

Book ownership and the toy quoter

6 min read

On this page

Who owns the book

One thread applies updates and reads the top, and the strategy runs on that thread, straight after apply, while the lines it touches are still in L1 at 1 ns a load. Anything else that wants the top, a risk monitor, a logger, a screen for a human, gets a copy of Top (best bid and best ask with their quantities, 48 bytes, inside one cache line) through a message, never a reference into the book. The concurrency chapter’s rule in one line: one writer per cache line, and share by message. A reader on another core that looked into the book directly would pull its lines across at 50–100 ns each and make the owner fetch them back on its next write; worse, it could load the best bid from one update and the best ask from the next, a top that never existed. Orders leave the same way: the strategy hands each to the gateway thread, the one that owns the order connection to the venue.

/// A cancel-replace: move my resting order on `side` to `price` for `qty`.
#[derive(Clone, Copy)]
pub struct Order { pub side: Side, pub price: Ticks, pub qty: Lots }

pub struct Quoter {
    pub inventory: Lots,                     // lots bought minus lots sold; > 0 is long
    pub half_spread: Ticks,                  // distance from the mid to each quote
    pub skew_per_lot: Ticks,                 // how far both quotes move per lot held
    pub size: Lots,                          // lots on each side
    pub resting: [Option<(Ticks, Lots)>; 2], // [bid, ask] as last sent
}

impl Quoter {
    /// Runs on the book's thread, after apply, only when the top changed.
    pub fn on_top(&mut self, top: Top) -> [Option<Order>; 2] {
        let (Some((bid, _)), Some((ask, _))) = (top.bid, top.ask) else {
            return [None, None];                 // a side is empty: the toy waits
        };
        let mid2 = bid + ask;                    // twice the mid, always an integer
        let skew2 = 2 * self.inventory * self.skew_per_lot;
        let want = [
            (mid2 - 2 * self.half_spread - skew2).div_euclid(2),     // bid, rounded down
            (mid2 + 2 * self.half_spread - skew2 + 1).div_euclid(2), // ask, rounded up
        ];
        let mut out = [None, None];
        for (i, side) in [Side::Bid, Side::Ask].into_iter().enumerate() {
            let desired = Some((want[i], self.size));
            if self.resting[i] != desired {
                out[i] = Some(Order { side, price: want[i], qty: self.size });
                self.resting[i] = desired;       // assumed accepted
            }
        }
        out
    }
}

The mid of two whole-tick prices can fall halfway between ticks, so the code keeps twice the mid and halves at the end, rounding the bid down and the ask up so that neither quote comes nearer the mid than the half-spread. This is a toy. It has no risk limits and no venue rules such as minimum sizes or message-rate limits, ignores that its own orders appear in the book it reads, and treats an order as resting the moment it is sent, where a real quoter tracks each one as sent, acknowledged or rejected. Its skew is in whole ticks per lot, which suits a toy position of a few lots; at the desk’s real 50,000 lots it would move the quote $500, so a real quoter keeps skew in fractions of a tick and caps it.

What it costs

The quoter’s arithmetic is a few compares, additions and two halvings that compile to shifts: 5–20 ns, 20–80 cycles. The emit hands the order to the gateway thread through a queue, at the cost of one cross-core cache-line transfer, 50–100 ns. The strategy’s whole decision on the dense array, from decoded update to decided order, is under 100 ns, a tenth of a budget, and the hand-off brings the total to about 150 ns at most. The kernel’s send path on the gateway thread that follows is 2–5 µs, from the networking chapter: twenty to fifty times the decision. The decision is not where the time goes.

Worked example: the mid moves one tick

The instrument is BTC-USDT-PERP. At 14:05:09.412, on core 3, the copy’s best bid is 6,812,345 and its best ask 6,812,348. The desk is flat, inventory 0, with a half-spread of 2 ticks and 500 lots a side. Twice the mid is 13,624,693, a mid of 6,812,346.5, so the desired bid is 6,812,344.5 rounded down to 6,812,344 and the desired ask 6,812,348.5 rounded up to 6,812,349. Both are resting there.

An update arrives: seq 1,874,102,517, bid side, price 6,812,346, 512 lots, a new level one tick above the best. From recv returning to the order in the gateway’s queue:

StepWhat happensCost
Decodethe 48-byte message, in L1 after the copy out of the kernela few L1 loads, 1 ns each
Checksseq as expected; quantity not negativeunder 1 ns
applynew best bid: level set, best-price cursor (the index of the best level) moved5–20 ns
Crossed check6,812,346 < 6,812,348under 1 ns
Top changedyes; Top read from the two cursors1 ns
Quotertwice the mid 13,624,694, mid 6,812,347; desired bid 6,812,345, ask 6,812,3495–20 ns
Diffbid differs from 6,812,344: one cancel-replace; ask matches: nonein the quoter’s figure
Emitone Order into the gateway thread’s queue50–100 ns
Totalabout 65–150 ns, 260–600 cycles

The mid moved half a tick, enough to round the desired bid up one tick, while the ask, already rounded up, stayed put. One message goes to the venue, not two, and the ask keeps its place in the queue at 6,812,349. The whole path is at most a sixth of a budget; the 2–5 µs of the gateway’s send is where the time goes next.

Seeing it

Three numbers show the loop working. The first is cancel-replaces per second set against top-changes per second. The ratio should sit well below one, because many changes of the top round to the same quote; near one, the quoter is chasing every one-tick move and paying each time with a moment of no quote and a lost place in the queue. The second is the time from top-changed to emit, read with rdtsc, the processor’s cycle counter, at both ends and exported as p50, p99 and max; on the dense array the p99 should sit within the 150 ns above, and a max far beyond it is a cache miss or an interrupt. The third is the desk’s inventory over the day, drawn as a line that should wander and keep coming back to zero. A line that drifts away and stays there means the skew is too weak for the flow it meets; one that swings hard from long to short means it is too strong.

Read more: Marco Avellaneda and Sasha Stoikov, “High-frequency trading in a limit order book”, Quantitative Finance 8(3), 2008, is the standard paper on placing a market maker’s quotes around the mid and skewing them with inventory, worked out properly where this section only sketches. Larry Harris, Trading and Exchanges (Oxford University Press, 2002), describes the same business from the trader’s side in its chapter “Dealers”.