← Back to roadmap
Phase 4Week 42~120 min read
0%·0/4

A Market-Making Strategy

Quote both sides, manage inventory, survive.

Mastery · Markets, Strategies & Getting Hired

Why this week

A market maker continuously quotes a bid and an ask. It earns the spread when uninformed traders cross it, loses to traders who know more, and carries whatever inventory the flow leaves it with. It's the classic high-frequency business, and the best first strategy for a systems engineer, because almost everything that decides whether it works is a systems problem. How fast can it requote when the market moves? Does it know its true position while cancels and fills are both in flight? Does it stop quoting when its data goes stale? Can it decide in nanoseconds without allocating?

This week you build one against your own exchange. The goal is to understand inventory risk and quote skewing, and to survive: no runaway inventory, no crossed quotes, no orders it has lost track of. Profit is not the goal.

⚠️
Warning

A market maker that makes money against your simulator has learned to exploit your simulator. Real market making involves things this week doesn't model: competing for queue position against firms with faster hardware, informed flow that is much smarter than a random taker, fee tiers that depend on monthly volume, exchange obligations, and tail events. Treat every PnL number this week as a check on your accounting, not as evidence of an edge.

Day 1 — What a market maker actually does

Day 01

Earn the spread, pay the informed, carry the inventory

The economics in one line

The PnL of a passive market maker breaks down roughly as:

PnL ≈ spread captured        (half-spread × fills, measured against the mid at fill time)
    − adverse selection      (the mid moving against you after fills; week 41's markouts)
    ± inventory moves        (position × mid changes not caused by your fills)
    + rebates − fees         (venue pricing)

The first term is the reason to do it. The second is why it's hard: week 41 showed that the realized spread can be close to zero once impact is taken into account. The third is the risk you carry, and the main thing a market maker controls: hold inventory briefly and in small amounts. That's what inventory skew is for. The fourth is often bigger than you'd expect, as the drill on Day 4 shows.

Formal models make the trade-off precise. Avellaneda and Stoikov (2008) model a market maker with risk aversion γ, facing a mid that moves randomly with volatility σ and taker orders whose arrival rate falls off exponentially with distance from the mid. The optimal quotes are centred not on the mid but on a reservation price that moves against your inventory. Guéant, Lehalle and Fernandez-Tapia (2013) add inventory limits and give closed-form approximations. Practitioners rarely run these models literally, but most quoting logic in practice uses some version of their main idea: skew the centre by inventory and widen with volatility.

Paperby Marco Avellaneda and Sasha Stoikov (Quantitative Finance, 2008)
High-frequency trading in a limit order book

Read the setup and the results. Skip the derivation on a first pass. Make sure you can explain each term of the reservation price and of the optimal spread in plain words before Day 2.

Bookby Álvaro Cartea, Sebastian Jaimungal and José Penalva
Algorithmic and High-Frequency Trading

The market-making chapters extend Avellaneda–Stoikov with inventory penalties, adverse selection and order-flow signals. They're a rigorous companion to this week. The microstructure chapters at the start review week 41.

Recall#mm-main-cost

For a passive market maker in a liquid instrument, which usually hurts more over time: inventory risk or adverse selection? Why?

Day 2 — Inventory skew

Day 02

Lean your quotes against your position

Reservation price, tick rounding and hard limits

The Avellaneda–Stoikov reservation price is

r = s − q · γ · σ² · (T − t)

where s is the mid, q your inventory (+ long), γ your risk aversion, σ² the variance of the mid per unit time, and T − t the time left. When you're long, r falls below the mid, and centring your quotes there makes your ask more competitive (you sell more readily) and your bid less so (you buy less readily). Their optimal total spread is γσ²(T − t) + (2/γ)·ln(1 + γ/k): a term for inventory risk plus a term for how quickly fill probability falls off with distance (k).

In practice you usually turn that into a handful of parameters: a half-spread in ticks, a skew in ticks per lot of inventory, and hard inventory limits. Then three details catch everyone out:

  • Rounding. Your theoretical bid is rarely on the tick grid. Round bids down and asks up. Rounding toward the mid would quietly tighten your quotes and give away edge.
  • Never cross the market. A heavy skew can push your bid to or past the best ask. A passive quoting engine must clamp (bid ≤ best ask − tick, ask ≥ best bid + tick) or use post-only orders that the exchange rejects instead of executing (week 8).
  • Hard limits. At the inventory limit, stop quoting the side that would add to the position. Don't rely on skew alone to stop accumulation.

Compare the two inventory lines: without skew the same taker flow wanders to the hard limit and stays there for stretches, because pulling the adding side stops accumulation but nothing pulls the position back; with skew it keeps returning to flat. Then add informed takers and watch the inventory term eat most of the spread capture, a preview of Day 4's PnL split and Day 6's experiments.

Inventory skew: quotes, inventory and PnL split under noise and informed flowOpen full screen ↗
Drill

Skew quotes by inventory

Target: 6/6 cases passNot yet run
#[derive(Clone, Copy, Debug)]
pub struct Params {
    /// Tick size in price units (prices are integers on a tick grid).
    pub tick: i64,
    /// Distance from the center to each quote, in ticks.
    pub half_spread_ticks: f64,
    /// How far the center moves per lot of inventory, in ticks.
    pub skew_ticks_per_lot: f64,
    /// At |inventory| ≥ this, stop quoting the side that adds to it.
    pub max_inventory: i64,
}

#[derive(Clone, Copy, Debug)]
pub struct Market {
    pub best_bid: i64,
    pub best_ask: i64,
}

#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct Quotes {
    pub bid: Option<i64>,
    pub ask: Option<i64>,
}

