Skip to content

Market data desks

Trading and risk teams want software to take routine actions on its own: pause the firm's trading in a stock after a crash print, send an opening basket, roll a futures position, liquidate an account that breached margin. The danger is acting on one bad price: a single venue printing 5 % down, or a book where only three venues are quoting. This track runs eleven such desks through one simulated trading shift and shows each desk acting when independent venues agree, and handing the decision to a person when they do not.

It is for engineers and quants building automated trading, surveillance or risk services, and for the desk heads who own those decisions. It assumes you know order books and futures, and that you have seen one library decision made (Your first decision). Library terms are defined in the glossary.

A simulator stands in for your market-data feed

Every price, quote and volatility on this page comes from a seeded simulator (examples/markets/sim.py), not from a market-data feed. Nothing here is a backtest, and the dollar figures are illustrative. The simulator produces objects with the shape of a real feed (per-venue bid, ask, sizes and last trade; an implied-volatility surface; contract multipliers and tick sizes), so the desk code is the code you would run on real data once you replace the simulator. See Wiring a desk to a real feed.

uv run grounded-decision examples --track markets
uv run grounded-decision examples mkt_equity_halt

How every desk works

Each desk decides about one thing, its entity (a symbol, an account, a futures curve, a strategy), once per tick of the simulated shift:

  1. Read the current data from the simulator: venue quotes, last trades, implied volatilities or realised-volatility estimates.
  2. Turn it into evidence: a graph of which independent sources agree with which, summarised by snapshot_from_graph.
  3. Send the event, the evidence and the entity's saved state to handle_request (the JSON entry point), and save the returned state.
  4. Record the side effect in a ledger: the action's name on act, hold_for_human when a proposed action was held, await_next_event when the policy is waiting for another event and has nothing to propose.
flowchart LR
    S[simulator tick<br/>stands in for your feed] --> B[evidence builder:<br/>which venues agree?]
    B --> D[handle_request:<br/>six checks]
    ST[(saved state<br/>per entity)] --> D
    D --> ST
    D --> L{verdict}
    L -- act --> X[side effect:<br/>order, quote, kill switch]
    L -- hold --> H[trader / analyst / risk screen]

Each desk's naive policy is what an always-fire automation would do: halt on any -3 % print, send the basket at 09:30, roll on the calendar, and so on. The JSON summary's blocked_overconfident_acts counts how many of those the desk held.

From a book to evidence

Desks never pass a raw price to the decision. They pass a graph of which sources agree right now: venues whose midpoints are within a band of each other, crash prints within 1 % of each other, strikes whose implied volatilities line up. Every source is labelled "agrees with the consensus", so neighbour agreement (λ) is 1 and the evidence strength dλ² is simply the average number of agreeing neighbours per source. For a complete graph of \(k\) agreeing venues that is \(k - 1\): 11 for twelve equity venues, 5 for six futures venues.

When the data does not qualify (too few live venues, one venue out of line, a two-strike surface), the builder returns a fixed unsupported graph instead: twelve sources with three disjoint links. Its evidence strength is 0.5, below the 1.05 line, and its link density (0.045) sits inside the knife-edge window around its 0.20 cut-off, so both checks fail and the desk holds. It is a marker meaning "do not act automatically", not a measurement; the observations name the reason (spoof_print, thin_book, thin_open, thin_curve, calendar_spread_disagreement, thin_surface, realized_volatility_on_target, thin_realized_volatility).

Builder (in examples/markets/graphs.py) Sources Supported when Unsupported when
venue_agreement_evidence live venues at least 4 live, every midpoint within the band of the median fewer than 4 live, or any live venue out of line
crash_print_evidence venues whose last trade is at least 3 % down at least 8 crash prints within 1 % of each other fewer than 8 crash prints
constituent_quote_evidence live venue quotes of every basket stock every stock has at least 4 live venues any stock thin
calendar_spread_evidence paired front and back venue quotes both legs at least 4 live, every implied spread within 1 point a thin leg, or a spread out of line
volatility_surface_evidence live strikes at least 4 live and at least 6 agreeing strike pairs a two-strike sheet
realized_volatility_evidence realised-volatility estimates at least 6 agreeing pairs, median away from the target median within 1 point of the target, or too few

Two rules in the builders are product choices, not mathematics:

  • Every live venue must agree, not most. Five honest ES venues and one bad one still form a dense five-venue cluster (evidence strength 4), which would pass the neighbour-agreement check. A halt, liquidation or exercise must not treat that majority as support for the one odd print, so venue_agreement_evidence refuses it. The theorem behind the check would pass it; the builder is where your product rule lives, and it is tested.
  • At least four live venues. A complete graph of three venues has evidence strength 2, above the line. The builders treat a three-venue book as "not a book" rather than "a tiny cluster".

