vike
Market making

Ladder quoting

The pure per-side rung builder behind the maker's N-orders-per-side mode — rung 0 is today's quote bit-for-bit, deeper rungs step outward, and the whole side is diffed to the minimal verb set each tick.

crates/vike-mm/src/ladder.rs's desired_side is the pricing half of ladder quoting: given one side's level-0 context — the price and size today's single quote would use — it maps a LadderParams onto that side and returns the rungs as absolute (price, size) pairs, rung 0 first and deepest last. The data half lives one layer down, in vike-model: crates/vike-model/src/strategy/maker_params.rs's LadderParams and LadderLevel, plus the size-profile expansion LadderParams::rungs. The header names why the split runs that way: the params ride the typed live-parameter payload, so they have to be a Copy scalar bag in the bottom layer, exactly the split quote styles already keep between QuoteStyle (data in vike-model) and book::priced (arithmetic here). Like skew, book and refresh, the geometry is isolated so it unit-tests without a runtime or a broker.

The geometry

Rung 0 is always (level0_px, base_qty) — a laddered maker's innermost order is bit-for-bit the quote the single-order maker would have rested. Rung k steps k · offset_step further out, away from the reservation price: a bid rung sits that far below the level-0 bid, an ask rung that far above the level-0 ask. The step's absolute size is the ladder's LadderOffsetUnit scale times offset_step, and the unit is the one grid-agnostic choice in the design:

  • HalfSpread (the default) measures the step in multiples of the level-0 half-spread, the gap between the reservation price and level 0's quote. It needs no tick grid at all, so a 1.0 step reads identically on a 0..1 prediction market and a five-figure crypto book.
  • Ticks steps by the venue grid the maker already quotes on. It needs the book's positive tick_size; on an unknown grid the ladder collapses to level 0 only.

Sizes are a multiplier of the base size, so the inventory skew that shapes level 0 flows through every rung. LadderSizeProfile::Linear (the default) is arithmetic growth, size_k = base · (1 + k·(ratio − 1)) — a ratio of 2 gives multipliers 1, 2, 3, 4…; Geometric is size_k = base · ratio^k — the same ratio gives 1, 2, 4, 8…. Both put level 0 at exactly 1.0 × the base, and a size_ratio of 1.0 makes every rung the base size under either profile. A ratio below 1 shrinks deeper rungs; the geometric profile decays without ever reaching zero, the linear one can.

LadderParams::levels counts rungs per side including level 0. levels <= 1 is off: LadderParams::is_active is false, the maker rests exactly today's one order per side under the "bid"/"ask" tags, and the pre-feature verbs are reproduced byte-identically. The module is reached only when the ladder is active (levels >= 2); nothing in it logs or allocates on the default path.

Two guards — never place a nonsensical rung

The header lists exactly two ways a rung is refused. With no usable price scale — unit_scale <= 0, a Ticks unit on an unknown grid or a zero or crossed half-spread — deeper rungs would collapse onto level 0's price, so the ladder is cut to level 0 only. With a non-positive rung size — a decaying Linear profile past zero, or a skew-floored base — the rung is not placeable; because both profiles are monotone in k, the walk stops at the first such rung and the returned set is the contiguous placeable prefix 0..m. The in-file tests pin both: a five-level linear ladder at ratio 0.5 on a base of 2.0 returns two rungs, and a four-level ladder with a zero half-spread returns one.

How the maker drives it

crates/vike-mm/src/quote.rs's requote takes the ladder branch at its stage (5), after pricing, the reward fold, the skew and the per-side suppression verdict have already produced this tick's (bid_px, ask_px) and sizes. The rung geometry is taken from this tick: half is (ask_px − bid_px)/2, tick is the book view's tick_size, and each side's base_qty is its already-skewed level-0 size. Rungs carry tags "bid0".."bidN" / "ask0".."askN", and SpreadMaker::requote_ladder_side diffs the desired set against what is resting:

  • suppressed by the fill-rate breaker: pull every resting rung of that side and place nothing — the whole-side analogue of the single path's suppression cancel, so a rung is never stranded;
  • otherwise, for each desired rung k: submit it if no rung k is resting, else modify it in place unless the per-rung refresh tolerance says the drift is churn; then cancel any resting rung beyond the desired count — the ladder shrank, through a re-tune with fewer levels or one of the guards above.

A fill on a side sets that side's refresh_stale flag, which forces every rung through the modify arm once so a partial fill's size top-up is never read as "no change". The tolerance is never consulted on a pull.

A rung that dies at the venue is not freed

"What is resting" is the maker's own SideState::rungs vec, written only by the diff above — and nothing clears a rung the venue terminated. crates/vike-mm/src/strategy_impl.rs's on_order_event, the order-death hook that frees a single quote's slot after a fill, a cancel or a reject, matches the tags "bid"/"ask" exactly and returns on any rung tag. Its doc says so: "⚠ The LADDER path is NOT covered. … A laddered maker therefore still wedges per-rung; the ladder is off by default (levels >= 2), and the single-quote path — the one the live mount runs — is closed." A rung's tag carries its index into rungs, so freeing one would renumber every deeper rung's tag away from the order actually resting under it; closing this needs a per-rung liveness slot rather than a dense vec, which is a reshape of the diff. Until then a filled, canceled or rejected rung stays in the vec and every later tick re-prices a terminal order through modify_tagged.

Two retirements handle mode switches: SpreadMaker::retire_single_order cancels the leftover "bid"/"ask" on the way into ladder mode, and SpreadMaker::retire_ladder_orders cancels resting rungs on the way out — a guarded no-op while the rung vector is empty, which is always the case on the never-laddered path, so the single-order tail stays byte-identical.

It composes rather than replaces

SpreadMaker::with_ladder's doc spells the composition: the style or Avellaneda–Stoikov layer sets rung 0's price, the skew shapes the base size every rung multiplies, and own-order filtration subtracts the rungs — the doc recommends SpreadMaker::with_own_order_book, which tracks N orders, over the single-snapshot filtration. The ladder rides the live-parameter plane, so on_params_updated can turn it on or off or re-shape it without a remount.

The behaviour suite is crates/vike-mm/src/tests/ladder_quoting.rs, whose header names what it covers: N rungs per side diffed to the minimal verb set, the single-quote reduction at levels <= 1, per-rung tolerance, whole-side pulls, and live toggling. Where the ladder sits in the one shared tick step is on quote emission.

On this page