Market Making¶
Learning objectives
After reading this page you will understand:
- What a market maker is and why exchanges rely on them
- How to submit a two-sided quote with the
QUOTEcommand - What happens to a quote when one side fills (inactivation)
- How
quote_refresh_policycontrols inactivation behaviour - How MM obligations enforce minimum spread width and size
- How Market-Maker Protection (MMP) prevents excessive fills in a burst
- How
disconnect_behaviourdetermines what happens to your quotes if your gateway drops
Prerequisites: Configuration — you need a gateway configured
with role: MARKET_MAKER before the engine accepts quotes.
Gateway Commands — understand how to connect a gateway terminal.
What is a market maker?¶
In a real exchange, market makers are specialist participants who commit to continuously posting a two-sided market — a price at which they will buy (bid) and a price at which they will sell (ask). In return for this commitment, they often receive reduced transaction fees or regulatory benefits.
Their presence is what makes a market liquid: without market makers, a buyer who arrives when no seller is resting would have to wait indefinitely. Market makers ensure there is almost always a price to trade at.
EduMatcher models this with the MARKET_MAKER participant role, the QUOTE
command (which posts a bid and ask as a linked pair), and an optional
obligations framework that enforces spread and size constraints.
Configuring a market-maker gateway¶
A gateway must be assigned the MARKET_MAKER role in engine_config.yaml
before the engine will accept quotes from it:
gateways:
alf:
- id: MM01
description: Market maker
role: MARKET_MAKER
quote_refresh_policy: INACTIVATE_ON_ANY_FILL # default
disconnect_behaviour: CANCEL_QUOTES_ONLY # default
enforce_mm_obligation: true
mm_max_spread_ticks: 10 # max spread in ticks (10 ticks = $0.10 for tick_size=0.01)
mm_min_qty: 100 # minimum size on each side
A TRADER gateway that tries to send a QUOTE command will receive a
rejection: Quotes are only allowed for MARKET_MAKER participants.
The QUOTE command¶
A quote is a single atomic operation that posts one bid order and one ask
order under a shared QUOTE_ID. Both legs are ordinary LIMIT orders in the
book — the only difference is that they are tracked together for lifecycle
management.
Optional fields:
| Field | Default | Description |
|---|---|---|
QUOTE_ID= |
Auto-generated | Label for this quote pair; used in status events and cancel |
TIF= |
DAY |
Either DAY or GTC; controls whether the quote survives to the next session |
Validation rules¶
The engine rejects a quote if:
| Condition | Rejection reason |
|---|---|
Gateway role is not MARKET_MAKER |
Quotes are only allowed for MARKET_MAKER participants |
BID_PRICE >= ASK_PRICE |
Quote requires bid_price < ask_price |
| Either quantity \(\leq\) 0 | Quote quantities must be positive |
| Symbol is halted | {SYMBOL} is halted — quotes rejected during circuit breaker halt |
Spread exceeds mm_max_spread_ticks |
Spread {n} ticks exceeds max {m} |
Either side below mm_min_qty |
Quote size must be >= {n} |
| Sending a new quote replaces any existing quote for the same (gateway, symbol) pair | (no rejection; the existing quote is cancelled silently) |
Quote acknowledgement¶
On success:
The bid= and ask= values are the individual order IDs assigned to each leg.
These can be referenced individually in cancellation or via book queries.
Quote lifecycle¶
stateDiagram-v2
[*] --> ACTIVE : QUOTE accepted
ACTIVE --> INACTIVE_BID_FILLED : bid leg fills
ACTIVE --> INACTIVE_ASK_FILLED : ask leg fills
ACTIVE --> CANCELLED : QUOTE_CANCEL or gateway disconnect
ACTIVE --> REPLACED : new QUOTE submitted for same symbol
INACTIVE_BID_FILLED --> [*] : ask leg cancelled by engine
INACTIVE_ASK_FILLED --> [*] : bid leg cancelled by engine
CANCELLED --> [*]
REPLACED --> [*]
When inactivation occurs, the gateway receives a quote.status event:
This tells the market maker: "your bid was hit; your ask is now cancelled; you need to re-quote."
Quote refresh policy¶
The quote_refresh_policy config key controls what triggers inactivation:
| Policy | Inactivation trigger | Typical use |
|---|---|---|
INACTIVATE_ON_ANY_FILL |
Any partial or full fill on either leg | Conservative; re-quote after every fill event |
INACTIVATE_ON_FULL_FILL |
Only when a leg is completely filled | Allows partial fills to accumulate before re-quoting |
NEVER_INACTIVATE |
Never — both legs stay in the book until manually cancelled | High-volume automated MMs that manage their own inventory |
flowchart TD
FILL[Fill event received on a quote leg]
POL{quote_refresh_policy}
ANY_FILL[INACTIVATE_ON_ANY_FILL]
FULL_FILL[INACTIVATE_ON_FULL_FILL]
NEVER[NEVER_INACTIVATE]
PARTIAL{Partial fill?}
INACT[Inactivate quote\ncancel sibling leg\npublish quote.status]
STAY[Leave sibling in book\nno status event]
FILL --> POL
POL --> ANY_FILL
POL --> FULL_FILL
POL --> NEVER
ANY_FILL --> INACT
FULL_FILL --> PARTIAL
PARTIAL -->|yes| STAY
PARTIAL -->|no, full fill| INACT
NEVER --> STAY
MM obligations enforcement¶
When enforce_mm_obligation: true is set, the engine validates every QUOTE
command against two constraints:
Spread constraint¶
The spread in ticks must not exceed mm_max_spread_ticks. Example: if
tick_size = 0.01 and mm_max_spread_ticks = 10, then a quote of
BID=149.90 ASK=150.10 has a spread of 20 ticks and will be rejected.
Minimum size constraint¶
Both BID_QTY and ASK_QTY must be ≥ mm_min_qty. Posting 50 on one side
when mm_min_qty = 100 will be rejected.
Per-symbol overrides¶
Obligations can be configured with four levels of precedence, from lowest to highest:
global mm_obligation_defaults
└── per-symbol global policy (mm_obligation_policies[symbol])
└── per-gateway defaults (gateways[MM01]: mm_max_spread_ticks, ...)
└── per-gateway per-symbol policy (gateways[MM01].mm_obligations[symbol])
This lets you enforce tight spreads on liquid symbols while being more lenient on illiquid ones, all from a single config file.
Example:
mm_obligation_defaults:
enforce_mm_obligation: true
mm_max_spread_ticks: 20
mm_min_qty: 50
gateways:
MM01:
role: MARKET_MAKER
mm_obligations:
AAPL:
mm_max_spread_ticks: 5 # tighter spread required on AAPL
mm_min_qty: 200
Market-Maker Protection (MMP)¶
Real market makers are exposed to adverse selection: a rapid stream of fills on one side can mean an informed trader is hitting their price while they cannot adjust fast enough. Market-Maker Protection provides an automatic pause when fill activity exceeds a threshold.
The MMP parameters live in mm_obligation_defaults or per-gateway config:
| Parameter | Default | Meaning |
|---|---|---|
mmp_fill_count |
5 | Number of fills within the window that triggers MMP |
mmp_window_ns |
1,000,000,000 | Rolling window in nanoseconds (default: 1 second) |
max_requote_delay_ns |
500,000,000 | How long (ns) the MM has to re-quote before being flagged |
sequenceDiagram
participant E as Engine
participant MM as MM01 gateway
E-->>MM: order.fill.MM01 (fill 1)
E-->>MM: order.fill.MM01 (fill 2)
E-->>MM: order.fill.MM01 (fill 3)
E-->>MM: order.fill.MM01 (fill 4)
E-->>MM: order.fill.MM01 (fill 5 - MMP threshold reached)
note over E: MMP activates and requote deadline is set
MM->>E: QUOTE\|SYM=AAPL\|... (re-quote within deadline)
note over E: MMP reset
What the MM operator sees in the terminal when MMP fires:
[09:30:01.002] FILL 7c4a91e2 qty=100 @209.8 remaining=400 [PARTIAL]
[09:30:01.003] FILL 7c4a91e2 qty=100 @209.8 remaining=300 [PARTIAL]
[09:30:01.004] FILL 7c4a91e2 qty=100 @209.8 remaining=200 [PARTIAL]
[09:30:01.005] FILL 7c4a91e2 qty=100 @209.8 remaining=100 [PARTIAL]
[09:30:01.006] FILL 7c4a91e2 qty=100 @209.8 remaining=0 [FILLED]
[09:30:01.006] CANCELLED be2170fd
[09:30:01.006] QUOTE INACTIVE_BID_FILLED Q1
The bot (or operator) must re-quote within max_requote_delay_ns to avoid
an obligation breach flag.
Cancelling a quote¶
This cancels both legs atomically. The gateway receives a quote.status
event with state CANCELLED.
Startup seeding — pre-loading quotes from config¶
A live market maker's first action after connecting is to post a quote. In a classroom or demo environment it is useful to have quotes already in the book before any participant connects, so the book is never completely empty and price discovery can begin from a known starting point.
This is done with the market_maker_quotes key under each symbol in
engine_config.yaml:
symbols:
AAPL:
tick_size: 0.01
market_maker_quotes:
- gateway_id: MM01 # must be a configured MARKET_MAKER gateway
quote_id: SEED-MM01-AAPL
bid_price: 149.95
ask_price: 150.05
bid_qty: 500
ask_qty: 500
tif: DAY # or GTC — see below
seed_once: true # default — inject only on the very first startup
The engine injects these quotes during _load_config(), which runs at every
startup after GTC orders are restored. Each entry creates a linked bid/ask pair
in the order book exactly as if the market-maker gateway had sent a QUOTE
command — the only difference is that no live gateway connection is needed.
If the MM gateway later connects and sends a new QUOTE command for the same
symbol, the seed is silently replaced (same behaviour as any re-quote).
Controlling when seeds are applied: seed_once¶
The seed_once field controls whether a seed is a one-off primer or a
permanent daily seed:
seed_once |
When is the seed injected? | Typical use |
|---|---|---|
true (default) |
Only on the very first startup for that symbol — never again once the book has history | Initial liquidity for a brand-new symbol; the MM takes over from day 2 onwards |
false |
On every startup, regardless of whether the symbol has been traded before | Demo setups where a specific spread must always be the opening quote |
"First startup" is detected via book_stats.json: at every shutdown the
engine writes src/data/book_stats.json with an entry for every configured
symbol. If that entry is absent when the engine starts, the symbol is new.
Once it exists — even if no trades happened yet — the symbol is considered
known and seed_once seeds are skipped.
flowchart TD
START[Engine startup\n_load_config]
LOADSTATS[Load book_stats.json]
FOREACH[For each market_maker_quotes entry]
SEEDONCE{seed_once?}
KNOWN{Symbol already in\nbook_stats.json?}
SKIP[Skip — symbol has\nprior history]
INJECT[Inject seed quote\ninto order book]
START --> LOADSTATS --> FOREACH --> SEEDONCE
SEEDONCE -->|false| INJECT
SEEDONCE -->|true| KNOWN
KNOWN -->|yes| SKIP
KNOWN -->|no| INJECT
Resetting to \"first day\" for testing
Delete src/data/book_stats.json before starting the engine. Every
symbol will appear new again and seed_once seeds will be re-injected.
This is the standard way to reset a demo exchange to day-one state.
Seeding and the startup order¶
Seed quotes are injected after GTC resting orders are restored. This means a GTC resting order from a previous session may immediately cross against a seed quote — that trade fires at startup, before any gateway connects:
sequenceDiagram
participant E as Engine (startup)
note over E: Step 1 — restore GTC orders
E->>E: Load gtc_orders.json → GTC SELL AAPL 100@149.00 restored
note over E: Step 2 — inject MM seed quotes (only if new symbol)
E->>E: SEED-MM01-AAPL BID=149.95 injected
note over E: bid 149.95 ≥ resting ask 149.00 → immediate match!
E->>E: trade.executed AAPL 100@149.00 published
E->>E: quote.status INACTIVE_BID_FILLED (if INACTIVATE_ON_ANY_FILL)
note over E: Step 3 — ready for connections
Startup trades fire before any gateway is connected
If a GTC sell order rests at a price that a seed bid would cross, the
trade executes at startup. Fill events are published to the bus —
pm-clearing and pm-stats record them — but no participant terminal is
connected yet. The MM gateway's inbox will have the fill event waiting
when it connects.
What happens on subsequent days?¶
The table below summarises the full session-boundary behaviour:
| Component | Saved at shutdown? | Day 2+ behaviour |
|---|---|---|
| GTC participant orders | Yes → gtc_orders.json |
Restored into book before seed injection |
| GTC combo parent state | Yes → gtc_combos.json |
Restored; parent-child links rebuilt |
| Book stats (OHLCV, last prices) | Yes → book_stats.json |
Restored; provides the "known symbol" flag |
MM seed quotes (seed_once: true) |
No | Skipped — symbol is already known |
MM seed quotes (seed_once: false) |
No | Re-injected on every startup |
| DAY participant orders | No (expired at shutdown) | Must be re-submitted by participants |
Why quote legs are never saved to gtc_orders.json
At shutdown the engine skips quote-origin orders when writing
gtc_orders.json. Config seeds are the authoritative source for seed
quotes — saving the legs would create duplicate orders in the book on the
next startup when seeds are also re-injected.
Choosing tif for seed quotes¶
tif value |
Behaviour during the session | Cross-session behaviour |
|---|---|---|
DAY |
Quote expires at end of trading day (Ctrl-C / SIGTERM) | Re-seeded from config on next startup (if conditions met) |
GTC |
Quote survives an ATC/session reset within the same engine run | Still re-seeded from config on next startup (legs not persisted) |
For most setups tif: DAY is the right choice. Use tif: GTC only if you
want the seed to survive an intra-session CLOSED → PRE_OPEN cycle within
the same running engine instance.
Disconnect behaviour¶
If a market maker's gateway disconnects (Ctrl-C or network failure), the engine must decide what to do with any resting quotes:
disconnect_behaviour |
What happens to quotes | What happens to plain orders |
|---|---|---|
CANCEL_QUOTES_ONLY |
All quotes cancelled | Resting orders remain in book |
CANCEL_ALL |
All quotes cancelled | All resting orders also cancelled |
LEAVE_ALL |
Nothing cancelled | Nothing cancelled |
The default is CANCEL_QUOTES_ONLY, which is the most appropriate for
market making: quotes represent ongoing commitment and should not linger after
the MM disconnects.
Full worked example¶
Scenario: MM01 provides a continuous two-sided market in AAPL.
GW02 is a customer who buys 200 shares.
# Configure engine_config.yaml:
# gateways.MM01.role = MARKET_MAKER
# gateways.MM01.enforce_mm_obligation = true
# gateways.MM01.mm_max_spread_ticks = 10
# gateways.MM01.mm_min_qty = 100
# gateways.MM01.quote_refresh_policy = INACTIVATE_ON_ANY_FILL
# 1. MM01 posts a quote
MM01> QUOTE|SYM=AAPL|BID=149.95|ASK=150.05|BID_QTY=500|ASK_QTY=500|QUOTE_ID=q1
[14:30:00] QUOTE ACK q1 bid=ord-001 ask=ord-002
# 2. GW02 buys 200 at market — hits the ASK leg
GW02> NEW|SYM=AAPL|SIDE=BUY|TYPE=MARKET|QTY=200
[14:30:01] FILL ord-003 AAPL BUY 200@150.05
# MM01 sees:
[14:30:01] FILL ord-002 AAPL SELL 200@150.05 (partial fill on ask)
[14:30:01] QUOTE INACTIVE_ASK_FILLED q1 (bid leg auto-cancelled)
# 3. MM01 re-quotes
MM01> QUOTE|SYM=AAPL|BID=149.95|ASK=150.05|BID_QTY=500|ASK_QTY=300|QUOTE_ID=q2
[14:30:01] QUOTE ACK q2 bid=ord-004 ask=ord-005
After the customer fill, MM01's bid leg (ord-001) was cancelled by the
engine automatically. The market maker posted a new quote q2 with 300 on the
ask to reflect the inventory consumed.
Config reference summary¶
gateways:
MM01:
role: MARKET_MAKER
quote_refresh_policy: INACTIVATE_ON_ANY_FILL # or INACTIVATE_ON_FULL_FILL / NEVER_INACTIVATE
disconnect_behaviour: CANCEL_QUOTES_ONLY # or CANCEL_ALL / LEAVE_ALL
enforce_mm_obligation: true
mm_max_spread_ticks: 10
mm_min_qty: 100
mm_obligations: # per-symbol overrides (optional)
TSLA:
mm_max_spread_ticks: 20
mm_min_qty: 50
MM quote identification and quote-leg mapping¶
Scope¶
The rest of this chapter explains exactly how EduMatcher represents a market-maker quote, how a quote is mapped to its two child orders, how the MM can identify that a fill belongs to the currently active quote, and what the MM must do to cancel or re-issue a quote.
It answers these questions:
- Is a quote identified by
quote_id, or by(gateway_id, symbol)? - How is a quote mapped to the resting order(s)?
- How does the MM learn that one quote leg was taken?
- When is sibling cancellation automatic, and when must the MM do it?
- What state and data must the MM keep in order to re-quote safely?
The chapter describes the engine behavior in full details
Executive summary¶
Identity model¶
EduMatcher uses two identifiers for a quote, and they serve different roles:
- Active quote slot in the engine:
(gateway_id, symbol) - External correlation identifier:
quote_id
Practical meaning:
- The engine allows at most one active quote slot per
(gateway_id, symbol). - A new
QUOTEon the same gateway and symbol replaces the previous active slot. quote_ididentifies the specific logical quote instance occupying that slot.
So the answer is:
- The engine routes and replaces quotes by
(gateway_id, symbol). - The MM should correlate business events by
quote_id.
Mapping model¶
Each quote becomes two ordinary limit orders:
- bid leg:
side=BUY - ask leg:
side=SELL
The mapping is explicit in both directions:
QuoteIndex[(gateway_id, symbol)] -> QuoteEntry(quote_id, bid_order_id, ask_order_id)- each leg order stores
origin=QUOTEandquote_id=<same quote_id>
How the MM identifies a fill as belonging to the active quote¶
The fill arrives as a normal order.fill.<gateway_id> event for a leg order_id.
Important implementation detail:
order.filldoes not includequote_idin its published payload.
Therefore the MM must identify quote-leg fills by keeping the mapping returned
by quote.ack:
quote_id -> {symbol, bid_order_id, ask_order_id}order_id -> {quote_id, leg_side, symbol}
Then the rule is simple:
- if
fill.order_id == bid_order_id, the fill belongs to the bid leg of that quote - if
fill.order_id == ask_order_id, the fill belongs to the ask leg of that quote
When cancellation is automatic¶
Automatic sibling cancellation depends on quote_refresh_policy:
INACTIVATE_ON_ANY_FILL: sibling leg is auto-cancelled on any fillINACTIVATE_ON_FULL_FILL: sibling leg is auto-cancelled only when the filled leg reachesremaining_qty=0NEVER_INACTIVATE: no automatic sibling cancellation due to fills
The quote_refresh_policy is set per gateway in engine_config.yaml under gateways.alf[].quote_refresh_policy.
What the MM needs in order to re-issue a quote¶
To re-issue a quote, the MM needs:
- the symbol
- the new bid price and ask price
- the new bid quantity and ask quantity
- the desired
TIF - a new or reused client-side quote label strategy for
QUOTE_ID - the current quote mapping so fills and cancels can be correlated correctly
In practice the MM should keep:
- current active quote per symbol
- reverse map from child
order_idtoquote_id - local state showing whether the quote is active, cancelled, or inactivated
- local pending-submission state for edge cases where a fill arrives before
quote.ack
Exact implementation model¶
Quote identity: quote_id vs (gateway_id, symbol)¶
Both are used, but they are not interchangeable.
(gateway_id, symbol) is the engine's active-slot key¶
Internally the engine stores active quotes in QuoteIndex, keyed by:
gateway_idsymbol
Consequences:
- there can be only one active quote slot per gateway and symbol
QUOTE_CANCELtargets the active slot by symbol, not byquote_id- a new
QUOTEon the same symbol replaces the currently indexed slot
quote_id is the MM's logical correlation key¶
quote_id is:
- optional on submission
- auto-generated by the engine if omitted
- returned in
quote.ack - carried in
quote.status - copied into both child orders internally
The engine therefore treats quote_id as the identifier for the specific quote
generation, but not as the routing key for cancel/replace.
Quote-to-leg mapping¶
Engine-side mapping¶
The engine stores one QuoteEntry per active quote slot:
quote_idgateway_idsymbolbid_order_idask_order_id
That gives deterministic lookup from quote slot to its two child orders.
Order-side mapping¶
Each child order stores:
origin=QUOTEquote_id=<quote_id>
That gives deterministic lookup from child order back to the originating quote.
What the MM actually receives¶
The MM does not receive the full internal Order object on order.fill.
The fill payload includes fields such as:
order_idfill_qtyfill_priceremaining_qtystatussymbolsideorder_typetifqtyprice
It does not include quote_id.
So the MM must not assume that order.fill alone is enough to identify the
logical quote instance unless the MM already persisted the order-id mapping from
quote.ack.
Quote lifecycle messages and what they mean¶
quote.ack.<gateway_id>¶
Purpose: acceptance or rejection of a quote request.
Fields:
quote_idacceptedreasonbid_order_idask_order_id
Important details:
- on rejection,
accepted=falseand no leg IDs are usable - on acceptance,
bid_order_idandask_order_idare the critical correlation keys - on explicit
QUOTE_CANCEL, the engine currently sends an acceptance ack with thequote_id, but no leg IDs
quote.status.<gateway_id>¶
Purpose: quote lifecycle transition.
Current status values emitted by the engine:
ACTIVEINACTIVE_BID_FILLEDINACTIVE_ASK_FILLEDCANCELLED
Important detail:
REJECTEDis not aquote.statusvalue in the engine- rejection is reported only through
quote.ack accepted=false
order.fill.<gateway_id>¶
Purpose: execution detail for one child order.
This is the message that tells the MM that one quote leg traded.
order.cancelled.<gateway_id>¶
Purpose: cancellation detail for one child order.
This is how the MM learns that the sibling leg was actually cancelled as an order-book event.
Real message ordering in the engine¶
This is the most important correctness section in the note.
The engine does not always emit messages in the intuitive order
quote.ack -> quote.status ACTIVE -> later fill -> later inactive/cancel.
Normal resting-quote case¶
If neither leg matches immediately on insert, the MM will typically see:
quote.ack accepted=truequote.status ACTIVE- later, if a leg trades, one or more
order.fill - if policy inactivates the quote,
order.cancelledfor the sibling leg quote.status INACTIVE_*
Immediate-match-on-insert edge case¶
The engine inserts the two quote legs into the books before it emits
quote.ack and quote.status ACTIVE.
Therefore, if one leg matches immediately on entry, the MM may observe:
order.fillfor the filled leg- possibly
order.cancelledfor the sibling leg quote.status INACTIVE_*- only then
quote.ack accepted=true - and then
quote.status ACTIVE
That ordering is counter-intuitive, but it is what the current engine code does.
Practical consequence for the MM:
- the MM must tolerate fills arriving before
quote.ack - the MM must be able to reconcile those early fills once
quote.ackprovidesbid_order_idandask_order_id
Explicit cancel case¶
On QUOTE_CANCEL|SYM=<symbol>, the engine currently does:
- cancel any still-resting bid/ask child orders, emitting
order.cancelledper resting child - emit
quote.status CANCELLED - emit
quote.ack accepted=true
So in this path, quote.status CANCELLED comes before the final successful
quote.ack.
Replace-by-new-quote case¶
On a new QUOTE for the same (gateway_id, symbol):
- the engine removes the previous
QuoteIndexentry - cancels any still-resting old quote legs
- emits old-quote
quote.status CANCELLED - creates new legs
- eventually emits new-quote
quote.ack accepted=true - emits new-quote
quote.status ACTIVE
Again, the lifecycle is not simply “ack first, status second” across all paths.
ALF interaction examples¶
Example 1: normal quote, later bid-leg fill, automatic sibling cancel¶
Assume:
- gateway:
MM01 - symbol:
AAPL - policy:
INACTIVATE_ON_ANY_FILL - MM chooses
QUOTE_ID=Q123
MM submits quote¶
Engine accepts and returns mapping¶
Gateway output will look like:
At this point the MM must persist:
Q123 -> {symbol=AAPL, bid_order_id=B1A8C2D4, ask_order_id=S9F3E1AA, tif=DAY}B1A8C2D4 -> {quote_id=Q123, leg=BID, symbol=AAPL}S9F3E1AA -> {quote_id=Q123, leg=ASK, symbol=AAPL}
Later the bid leg is hit¶
Suppose another participant sells into the MM bid. The MM may see:
[09:31:02] FILL B1A8C2D4 AAPL BUY 100@209.80
[09:31:02] CANCELLED S9F3E1AA
[09:31:02] QUOTE INACTIVE_BID_FILLED Q123
How the MM identifies the fill as belonging to the active quote¶
The fill correlation logic is:
- read
order.fill.order_id - look it up in
order_id -> quote_id - conclude that
B1A8C2D4belongs toQ123 - because it is the stored
bid_order_id, conclude that the bid leg ofQ123was taken
Does the MM need to cancel the sibling leg?¶
No, not in this policy.
Under INACTIVATE_ON_ANY_FILL, the engine already did both of these things:
- removed the quote from the active quote index
- cancelled the sibling ask leg automatically
The order.cancelled and quote.status INACTIVE_BID_FILLED messages are the
observable confirmation of that automatic cleanup.
Example 2: full-fill-only policy¶
Assume policy INACTIVATE_ON_FULL_FILL.
If the bid leg is only partially filled, the MM may see:
And that may be all.
In that case:
- no sibling auto-cancel occurs yet
- no
quote.status INACTIVE_*is emitted yet - the quote remains active in the engine
The MM therefore continues to treat the quote as active until either:
- the filled leg becomes fully filled, causing inactivation, or
- the MM explicitly cancels or replaces the quote
Example 3: NEVER_INACTIVATE¶
Assume policy NEVER_INACTIVATE.
The MM may see:
And no automatic sibling cancel, and no INACTIVE_* status.
Meaning:
- the quote slot remains active in the engine
- the sibling ask leg remains resting unless something else removes it
- the MM must decide whether to keep quoting, cancel, or replace
If the MM wants a fresh two-sided quote immediately, it has two valid options:
Option A: explicit cancel then new quote¶
Typical resulting events:
[09:31:02] CANCELLED S9F3E1AA
[09:31:02] QUOTE CANCELLED Q123 Cancelled by participant
[09:31:02] QUOTE ACK Q123
Then the MM submits a new quote.
Option B: direct replacement quote¶
The engine will:
- remove the current active quote slot for
(MM01, AAPL) - cancel any surviving old child orders
- emit
quote.status CANCELLEDfor the old quote - install the new quote
This means the MM does not have to send QUOTE_CANCEL first if it is
immediately replacing the quote with another quote on the same symbol.
Example 4: immediate fill before quote.ack¶
This is the tricky case that every robust MM must handle.
Suppose the quote is marketable as soon as it is inserted. The MM may see:
[09:30:00] FILL B1A8C2D4 AAPL BUY 500@209.80
[09:30:00] CANCELLED S9F3E1AA
[09:30:00] QUOTE INACTIVE_BID_FILLED Q123
[09:30:00] QUOTE ACK Q123 bid=B1A8C2D4 ask=S9F3E1AA
[09:30:00] QUOTE ACTIVE Q123
Yes, that ordering is possible in the current engine.
What the MM must do in this case¶
The MM needs one more local concept:
pending_quote_by_symbol
When it sends:
it should locally remember that there is a pending quote submission for AAPL.
Then if an early fill arrives before quote.ack, the MM can:
- temporarily buffer the fill by
(gateway_id, symbol) - wait for
quote.ack - use
bid_order_idandask_order_idfrom the ack to retroactively resolve that buffered fill - then continue normal state handling
Without this pending-submit buffer, the MM cannot deterministically map an early fill to the quote until the ack arrives.
MM operator workflow via pm-alf-console¶
This section is intentionally operator-oriented. It shows what a market maker using the interactive ALF gateway will actually type, what the gateway will print back, and what the operator must remember while managing live quotes.
What the operator sees on screen¶
The gateway displays quote and order lifecycle events in a compact terminal format.
Important display behavior:
QUOTE ACKshows thequote_idand the first 8 characters of each child order IDFILLshows the first 8 characters of the filled child order IDCANCELLEDshows the first 8 characters of the cancelled child order IDQUOTE INACTIVE_*andQUOTE CANCELLEDshow thequote_id
So a human operator correlates events using:
quote_idfor the logical quote instance- the displayed 8-character order-id prefixes for the bid and ask legs
To reduce manual correlation load in fast markets, the gateway supports:
QLEGS shows per-gateway quote legs with explicit Filled and Filled?
columns, so the operator can identify the traded leg without mentally joining
multiple event lines.
A programmatic MM should keep the full IDs from the message payload. A human operator using the terminal will usually work from the 8-character prefixes printed by the gateway.
Typical manual quoting session¶
Assume the MM operator is connected as MM01 and wants to quote AAPL.
Step 1: submit a quote¶
Operator input:
Typical gateway output when the quote rests normally:
What the operator must remember immediately¶
At this moment the operator should record or mentally associate:
- symbol:
AAPL - quote id:
Q123 - bid leg prefix:
7c4a91e2 - ask leg prefix:
be2170fd - intended prices:
209.80 / 210.20 - intended sizes:
500 / 500 - current refresh policy for that gateway
The minimum safe correlation set for a human operator is:
Q123- bid leg short ID
- ask leg short ID
Step 2: identify which leg filled¶
Later the operator may see:
The operator identifies this as the quote bid leg because:
- the filled order-id prefix
7c4a91e2matches the bid ID shown inQUOTE ACK - therefore the bid side of
Q123traded
If instead the operator saw:
that would mean the ask side of Q123 traded.
With QLEGS, this becomes a direct read:
Interpretation pattern:
Filled?=YESwithLeg=BUYmeans the bid leg tradedFilled?=YESwithLeg=SELLmeans the ask leg tradedRem>0with leg statusNEWorPARTIALidentifies still-active exposure
Step 3: interpret what happens next¶
What the operator should do next depends on the configured refresh policy.
Case A: INACTIVATE_ON_ANY_FILL¶
Typical terminal output:
[09:31:02.417] FILL 7c4a91e2 qty=100 @209.8 remaining=400 [PARTIAL]
[09:31:02.418] CANCELLED be2170fd
[09:31:02.418] QUOTE INACTIVE_BID_FILLED Q123
Operator interpretation:
- the bid leg traded
- the engine automatically cancelled the sibling ask leg
- the quote is no longer active
- the operator may now prepare and submit a replacement quote
Operator action:
- no manual
QUOTE_CANCELis needed - compute the new bid/ask and send a fresh
QUOTE - optionally run
QLEGS|SYM=AAPL|SHOW=ALLbefore re-quoting to confirm final leg state
Case B: INACTIVATE_ON_FULL_FILL¶
Typical terminal output after a partial fill:
Operator interpretation:
- one quote leg traded
- the quote may still be active
- no sibling cancellation has happened yet
- the quote stays live until full fill or explicit operator action
Operator action choices:
- leave the quote working if that is desired
- send
QUOTE_CANCEL|SYM=AAPLif the quote should be withdrawn now - send a replacement
QUOTEif the operator wants to replace the current quote immediately
Case C: NEVER_INACTIVATE¶
Typical terminal output:
Operator interpretation:
- the bid leg traded
- the quote remains active unless manually changed or removed by another engine event
- the sibling ask leg is still live
Operator action choices:
- do nothing and keep the surviving structure live
- cancel explicitly with
QUOTE_CANCEL|SYM=AAPL - replace directly by sending a new
QUOTEonAAPL
Explicit cancel workflow from the terminal¶
If the operator wants to cancel the current active quote for AAPL:
Typical output:
[09:31:20.010] CANCELLED 7c4a91e2
[09:31:20.011] CANCELLED be2170fd
[09:31:20.011] QUOTE CANCELLED Q123 Cancelled by participant
[09:31:20.012] QUOTE ACK Q123
Important operator note:
QUOTE_CANCELis symbol-based, notquote_id-based- it targets whatever quote is currently active in the
(gateway_id, symbol)slot
So before typing QUOTE_CANCEL|SYM=AAPL, the operator should be sure that the
currently active AAPL quote is the one they intend to remove.
Direct replacement workflow from the terminal¶
In many cases the operator does not need a separate cancel command. The engine already supports replace-by-new-quote semantics.
Example:
Typical interpretation:
- if
Q123was still the active AAPL quote, the engine will remove it first - any still-resting old legs are cancelled
- old quote lifecycle ends with
QUOTE CANCELLED Q123 - new leg IDs are allocated for
Q124 - the operator then receives a new
QUOTE ACKforQ124
This is often the cleanest manual workflow for active MM operation.
What the operator must remember after every QUOTE ACK¶
After every accepted quote, the operator should refresh their working state.
The operator should remember:
- current symbol being quoted
- current
quote_id - bid leg short ID
- ask leg short ID
- whether the current policy auto-inactivates on any fill, only full fill, or never
- whether a fill already happened and whether the quote is still active
If the operator loses track of which leg IDs belong to which quote, then later
FILL and CANCELLED lines become ambiguous.
Operator fill-handling checklist¶
When a FILL line appears, the operator should follow this sequence:
- Match the displayed order-id prefix against the latest
QUOTE ACKfor that symbol. - Determine whether the bid leg or ask leg traded.
- Check
remaining=and[status]on the fill line. - Watch immediately for either:
CANCELLEDon the sibling legQUOTE INACTIVE_BID_FILLEDQUOTE INACTIVE_ASK_FILLED- Decide whether the quote is now inactive or still live.
- If inactive, compute and submit the next quote.
- If still live, decide whether to leave it working, cancel it, or replace it.
What is needed by the MM to re-issue a quote¶
To re-issue a usable two-sided quote, the operator or MM logic must know:
SYM- new
BID - new
ASK - new
BID_QTY - new
ASK_QTY TIF- new
QUOTE_IDif the MM uses explicit client-side quote labels
Typical re-quote command:
Operationally, the MM also needs to know whether the old quote is actually gone.
Safe trigger points for re-quoting are:
- after
QUOTE INACTIVE_* - after
QUOTE CANCELLED - after a deliberate replace-by-new-quote decision
Recommended operator habit¶
For manual terminal operation, the safest habit is:
- always supply your own
QUOTE_ID - after each
QUOTE ACK, note the bid and ask short IDs - after each
FILL, identify which leg traded by matching the short ID - wait for
QUOTE INACTIVE_*if the gateway auto-inactivates - submit the next
QUOTEonly once you know whether the old quote is still active
This reduces confusion during fast markets and makes post-trade reconciliation much easier.
flowchart TD
A[Operator sends QUOTE] --> B[Gateway prints QUOTE ACK with quote_id bid_id ask_id]
B --> C[Operator records quote_id and both leg IDs]
C --> D[Gateway prints FILL for one leg]
D --> E[Operator matches fill order_id to bid or ask leg]
E --> F{Did engine auto-inactivate?}
F -->|Yes| G[See sibling CANCELLED and QUOTE INACTIVE_*]
F -->|No| H[Quote may still be active]
G --> I[Compute replacement quote]
H --> J{Leave live, cancel, or replace?}
J -->|Cancel| K[Send QUOTE_CANCEL|SYM=...]
J -->|Replace| L[Send new QUOTE on same symbol]
J -->|Leave live| M[Continue monitoring fills]
I --> L
K --> L
Recommended MM-side local state¶
The MM should maintain these structures.
Active quote record per symbol¶
active_quote[symbol] = {
quote_id,
bid_order_id,
ask_order_id,
bid_price,
ask_price,
bid_qty,
ask_qty,
tif,
state,
}
Reverse index by order ID¶
Pending submit state¶
This exists specifically to survive the immediate-fill-before-ack edge case.
What the MM must know to re-issue a quote¶
To issue a new quote, the MM must compute and send:
SYMBIDASKBID_QTYASK_QTY- optional
TIF - optional
QUOTE_ID
Example:
What the MM should normally do before re-issuing¶
If policy auto-inactivates¶
Recommended sequence:
- receive
order.fillfor the quote leg - receive
quote.status INACTIVE_*or otherwise confirm old quote is no longer active - compute the new two-sided quote
- send the new
QUOTE
Manual QUOTE_CANCEL is usually unnecessary here.
If policy does not auto-inactivate¶
The MM must choose one of:
- send
QUOTE_CANCEL|SYM=<symbol>, wait for cancellation lifecycle, then send a newQUOTE - send a replacement
QUOTEdirectly on the same symbol, relying on the engine's replace semantics
What the MM must not assume¶
The MM must not assume:
- that
order.fillcontainsquote_id - that
quote.ackalways arrives before every other quote-related message - that
quote.status ACTIVEalways means the quote is still active at the moment it is received - that
QUOTE_CANCELis keyed byquote_idrather than by symbol
Recommended local state machine¶
The engine publishes only a few quote lifecycle statuses. The MM usually needs a slightly richer local state machine.
Recommended local states:
PENDING_SUBMITACTIVEPARTIALLY_FILLED_STILL_ACTIVEINACTIVE_BID_FILLEDINACTIVE_ASK_FILLEDCANCELLEDREJECTED
Notes:
PARTIALLY_FILLED_STILL_ACTIVEis an MM-local state, not an enginequote.statusREJECTEDis also MM-local; the engine reports rejection only throughquote.ack accepted=false
stateDiagram-v2
[*] --> PENDING_SUBMIT
PENDING_SUBMIT --> ACTIVE: quote.ack accepted=true
PENDING_SUBMIT --> REJECTED: quote.ack accepted=false
ACTIVE --> PARTIALLY_FILLED_STILL_ACTIVE: order.fill and policy does not inactivate yet
ACTIVE --> INACTIVE_BID_FILLED: quote.status INACTIVE_BID_FILLED
ACTIVE --> INACTIVE_ASK_FILLED: quote.status INACTIVE_ASK_FILLED
ACTIVE --> CANCELLED: quote.status CANCELLED
PARTIALLY_FILLED_STILL_ACTIVE --> INACTIVE_BID_FILLED: later quote.status INACTIVE_BID_FILLED
PARTIALLY_FILLED_STILL_ACTIVE --> INACTIVE_ASK_FILLED: later quote.status INACTIVE_ASK_FILLED
PARTIALLY_FILLED_STILL_ACTIVE --> CANCELLED: quote.status CANCELLED
INACTIVE_BID_FILLED --> PENDING_SUBMIT: strategy decides to re-quote
INACTIVE_ASK_FILLED --> PENDING_SUBMIT: strategy decides to re-quote
CANCELLED --> PENDING_SUBMIT: strategy decides to re-quote
Sequence graph: normal auto-inactivate flow¶
This is the cleanest sequence to reason about and the best default example for docs or code comments.
sequenceDiagram
participant MM as MM Gateway
participant ENG as Engine
participant TK as Taker
MM->>ENG: QUOTE|SYM=AAPL|...|QUOTE_ID=Q123
ENG-->>MM: quote.ack.MM01 {quote_id=Q123, bid_order_id=B1, ask_order_id=S1}
ENG-->>MM: quote.status.MM01 {quote_id=Q123, status=ACTIVE}
TK->>ENG: aggressive order hits B1
ENG-->>MM: order.fill.MM01 {order_id=B1, ...}
ENG-->>MM: order.cancelled.MM01 {order_id=S1}
ENG-->>MM: quote.status.MM01 {quote_id=Q123, status=INACTIVE_BID_FILLED}
MM->>MM: map B1 -> Q123 using quote.ack state
MM->>MM: compute replacement quote
MM->>ENG: QUOTE|SYM=AAPL|...|QUOTE_ID=Q124
Practical conclusions¶
- The engine's unique active quote slot is
(gateway_id, symbol). quote_ididentifies the logical quote instance occupying that slot.- The MM identifies quote-leg fills by
order_id, not byquote_idin the fill payload. quote.ackis the critical mapping message because it providesbid_order_idandask_order_id.- Under
INACTIVATE_ON_ANY_FILL, sibling cancellation is automatic. - Under
INACTIVATE_ON_FULL_FILL, sibling cancellation happens only after full fill. - Under
NEVER_INACTIVATE, the MM must decide when to cancel or replace. - Re-quoting can be done either by explicit
QUOTE_CANCELfollowed by newQUOTE, or by sending a replacementQUOTEdirectly on the same symbol. - The MM must tolerate edge cases where
order.filland evenquote.status INACTIVE_*arrive beforequote.ack.
Operational checklist for the MM implementation¶
- Always send your own
QUOTE_IDfor clean reconciliation. - Persist
quote.ackmappings immediately. - Maintain
order_id -> quote_idreverse lookup. - Correlate fills by
order_id, never by assumedquote_idin the fill payload. - Subscribe to all of:
order.fill.<gateway_id>order.cancelled.<gateway_id>quote.ack.<gateway_id>quote.status.<gateway_id>- Add pending-submit buffering for the immediate-fill-before-ack edge case.
- Treat
quote.status INACTIVE_*orquote.status CANCELLEDas the authoritative signal that the previous quote slot is no longer active. - Use direct replacement
QUOTEwhen you want the engine to perform atomic replace semantics on the same symbol.
And that may be all.
In that case:
- no sibling auto-cancel occurs yet
- no
quote.status INACTIVE_*is emitted yet - the quote remains active in the engine
The MM therefore continues to treat the quote as active until either:
- the filled leg becomes fully filled, causing inactivation, or
- the MM explicitly cancels or replaces the quote
Example 3: NEVER_INACTIVATE¶
Assume policy NEVER_INACTIVATE.
The MM may see:
And no automatic sibling cancel, and no INACTIVE_* status.
Meaning:
- the quote slot remains active in the engine
- the sibling ask leg remains resting unless something else removes it
- the MM must decide whether to keep quoting, cancel, or replace
If the MM wants a fresh two-sided quote immediately, it has two valid options:
Option A: explicit cancel then new quote¶
Typical resulting events:
[09:31:02] CANCELLED S9F3E1AA
[09:31:02] QUOTE CANCELLED Q123 Cancelled by participant
[09:31:02] QUOTE ACK Q123
Then the MM submits a new quote.
Option B: direct replacement quote¶
The engine will:
- remove the current active quote slot for
(MM01, AAPL) - cancel any surviving old child orders
- emit
quote.status CANCELLEDfor the old quote - install the new quote
This means the MM does not have to send QUOTE_CANCEL first if it is
immediately replacing the quote with another quote on the same symbol.
Example 4: immediate fill before quote.ack¶
This is the tricky case that every robust MM must handle.
Suppose the quote is marketable as soon as it is inserted. The MM may see:
[09:30:00] FILL B1A8C2D4 AAPL BUY 500@209.80
[09:30:00] CANCELLED S9F3E1AA
[09:30:00] QUOTE INACTIVE_BID_FILLED Q123
[09:30:00] QUOTE ACK Q123 bid=B1A8C2D4 ask=S9F3E1AA
[09:30:00] QUOTE ACTIVE Q123
Yes, that ordering is possible in the current engine.
What the MM must do in this case¶
The MM needs one more local concept:
pending_quote_by_symbol
When it sends:
it should locally remember that there is a pending quote submission for AAPL.
Then if an early fill arrives before quote.ack, the MM can:
- temporarily buffer the fill by
(gateway_id, symbol) - wait for
quote.ack - use
bid_order_idandask_order_idfrom the ack to retroactively resolve that buffered fill - then continue normal state handling
Without this pending-submit buffer, the MM cannot deterministically map an early fill to the quote until the ack arrives.
Recommended MM-side local state¶
The MM should maintain these structures.
Active quote record per symbol¶
active_quote[symbol] = {
quote_id,
bid_order_id,
ask_order_id,
bid_price,
ask_price,
bid_qty,
ask_qty,
tif,
state,
}
Reverse index by order ID¶
Pending submit state¶
This exists specifically to survive the immediate-fill-before-ack edge case.
What the MM must know to re-issue a quote¶
To issue a new quote, the MM must compute and send:
SYMBIDASKBID_QTYASK_QTY- optional
TIF - optional
QUOTE_ID
Example:
What the MM should normally do before re-issuing¶
If policy auto-inactivates¶
Recommended sequence:
- receive
order.fillfor the quote leg - receive
quote.status INACTIVE_*or otherwise confirm old quote is no longer active - compute the new two-sided quote
- send the new
QUOTE
Manual QUOTE_CANCEL is usually unnecessary here.
If policy does not auto-inactivate¶
The MM must choose one of:
- send
QUOTE_CANCEL|SYM=<symbol>, wait for cancellation lifecycle, then send a newQUOTE - send a replacement
QUOTEdirectly on the same symbol, relying on the engine's replace semantics
What the MM must not assume¶
The MM must not assume:
- that
order.fillcontainsquote_id - that
quote.ackalways arrives before every other quote-related message - that
quote.status ACTIVEalways means the quote is still active at the moment it is received - that
QUOTE_CANCELis keyed byquote_idrather than by symbol
Recommended local state machine¶
The engine publishes only a few quote lifecycle statuses. The MM usually needs a slightly richer local state machine.
Recommended local states:
PENDING_SUBMITACTIVEPARTIALLY_FILLED_STILL_ACTIVEINACTIVE_BID_FILLEDINACTIVE_ASK_FILLEDCANCELLEDREJECTED
Notes:
PARTIALLY_FILLED_STILL_ACTIVEis an MM-local state, not an enginequote.statusREJECTEDis also MM-local; the engine reports rejection only throughquote.ack accepted=false
stateDiagram-v2
[*] --> PENDING_SUBMIT
PENDING_SUBMIT --> ACTIVE: quote.ack accepted=true
PENDING_SUBMIT --> REJECTED: quote.ack accepted=false
ACTIVE --> PARTIALLY_FILLED_STILL_ACTIVE: order.fill and policy does not inactivate yet
ACTIVE --> INACTIVE_BID_FILLED: quote.status INACTIVE_BID_FILLED
ACTIVE --> INACTIVE_ASK_FILLED: quote.status INACTIVE_ASK_FILLED
ACTIVE --> CANCELLED: quote.status CANCELLED
PARTIALLY_FILLED_STILL_ACTIVE --> INACTIVE_BID_FILLED: later quote.status INACTIVE_BID_FILLED
PARTIALLY_FILLED_STILL_ACTIVE --> INACTIVE_ASK_FILLED: later quote.status INACTIVE_ASK_FILLED
PARTIALLY_FILLED_STILL_ACTIVE --> CANCELLED: quote.status CANCELLED
INACTIVE_BID_FILLED --> PENDING_SUBMIT: strategy decides to re-quote
INACTIVE_ASK_FILLED --> PENDING_SUBMIT: strategy decides to re-quote
CANCELLED --> PENDING_SUBMIT: strategy decides to re-quote
Sequence graph: normal auto-inactivate flow¶
This is the cleanest sequence to reason about and the best default example for docs or code comments.
sequenceDiagram
participant MM as MM Gateway
participant ENG as Engine
participant TK as Taker
MM->>ENG: QUOTE|SYM=AAPL|...|QUOTE_ID=Q123
ENG-->>MM: quote.ack.MM01 {quote_id=Q123, bid_order_id=B1, ask_order_id=S1}
ENG-->>MM: quote.status.MM01 {quote_id=Q123, status=ACTIVE}
TK->>ENG: aggressive order hits B1
ENG-->>MM: order.fill.MM01 {order_id=B1, ...}
ENG-->>MM: order.cancelled.MM01 {order_id=S1}
ENG-->>MM: quote.status.MM01 {quote_id=Q123, status=INACTIVE_BID_FILLED}
MM->>MM: map B1 -> Q123 using quote.ack state
MM->>MM: compute replacement quote
MM->>ENG: QUOTE|SYM=AAPL|...|QUOTE_ID=Q124
Practical conclusions¶
- The engine's unique active quote slot is
(gateway_id, symbol). quote_ididentifies the logical quote instance occupying that slot.- The MM identifies quote-leg fills by
order_id, not byquote_idin the fill payload. quote.ackis the critical mapping message because it providesbid_order_idandask_order_id.- Under
INACTIVATE_ON_ANY_FILL, sibling cancellation is automatic. - Under
INACTIVATE_ON_FULL_FILL, sibling cancellation happens only after full fill. - Under
NEVER_INACTIVATE, the MM must decide when to cancel or replace. - Re-quoting can be done either by explicit
QUOTE_CANCELfollowed by newQUOTE, or by sending a replacementQUOTEdirectly on the same symbol. - The MM must tolerate edge cases where
order.filland evenquote.status INACTIVE_*arrive beforequote.ack.
Operational checklist for the MM implementation¶
- Always send your own
QUOTE_IDfor clean reconciliation. - Persist
quote.ackmappings immediately. - Maintain
order_id -> quote_idreverse lookup. - Correlate fills by
order_id, never by assumedquote_idin the fill payload. - Subscribe to all of:
order.fill.<gateway_id>order.cancelled.<gateway_id>quote.ack.<gateway_id>quote.status.<gateway_id>- Add pending-submit buffering for the immediate-fill-before-ack edge case.
- Treat
quote.status INACTIVE_*orquote.status CANCELLEDas the authoritative signal that the previous quote slot is no longer active. - Use direct replacement
QUOTEwhen you want the engine to perform atomic replace semantics on the same symbol.
See also¶
- Market-Maker Bot (pm-mm-bot) — autonomous quoting process that implements the strategies described on this page
- Configuration — full gateway and
mm_obligation_defaultsconfig schema - Order Types — the LIMIT orders that quote legs create under the hood
- Risk Controls — how circuit breakers cancel quotes during a halt
- Persistence — GTC quotes survive restarts; MM seeds are injected at startup
- Gateway Commands — full QUOTE and QUOTE_CANCEL command syntax
- Messages —
quote.ackandquote.statusmessage payloads