The library's unusual-cases check compares a candidate score with a shared calibration set of 40 synthetic scores (CALIBRATION_SCORES, 0.35 to 0.935). Every desk except the options-quoting desk sends 0.10, below all of them, so that check cannot fail on those desks. Every desk is compiled with Limits(require_conformal_calibration=True), so an empty calibration set fails the check instead of passing it.

What the simulator does and does not model

SimulatedMarket in examples/markets/sim.py is pinned by tests/test_markets.py. What the desks rely on:

  • No look-ahead. advance_one_minute moves prices from the current state plus fresh random draws. The scenario setters (set_calm_book, set_spoofed_book, set_flash_crash_book, set_thin_book, set_wing_implied_volatility, set_surface_mostly_stale, set_surface_all_live) rewrite the current book only, never history.
  • Textbook price dynamics. Equities, the cash index, futures and the volatility future have a variance that drifts back to 20 % volatility (a Heston-style model with illustrative parameters, not a calibration). One step moves prices far more than one real minute would, so a few steps show movement. Options have no price model of their own; their volatility lives on the surface.
  • Real contract details. Prices round to each instrument's tick: ES 0.25 with multiplier 50, SPX options 0.10 with multiplier 100, VX 0.05 with multiplier 1,000; AAPL, MSFT and JPM trade in lots of 100. The decision never sees dollars; the event payload carries them so a person reading the ledger can.
  • Sane quotes. Bid never above ask. A quote is live only when it is two-sided, fresh and has size on both sides; stale or zero-size quotes never become evidence. Half the posted spread is \(1 + 6\sqrt{v}\) ticks, where \(v\) is the instrument's current variance. Implied volatilities stay between 0.05 and 2.0.
  • A small universe. SPX cash, AAPL, MSFT, JPM, ESU5 and ESZ5, VXU5, a nine-strike SPX September surface, a five-strike ES options surface and a few single options. Enough for every desk, not a 500-name index.

It does not model a matching engine, order-book depth beyond the top, open interest, borrow, corporate actions, settlement, or any vendor's quirks.

The desks

Each desk below gives the situation, what to notice in its output, and the takeaway. Each row of the output is one tick (one decision point) of the shift, numbered from 0.

mkt_equity_halt: internal halt surveillance

The situation. The firm's surveillance service can pause the firm's own trading in a stock (an internal kill switch; exchange-wide halts are the listing exchange's call). Entity: the symbol. Policy: (test(quotes_normal) ; act(keep_trading) | test(halt_signal) ; act(halt))*. Naive: halt whenever any venue's last print is 3 % or more down.

uv run grounded-decision examples mkt_equity_halt

What to notice in the output.

  • Tick 0: AAPL quoted two-sided on twelve venues. Keep trading (evidence strength 11).
  • Tick 1: one venue prints -5 %. Naive halts. There is one crash print, not eight, so the evidence is the unsupported graph (spoof_print): hold halt for the surveillance analyst.
  • Tick 2: all twelve venues print about -4 % together. Twelve crash prints agree: halt (strength 11).
  • Tick 3: the book recovers. Keep trading.

The takeaway. One venue is a rumour; twelve venues moving together are a market move. The eleven-against-one majority in tick 1 is deliberately not treated as evidence for halting.

mkt_index_open: opening basket

The situation. The index desk sends a basket of AAPL, MSFT and JPM at the open. In the first minutes of continuous trading some stocks may not be quoting properly yet. (Market-on-open auction orders, with their pre-open cut-offs, are a different workflow.) Entity: SPX. Policy: (test(basket_ready_check) ; act(send_opening_basket))*. Naive: send at 09:30 regardless.

uv run grounded-decision examples mkt_index_open

What to notice in the output.

  • Ticks 0 and 1: each stock is quoted on only three venues (thin_open). Hold the basket both times.
  • Ticks 2 and 3: every stock is two-sided on all venues: a 36-quote complete graph, evidence strength 35. Send both times.

The takeaway. The graph is built from venue quotes of every stock, not from the three tickers: three tickers can never have more than three links, even on a healthy open.

mkt_fut_roll: ES calendar roll

The situation. The futures desk rolls an ES position from September to December. Entity: the ES curve. Policy: (test(hold_front) ; act(stay_in_front_month) | test(roll) ; act(roll_to_back_month))*. Naive: roll on the first day of roll week.

uv run grounded-decision examples mkt_fut_roll

What to notice in the output.

  • Tick 0: both legs quoted on three venues each (thin_curve). Hold the roll.
  • Tick 1: both legs two-sided, every venue pair's implied calendar spread within one point: strength 5. Roll.
  • Ticks 2 and 3: the front book is live. Stay in the front month.
  • The summary's suggested_roll_day_index=2 is which of four illustrative roll-day liquidity scores the max-of-samples prophet rule would pick: the bar is the best day of a past roll (past days 2.5, 1.5, 4.0, 3.0, so 4.0), the same rule as Cache admission. It is shown for comparison and never overrides the decision.

The takeaway. Roll when both legs show a real, agreeing market, not on a calendar date. The rule's 1/2 is a guarantee in expectation (half the best day's liquidity, on average over rolls), only if past roll days are independent draws like this week's; it says nothing about any single roll.