/// Avellaneda–Stoikov reservation price: r = mid − q·γ·σ²·τ
/// (q = inventory, γ = risk aversion, σ = volatility per unit time,
/// τ = time left in the horizon).
pub fn reservation_price(mid: f64, inventory: i64, gamma: f64, sigma: f64, tau: f64) -> f64 {
    todo!()
}

/// Two-sided quotes around `fair` (a price, not ticks), skewed against
/// inventory:
///   center = fair − skew_ticks_per_lot · inventory · tick
///   bid    = center − half_spread_ticks · tick, rounded DOWN to the grid
///   ask    = center + half_spread_ticks · tick, rounded UP to the grid
///   post-only safety: bid ≤ best_ask − tick, ask ≥ best_bid + tick
///   inventory ≥  max_inventory → no bid (don't buy more)
///   inventory ≤ −max_inventory → no ask (don't sell more)
/// Rounding tip: floor(x / tick + 1e-9) and ceil(x / tick − 1e-9) keep
/// 99.99999999 from becoming the wrong tick.
pub fn quotes(fair: f64, inventory: i64, p: &Params, m: &Market) -> Quotes {
    todo!()
}
Rust
Recall#skew-not-widen

When you're long, why shift BOTH quotes down instead of just widening the bid?

Day 3 — Managing live quotes

Day 03

Requotes, cancels and in-flight orders

The order state machine is the strategy

Deciding what to quote is the easy part. The quotes then have to live on a real exchange, where every message takes time, and the hard work is in keeping track of them:

  • Requoting costs queue position. Under price-time priority, a price change is a cancel plus a new order at the back of the queue. Week 8's Reduce keeps priority, but only for size decreases. If you requote on every tick of the weighted mid, you'll always be at the back of the queue, and that's where week 41 showed the worst fills come from. Add hysteresis: requote only when the target price moves by at least a tick, or when your resting quote has become dangerous (stale, or on the wrong side of a fast move).
  • Every order has a state, and fills can arrive in any of them. A cancel and a fill can cross on the wire. You send a cancel for your bid, and before the cancel acknowledgement arrives, a fill on that same bid shows up. The exchange's ordering decides, and you only find out afterwards.
/// One resting quote as the strategy sees it. Fills may arrive in
/// every state except Dead.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum QuoteState {
    PendingNew { qty: u64 },                      // sent, no ack yet
    Live { id: OrderId, open: u64 },               // acked, resting
    PendingCancel { id: OrderId, open: u64 },      // cancel sent; may still fill
    Dead,                                          // cancelled, filled or rejected
}
  • Count in-flight orders in your limits. The worst-case position isn't your current position. It's your position plus every buy quantity that could still fill, which includes pending-new and pending-cancel orders. That's week 15's "position including open orders" check, now applied from the strategy's side.
  • Message budgets. Exchanges throttle message rates and often watch order-to-trade ratios. Week 17's token bucket is what an exchange uses to enforce that. Your strategy should stay well inside the limit rather than learn about it from rejects.
  • Stale data means pull your quotes. If the market-data feed has a sequence gap (week 10) or hasn't updated for longer than you expect, your fair value is unknown. Cancel both quotes first and investigate afterwards.
Recall#cancel-fill-race

You sent a cancel for your 5-lot bid. Before the cancel ack arrives, you get a 3-lot fill on that bid. What's your position change, what state is the order in, and what might happen next?

Design note#requote-policy

Write your requote policy as rules: when you move a quote, when you only reduce it, when you pull both sides, and what you do while a cancel is in flight. For each rule, name the failure it prevents (lost queue position, message storm, stale quote picked off, phantom inventory).

0 chars

Day 4 — Where did the money come from?

Day 04

PnL attribution

Spread capture, inventory and fees