mkt_fut_margin: variation margin

The situation. Clearing risk liquidates an account whose ES position breaches maintenance margin. Entity: the account. Policy: (test(mark) ; act(keep_position) | test(breach) ; act(liquidate))*. Naive: liquidate on any adverse mark past maintenance.

uv run grounded-decision examples mkt_fut_margin

What to notice in the output.

  • Tick 0: a one-tick gain ($12.50 per lot) on a live book. Keep.
  • Tick 1: one venue marks a spike; the naive variation is -$2,400. Hold the liquidation for a margin clerk (unsupported graph).
  • Tick 2: every futures venue drops together. Liquidate (strength 5).
  • Tick 3: the book recovers. Keep.

The takeaway. Liquidating a customer on one venue's bad mark is an expensive, visible mistake; a clerk can confirm a one-venue spike in minutes.

mkt_idx_opt_quote: SPX options auto-quote

The situation. An options market maker publishes quotes strike by strike. Entity: one strike. Policy: (test(quote_request) ; act(publish_quote) | test(risk_pull) ; act(pull_quotes))*. Naive: quote every strike on the sheet.

uv run grounded-decision examples mkt_idx_opt_quote

What to notice in the output.

  • Tick 0: the at-the-money 4500 call on a nine-strike live surface. Publish (strength 3.56).
  • Tick 1: the far 3700 put shows implied volatility 1.65. The rest of the surface still agrees (strength 3.33, the outlier is simply an unlinked strike), so neighbour agreement passes. But the desk sends the strike's unusualness score, 1.6, which is above every calibration score: p = 1/41 ≈ 0.024, below 0.10. Hold on the unusual-cases check alone.
  • Tick 2: only two strikes have live markets (thin_surface). Hold.
  • Tick 3: risk asks to pull quotes on a live surface. Pull.

The takeaway. This is the one market desk where the unusual-cases check does real work: a strike far from the smile is held even though the surface around it is healthy. In production the score is how far the strike's implied volatility sits from your fitted smile, on the same scale as the calibration set.

mkt_fut_opt_exer: ES option exercise

The situation. Operations decides whether to exercise an ESU5 4500 call at expiry. Entity: the option. Policy: (test(exercise) ; act(exercise) | test(abandon) ; act(let_expire))*. Naive: exercise whenever intrinsic value is above zero.

uv run grounded-decision examples mkt_fut_opt_exer

What to notice in the output.

  • Tick 0: in the money by 2.00 with ES venues agreeing. Exercise (strength 5).
  • Tick 1: the same intrinsic value, but one ES venue is off. Hold.
  • Tick 2: out of the money on a live book. Let expire.
  • Tick 3: in the money again on a recovered book. Exercise.

The takeaway. The evidence is the ES venue graph, never the option's own last price: whether the option is in the money depends on the underlying, so that is the market that has to agree.

mkt_vol_target: realised-volatility overlay

The situation. A volatility-target overlay scales an equity book to 10 % volatility. Entity: the overlay. Policy: (test(rebalance) ; act(rebalance_overlay) | test(hold) ; act(stay))*. Naive: rebalance towards 10 % every day.

uv run grounded-decision examples mkt_vol_target

What to notice in the output.

  • Tick 0: seven realised-volatility estimates (different lookback windows) agree around 18 %, well above target. Rebalance (cut the overlay; strength 5.71).
  • Tick 1: six estimates sit on 10 % (realized_volatility_on_target). Naive trades anyway; the desk holds.
  • Tick 2: only two noisy estimates. Hold.
  • Tick 3: the estimates agree high again. Rebalance.

The takeaway. An overlay that trades inside its own target band pays costs for nothing. Here the builder encodes "close to target" as unsupported evidence; that is a product rule in the builder, not something the checks infer.

Desks that combine more than one decision or check

The last four desks put a second worker, a second asset or a second check next to the decision, using the pattern from the combinations track: you write the AND yourself.

mkt_basis_arb: cash and futures legs, both must act

The situation. A basis trade buys the cash index basket and sells ES futures when the basis is wide. Two workers decide the legs: one on SPX venues (buy_cash_basket), one on ES venues (sell_futures), and compose_parent acts only if both act. Naive: trade whenever the basis exceeds two ticks. (The SPX index itself is not tradable; in practice the cash leg is a basket or an index ETF. The simulator quotes the index directly for simplicity.)

uv run grounded-decision examples mkt_basis_arb

What to notice in the output. The ledger lists the cash rows first, then the futures rows: cash acts in both rounds (strength 11); futures hold in round 0 (one ES venue off) and sell in round 1 (strength 5). parent_verdicts is ["abstain", "act"]: no trade in round 0, the trade in round 1.

The takeaway. A two-legged trade with one leg priced off a bad venue is not half a good trade. Combine legs with AND; see Composing modules.

mkt_dispersion: index against names

The situation. A dispersion book sells index volatility against the single names, which only makes sense when every name is quoting. Entity: the SPX dispersion package. Policy: (test(signal) ; act(sell_index_volatility))*. Naive: always sell.

uv run grounded-decision examples mkt_dispersion

What to notice in the output.

  • Ticks 0 and 1: the basket is thin. Hold twice.
  • Ticks 2 and 3: every stock quotes on every venue. Trade (strength 35).
  • Beside the decision, the desk asks the router for a fingerprint of the basket graph: depth 1 is allowed (basket_hash_depth_1_allowed=True), depth 5 is refused (basket_hash_depth_5_allowed=False) because the default cost budget allows depth 2 at most. For a fixed depth the computation is polynomial; the cap is a budget, not an impossibility. The fingerprint does not affect the trade.

The takeaway. A trade against a basket needs the whole basket. Router refusals are answers about cost, independent of the decision; see Use the hardness router.

mkt_gamma_hedge: option fill, then ES hedge

The situation. An SPX options market maker hedges each call fill by selling ES futures. Entity: the market maker's account. Policy: (test(fill) ; test(hedge) ; act(send_hedge))*: a fill, then a hedge. Naive: hedge every fill immediately. The hedge size is contracts × delta × option multiplier ÷ futures multiplier: 50 calls at delta 0.52 is \(50 \times 0.52 \times 100 / 50 = 52\) ES contracts.

uv run grounded-decision examples mkt_gamma_hedge

What to notice in the output.

  • Tick 0: a fill of 50 SPX 4500 calls. The policy now waits for the hedge event: await_next_event, no failed checks, state saved on the account.
  • Tick 1: the hedge, against one bad ES venue. Hold send_hedge; naive would have sold 52 ES into a bad book.
  • Tick 2: a second fill of 20 calls. Waits again.
  • Tick 3: the hedge on a live book. Send 21 ES (20 × 0.52 × 100 / 50 = 20.8, rounded).

The takeaway. "Waiting for the next event" is not a hold: nobody needs to look at it. Save the state with the account, or the hedge event arrives at a policy that never saw the fill.

mkt_vol_term: VX against SPX options

The situation. A volatility desk fades gaps between VX futures and SPX option implied volatility. Entity: the strategy. Policy: (test(fade_signal) ; act(fade_volatility_gap))*. Naive: always fade.

uv run grounded-decision examples mkt_vol_term

What to notice in the output.

  • One bad VX venue: hold. A live VX book: fade (strength 5). A thin VX book: hold. Live again: fade.
  • Beside the decision, the desk checks that last week's hedge profit-and-loss distribution still looks like the book's reference, using the same distribution comparison as Canary latency with an allowance of \(8 \times 0.5^3 = 1.0\) (arbitrary units). A calm week is 0.079 away (calm_week_pnl_comparable=True); a jump-regime week is 4.35 away (jump_week_pnl_comparable=False). fade_approved is the AND of the live-book act and the calm-week comparison.

The takeaway. The question "has the hedge stopped behaving like it used to?" is a separate check that your service combines with the decision; it is not a sixth check inside it. The distance has no direction, so a block means "review", not "the hedge got worse".

What the shift did

From uv run grounded-decision examples --track markets --json (summary.side_effects and blocked_overconfident_acts):