A single PnL number doesn't tell you whether your quoting is good or you got lucky. Split it up:

  • Spread capture. For each fill, the edge against the mid at that moment: (mid − px)·qty for buys and (px − mid)·qty for sells. Positive means you bought below and sold above fair value.
  • Inventory PnL. For every mid move, position × Δmid. This is where adverse selection shows up: buy, then the mid drops.
  • Fees and rebates.

If you mark with the same mid you attribute against, the three components add up exactly to the total, cash + position × final_mid + fees. That identity is your accounting test.

Drill

Split PnL into spread capture and inventory

Target: 6/6 cases passNot yet run
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Side {
    Buy,
    Sell,
}

#[derive(Clone, Copy, Debug)]
pub enum Ev {
    /// One of our orders filled: we bought or sold `qty` at `px`.
    Fill { side: Side, px: i64, qty: i64 },
    /// The market mid moved to this value.
    Mid(i64),
}

#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)]
pub struct Pnl {
    /// Signed inventory at the end (+ long, − short).
    pub position: i64,
    /// Edge vs the mid AT FILL TIME, summed over fills:
    ///   buy:  (mid − px) · qty      sell: (px − mid) · qty
    pub spread: i64,
    /// What holding inventory made or lost while the mid moved:
    ///   Σ position_before_move · (new_mid − old_mid)
    pub inventory: i64,
    /// Σ −fee_per_lot · qty. A negative fee_per_lot is a maker rebate,
    /// so it shows up here as a positive number.
    pub fees: i64,
    /// cash + position · final_mid + fees, where buying costs px·qty cash
    /// and selling earns it. Always equals spread + inventory + fees.
    pub total: i64,
}

/// Replay our fills and the mid path, starting flat at `start_mid`.
pub fn attribute(events: &[Ev], start_mid: i64, fee_per_lot: i64) -> Pnl {
    todo!()
}
Rust

Look at the drill's last case. The market maker captured +14 of spread, lost −22 on inventory as the mid moved against its fills, and ends up +6 overall only because of +14 in rebates. That shape is common in practice: spread capture roughly cancelled by adverse selection, with fee economics making the difference. It's also why a change in a venue's fee schedule can turn a strategy that worked into one that doesn't, with no change to its code.

Recall#attribution-identity

Why does spread + inventory + fees equal the total exactly, with no residual?

Day 5 — Risk controls that keep a market maker alive

Day 05

Survive first

Limits, kill conditions and self-protection

Most market-making disasters come from missing controls, not bad quoting:

  • Inventory limits. A soft limit, where skew increases sharply, and a hard limit, where the adding side stops quoting. Both are measured on worst-case position, including in-flight orders.
  • Loss limits. Intraday realized + unrealized loss beyond a threshold pulls all quotes and latches a kill switch that a human has to reset. Week 45 builds the real-time version.
  • Order sanity. Maximum order size and price bands around a reference price (week 15). A bug that quotes 100× size or one tick from zero is caught here.
  • Self-trade prevention. Your own bid must never be at or above your own ask. Exchanges also offer self-trade prevention, but don't rely on it.
  • Market sanity. Don't quote into a crossed or locked book, an unusually wide market, or a halted instrument. Those conditions usually mean your data or the market is broken.
  • Stale data. Covered on Day 3, and the most important item on this list. No fresh data means no quotes.
Design note#pull-conditions

List every condition under which your market maker cancels all quotes, how quickly each must be detected, and whether it resets automatically or needs a human. Then name the one you think most real-world blowups would have been prevented by.

0 chars

Day 6 — Experiments you can trust

Day 06

What a simulation can and cannot tell you

Vary the counterparty, not just the parameters

The simulated takers decide your results. Run the same market maker against:

  1. Uninformed takers only (random side, random timing). The market maker will look great, because nobody is adversely selecting it. This is your baseline for the accounting, not a result about markets.
  2. Add informed takers who trade in the direction of the next mid move (they peek at the simulator's future). Watch markouts go negative and inventory PnL go against you, and see how much skew and width it takes to survive.
  3. Add a fast competitor that quotes at the same prices and gets to new levels before you. Watch your fill rate drop and your remaining fills get worse.

Evaluate across many seeds and report the distribution of PnL, maximum inventory and drawdown, not one run. Resist tuning the half-spread and skew to the seed you happened to use. That's overfitting, and week 43 shows how quickly it happens.

Design note#mm-experiments

Design your experiment matrix: which taker mixes, which parameter values, how many seeds, and which three metrics decide whether a parameter set is 'better'. Write down what result would make you conclude the strategy logic itself is wrong rather than mis-tuned.

0 chars

Capstone

Build the market maker in crates/sim as a strategy that trades through the real engine as its own account, against week 23's simulated traders.

Structure. A small strategy interface: a handler for book updates that returns quote intents, and a handler for execution reports (acks, fills, cancels, rejects) that updates order state. The market maker keeps at most one bid and one ask live. Fair value is week 41's weighted mid. Quotes come from the drill's skew logic, with hysteresis before requoting.

In the repo, sim::mm::MarketMaker (src/mm.rs) implements week 23's Agent trait, so the scenario runner and week 44's backtester can both drive it. act reads the market's top and calls decide(&top, &mut outbox), which writes the commands itself rather than returning intents. The pure part is target(&top, position) -> Quotes, and on_event hands each Delivery to on_delivery. Fair value is fair_value(&top), the microprice() of market_top(&book): the feed's top of book with the strategy's own resting quotes taken out, because a fair value that includes your own quotes makes you chase your own tail.

MmConfig holds the drill's parameters plus quote_qty and requote_ticks. One difference from the drill: instead of dropping a side only at the limit, each quote's size is min(quote_qty, room), where the bid's room is max_inventory − position and the ask's is max_inventory + position, so quotes shrink as the position nears the limit. A quote within requote_ticks of its target stays where it is. If only its size has to shrink, it gets a Reduce, which keeps queue priority. Otherwise it's cancelled and replaced with a post-only order, and all cancels go out before any new order.

Order state. Implement the Day 3 state machine with explicit tests for the races: a fill while a cancel is pending, a cancel rejected because the order already filled, a fill on an order the strategy believes is dead. That last one must raise an alarm, not panic.

The repo doesn't use an explicit state enum. It keeps a record of every order of its own that may still be on the book (in flight, resting or being cancelled), with a flag for whether a Rested event or a maker fill has confirmed it's resting. A record is dropped only when a cancel, expiry, rejection or final fill says the order is gone. A quote whose cancel is in flight stops being the working quote but stays in that list, so its open size still counts against the limit, and that's what keeps |position| ≤ max_inventory even with order latency. A fill for an order it no longer tracks still moves its position and cash, but raises no alarm. Adding the alarm is part of your version.

Limits. Inventory limits on worst-case position, a loss limit that pulls quotes and latches, a staleness pull, and a self-cross check before every send.

The repo's version has the first of these: the worst-case inventory limit, enforced through quote sizes. Its tests run it against a risk engine set to the same limit and require zero risk rejections. It also pulls both quotes when the market is one-sided, and its quotes are post-only and clamped so they never take from the market. The loss limit, the staleness pull and an explicit self-cross check are yours to add.

Measurement. Per run: fills, the inventory path (max |inventory| and time at the limit), the PnL split from the drill, markout curves from week 41, and messages per fill. Run multiple seeds and taker mixes and report distributions.

The repo measures less. Its P&L is cash-based: week 23's Inventory, worth cash + position × mark. The tests mark it at the simulation's true fair value, where it equals the strategy's edge over all its fills. Against noise flow on three seeds they require more than half a tick of edge per lot traded, and that the noise takers lose. It deliberately doesn't use week 45's risk::Positions, so week 42 doesn't depend on later code. The PnL split and markout curves are your additions, built on week 41's Markouts.

Reading the benchmark. The decision path is market data in → quotes out. w42.strategy_decision times, for each update, applying one L2 update to the strategy's BookBuilder and then market_top and decide, including the timer's own cost. Between updates, outside the timing, the strategy's commands are acknowledged as a venue would, so its order list stays at its steady size. A couple of hundred nanoseconds at p99 is plenty for the arithmetic. What the target really tests is that you kept allocation, formatting, logging, hash map lookups and dynamic dispatch out of the path. Precompute tick conversions and limits, keep the strategy state in a fixed-size struct, and push metrics to a cold path (week 19).

Pitfalls:

  • Your skewed bid crossing your own ask.
  • Inventory limits checked on the filled position only.
  • Treating a sent cancel as done.
  • Requoting on every update, so you never hold queue position.
  • Judging the strategy against takers that never adverse-select.
  • Quoting through a feed gap.
Capstone · in your exchange · crates/sim

A market maker against your own exchange

A two-sided, inventory-skewed market maker in crates/sim that trades through the real engine against simulated flow, with an inventory limit, requote thresholds, tracking of its own in-flight and resting orders, and an allocation-free market data → quote decision path.

cargo xtask test --week 42      # tests named w42_*
cargo xtask bench --week 42     # p50 / p99 / p99.9 on your machine
cargo xtask report --upload  # update this page and your lab
  • All week 42 tests pass w42_*
  • Market data → quote decision w42.strategy_decisiontarget p99 ≤ 250 ns

Feeds into

  • Week 43 takes the same concerns (order state, limits, PnL) to a second strategy type, pairs trading, and confronts the overfitting risk you met on Day 6.
  • Week 45 builds the real-time position and PnL engine (risk::Positions) that your loss limit and kill switch belong behind. The repo's market maker keeps its own cash-and-position accounting, so you can compare the two.