Id Held What the naive policy would have done on top
mkt_equity_halt 1 halt on the one-venue -5 % print
mkt_index_open 2 send the basket into a half-open market, twice
mkt_fut_roll 1 roll on a three-venue curve
mkt_fut_margin 1 liquidate the account on one venue's spike
mkt_idx_opt_quote 2 publish the 1.65 wing and a two-strike sheet
mkt_fut_opt_exer 1 exercise off one bad ES price
mkt_vol_target 2 trade on target and on two noisy numbers
mkt_basis_arb 1 (the combined trade waits one round) trade the basis with a bad ES leg
mkt_dispersion 2 trade against a thin basket, twice
mkt_gamma_hedge 1 (plus 2 waiting rows) sell 52 ES into a bad book
mkt_vol_term 2 fade a bad VX print and a thin VX book

Every hold here is a scenario the simulator was told to produce. The table shows that each desk holds what it should; it says nothing about how often a real feed produces such moments. Measure that on your own recorded data: Estimate impact before launch.

Wiring a desk to a real feed

The simulator is the only part you replace. Everything downstream of a list of VenueQuote or ImpliedVolatilityPoint stays the same.

1. Fill the data objects from your feed. Write one function per data type that reads your quote cache (an in-process order book, Redis, a shared-memory ring buffer) and returns the dataclasses from sim.py:

Object Source in a real system
VenueQuote (equities) per-venue top of book and last sale from direct exchange feeds or the consolidated tape
VenueQuote (futures) the futures exchange's market-data feed (CME for ES, Cboe Futures Exchange for VX)
ImpliedVolatilityPoint your options pricing service, solving implied volatility from live option quotes
is_live / is_stale your feed handler's staleness flag: no update within N seconds, one-sided, or zero size
reference price the last calm consolidated price before the move (for crash detection)
realised-volatility list the portfolio's realised volatility over several lookback windows, from your risk system
CALIBRATION_SCORES scores of recent automated decisions later confirmed correct, refreshed daily from your decision log

Delete SimulatedMarket.advance_one_minute and the set_* scenario methods. In the desk code, every market.quotes_by_symbol[symbol] becomes a call to your function.

2. Rebuild evidence at decision time. Call the builder on the latest book every time a desk is about to decide. A snapshot cached for a second is fine; reusing one from before the trigger defeats the purpose. The decide call itself has a 50 ms target (checked by a test on the library, not a timer in your service); measure your builder plus decide at your tick rate (Limits and performance).

3. Tune the bands and minimums. The agreement bands in desks.py (STOCK_AGREEMENT_BAND_DOLLARS, ES_AGREEMENT_BAND_POINTS, …) and the minimums in graphs.py (MINIMUM_LIVE_VENUES, MINIMUM_CRASH_PRINTS) are business choices. Set a band from the normal cross-venue dispersion of midpoints on quiet days (for example its 99th percentile), and a minimum from how many independent venues you require before software may act alone.

4. Save state per entity. Store record["extras"]["state"] against the desk's entity key in a database row or key-value store, with the policy version, and send it back on the next request (State and policy versions).

5. Connect the side effects. On act, do what the action names: send the order to your order or execution management system, publish or pull quotes, flip the internal kill switch, submit the exercise instruction. On hold_for_human, put record["proposed_action"], record["reason"] and the failed checks on the responsible person's screen (trader, surveillance analyst, risk manager, margin clerk, operations), with a deadline and a default for when nobody answers in time (for a hedge, usually "hedge on the next clean minute"). On await_next_event, only save the state.

Limits

  • Simulated data. Seeded and synthetic in the shape of venue books, prints and a volatility surface. The holds are planted scenarios.
  • The checks do not know which way the market moved. They ask whether independent sources agree, not whether the agreed move supports the action. A desk's event (halt_signal, breach, roll) carries the direction; your trigger logic decides it.
  • The unusual-cases check is idle on ten of the eleven desks, which send a fixed low score. Send a real per-instrument score in production.
  • Not a pricing or execution engine. Multipliers and ticks are there so payload notionals are readable.
  • Regulatory and venue rules (trading halts, market-maker obligations, margin rules) are outside this library; the desks show where a decision is made, not whether it is permitted.

Adding a desk

For contributors:

  1. Name the entity, the naive policy in trading language, and the side effect on act and on hold.
  2. Pick an evidence builder (venues, crash prints, basket quotes, calendar spread, surface, realised volatility). Do not hand-type evidence numbers.
  3. Write a policy that marks those actions.
  4. Drive a SimulatedMarket through calm, spoofed, flash and thin books (or the surface equivalents) and tick it through LiveDesk from examples/livefeed.py.
  5. Write the desk docstring in the same shape as the others (problem, why always-fire fails, the graph, production wiring, scenario), register it in RUNNERS in examples/markets/desks.py, and add a section here with the id in backticks (a test checks).

More on adding examples: Development and automation. The live service desks track shows the same loop for cards, patients, moderation and on-call.