Risk Controls¶
Learning objectives
After reading this page you will understand:
- How an instrument halt state prevents trading while a symbol is suspended
- How price collars reject orders that stray too far from a reference or last-traded price
- How circuit breakers detect violent price moves and automatically halt and resume a symbol
- How the kill switch lets any gateway instantly cancel all its own resting orders
- How Self-Match Prevention (SMP) stops a gateway from trading against its own resting orders,
and the four actions (
NONE,CANCEL_AGGRESSOR,CANCEL_RESTING,CANCEL_BOTH) it can take - Why SMP is resolved per order, not fixed per gateway, and how a gateway-level default fills in when an order or quote doesn't specify one
- How all five mechanisms interact with the order types described in Order Types
- How to configure each feature in
engine_config.yaml
Prerequisite: Concepts — Order Book explains the order lifecycle that underpins these controls.
Overview¶
Real exchanges operate several layers of protection against runaway prices and disorderly markets. EduMatcher implements five complementary mechanisms:
| Mechanism | Who sets it | What it checks | How it resolves |
|---|---|---|---|
| Instrument halt | Operator (external message) | Symbol is marked HALTED |
Operator sends resume |
| Price collar | Config per symbol | Incoming order price vs. reference band | Order is rejected |
| Circuit breaker | Config per symbol | Last trade price vs. rolling reference | Automatic halt + scheduled resume |
| Kill switch | Any authenticated gateway | — | Cancels all resting orders for that gateway |
| Self-Match Prevention (SMP) | Per order/quote, or a gateway-level default | Incoming order would trade against a resting order from the same gateway | Cancel aggressor, resting order, both, or allow the trade |
The first three — instrument halt, price collar, and circuit breaker — act on the order admission path, before an order enters the book. The kill switch is different: it acts on orders that are already resting, cancelling a gateway's own exposure on demand. SMP is different again: it acts during matching, at the instant a specific incoming order would otherwise cross against a specific resting order — see Self-Match Prevention (SMP) below.
The following diagram shows the order admission path through those three admission-path controls:
flowchart TD
A([Incoming order]) --> B{Symbol halted?}
B -- Yes --> C{Order type?}
C -- MARKET / FOK / IOC --> REJ1([Reject: SYM is halted —\nTYPE orders rejected during\ncircuit breaker halt])
C -- LIMIT / ICEBERG --> REST([Accept \u2014 rest on book,\nno matching sweep])
B -- No --> D{Collar\nconfigured?}
D -- Yes --> E{Price within\nstatic band?}
E -- No --> REJ2([Reject: STATIC_COLLAR_BREACH])
E -- Yes --> F{Last trade\nprice known?}
F -- Yes --> G{Price within\ndynamic band?}
G -- No --> REJ3([Reject: DYNAMIC_COLLAR_BREACH])
G -- Yes --> MATCH
F -- No --> MATCH
D -- No --> MATCH([Accept \u2014 enter matching engine])
MATCH --> H{Trade\nexecuted?}
H -- Yes --> I{CB level\ncrossed?}
I -- Yes --> HALT([Halt symbol\nschedule resume])
I -- No --> DONE([Done])
H -- No --> DONE
Global on/off switches
Collar and circuit-breaker enforcement can each be disabled engine-wide
with the top-level enforce_collars / enforce_circuit_breakers boolean
config fields (both default to true). These are blunt, engine-startup
switches intended for tests, not per-symbol controls — see
Configuration - Engine Behavior Flags.
Instrument halt state¶
What it does¶
Each symbol in EduMatcher can be in one of two states:
| State | Orders accepted? | Quotes accepted? |
|---|---|---|
ACTIVE |
Yes | Yes |
HALTED |
LIMIT and ICEBERG rest (do not match); MARKET, FOK, IOC are rejected | Rejected immediately |
A halt is an operator-initiated pause. It is broader than a circuit breaker pause: it can be applied manually at any time, for any reason (e.g., a pending news announcement, a technology incident at a venue, a regulatory instruction).
State model¶
The engine maintains a _halted_symbols dictionary (keyed on symbol name) that
records which symbols are currently halted. Any key not present in the
dictionary is implicitly ACTIVE.
Halt behaviour by order type¶
When the engine receives a new order for a halted symbol:
- MARKET / FOK / IOC — rejected immediately with a reason of the form
"<SYMBOL> is halted — <TYPE> orders rejected during circuit breaker halt"(e.g."AAPL is halted — MARKET orders rejected during circuit breaker halt"). These order types require immediate execution and cannot be held on the book. - LIMIT / ICEBERG — accepted and placed on the book but the engine suppresses the continuous-matching sweep. The order will rest until the symbol is resumed, at which point it participates in the reopening.
When the engine receives a new quote for a halted symbol, the entire quote is
rejected (both sides) with reason "<SYMBOL> is halted — quotes rejected
during circuit breaker halt".
Quote eligibility by participant role (for example, MARKET_MAKER vs
TRADER) is described in
Configuration - Role Privileges.
Interaction with auctions¶
If a symbol is halted during an auction phase the engine will still accept LIMIT orders so they can participate in the uncross when the halt is lifted and trading resumes. Market-maker quotes are rejected because a quote always implies willingness to trade immediately and a halted market does not offer that guarantee.
Price collars¶
Motivation¶
Price collars are a pre-trade filter. They prevent an erroneous ("fat-finger") order from moving the market far from its fair value. Without collars a mistyped limit price could instantly sweep the entire book.
Band definitions¶
EduMatcher supports two independent collar bands, both configured per symbol:
| Band | Measured from | Purpose |
|---|---|---|
| Static band | Reference price (typically last closing price, set in config) | Wide safety net; catches gross errors |
| Dynamic band | Last traded price in this session | Tighter rolling band; tracks intra-day moves |
Both bands are expressed as a percentage of the relevant reference price.
The boundary tick values are computed with truncation toward zero
(int()) so that the protected range is always at least as tight as the
nominal percentage:
| Band | Reference price used |
|---|---|
| Static | Resolved opening price at startup — persisted last price if available, else last_buy_price, else last_sell_price (converted to ticks) |
| Dynamic | Last executed trade price in the current session |
The dynamic band is skipped entirely when no trade has occurred yet in the session.
static_upper = int(reference_price × (1 + static_band_pct))
static_lower = int(reference_price × (1 - static_band_pct))
dynamic_upper = int(last_trade_price × (1 + dynamic_band_pct))
dynamic_lower = int(last_trade_price × (1 - dynamic_band_pct))
An incoming order price is rejected if it falls outside either band. The static band is checked first.
Tick-based prices
All prices in EduMatcher are stored as integer tick counts. The collar boundaries are in ticks, not display prices. See Configuration for the relationship between ticks and display prices.
Validation logic¶
- If
price < static_lowerorprice > static_upper→STATIC_COLLAR_BREACH - If
last_trade_priceis known andprice < dynamic_lowerorprice > dynamic_upper→DYNAMIC_COLLAR_BREACH - Otherwise → accepted
The dynamic check is skipped when last_trade_price is None (no trade has
occurred yet in this session).
Which orders are checked¶
Collars apply to LIMIT and ICEBERG orders (any order that carries an explicit price). MARKET orders do not carry a price and are therefore not subject to collar validation.
Configuration¶
Add a collar sub-section to any symbol in engine_config.yaml:
symbols:
MSFT:
tick_decimals: 2
last_buy_price: 420.00
collar:
static_band_pct: 0.20 # ±20% from reference price (default)
dynamic_band_pct: 0.02 # ±2% from last traded price (default)
Both fields are optional. When the collar key is absent entirely, no collar
is applied to that symbol. When the collar key is present but empty ({}),
the defaults above are used.
Global level profiles (L1/L2/L3 style)¶
Collar values can be sourced from reusable global risk levels:
risk_controls:
default_level: L2
levels:
L1:
collar:
static_band_pct: 0.30
dynamic_band_pct: 0.05
L2:
collar:
static_band_pct: 0.20
dynamic_band_pct: 0.02
symbols:
AAPL:
# inherits L2
tick_decimals: 2
TSLA:
level: L1
# override dynamic band only
collar:
dynamic_band_pct: 0.06
Resolution precedence is:
symbols.<symbol>.collar(symbol override)symbols.<symbol>.levelprofile fromrisk_controls.levelsrisk_controls.default_levelprofile- built-in defaults (
static_band_pct=0.20,dynamic_band_pct=0.02)
| Parameter | Type | Default | Meaning |
|---|---|---|---|
static_band_pct |
float | 0.20 |
Maximum distance from reference price, as a fraction (0 < x < 1) |
dynamic_band_pct |
float | 0.02 |
Maximum distance from last trade price, as a fraction (0 < x < 1) |
The reference price for the static band is resolved at engine startup,
preferring a persisted last price from book_stats.json, then the configured
last_buy_price, then last_sell_price (converted to ticks). The circuit
breaker seeds its reference from the same resolved value, so both controls agree
at the open — see Day one (IPO) behaviour.
Circuit breakers¶
Motivation¶
A circuit breaker is an automatic mechanism that pauses trading when prices move too fast. Unlike a collar (which rejects individual orders), a circuit breaker monitors executed trades and halts the entire symbol when a trade deviates too sharply from recent history. It then automatically resumes after a configurable pause, giving participants time to update their orders.
Real-world examples: the US market-wide circuit breakers (Level 1/2/3), the London Stock Exchange's Automated Auction Call mechanism, and per-stock volatility interruptions on Euronext.
In many production exchanges, the primary circuit-breaker trigger is linked to an index (market-wide reference) with per-symbol volatility controls layered on top. EduMatcher does not yet implement index-level circuit-breaker triggers, so its current circuit-breaker logic is symbol-linked: each symbol evaluates its own rolling trade reference and can halt independently.
How it works — step by step¶
-
Every trade is recorded. When the engine publishes a fill, it calls
record_trade(price, now)on the circuit breaker for that symbol. -
Reference price is computed from history. The engine looks back over a rolling time window (
reference_window_ns) and averages the prices of trades that occurred within that window — excluding the new trade being evaluated. This ensures the check is "does this trade deviate from where the market has been?" rather than including the potentially erroneous trade in its own reference. On day one, before any real trade exists, this history is pre-seeded with a single baseline point derived from the symbol's opening reference price, so even the very first order can be evaluated (see Day one (IPO) behaviour). -
Deviation is tested against levels. The absolute shift from reference is compared against configured levels (for example
L1=7%,L2=13%,L3=20%). The highest crossed level fires. -
Symbol is halted. The engine sets
_halted_symbols[symbol] = True, cancels all outstanding market-maker quotes for that symbol, and broadcasts acircuit_breaker.halt.{symbol}message over the pub socket. -
Resume is scheduled by level. The fired level defines the halt length. Example: L1 might halt for 5 minutes, L2 for 15 minutes, and L3 for the rest of the trading day.
-
Engine polls for resumption. Each iteration of the main event loop calls
_flush_circuit_breakers(), which checksshould_resume(now)for every active circuit breaker. When the pause expires:- The symbol is un-halted.
- The engine runs an uncross (
_run_uncross(), the same equilibrium-price algorithm used for scheduled auctions) for that symbol before continuous matching resumes, so interest that crossed while resting during the halt is matched at a fair equilibrium price rather than starting continuous trading in a crossed state. If nothing crossed, the uncross is a no-op. - An
auction.result.{symbol}withreason: "REOPEN"is broadcast. - A
circuit_breaker.resume.{symbol}message is broadcast, carryinghalt_source.
stateDiagram-v2
[*] --> ACTIVE
ACTIVE --> HALTED : trade price shift \u2265 L1/L2/L3 threshold\nMM quotes cancelled
HALTED --> UNCROSS : resume timer expires
UNCROSS --> ACTIVE : uncross run unconditionally\n(no-op if nothing crossed)\nauction.result reason=REOPEN
ACTIVE --> HALTED : operator halt\n(per-symbol or ADMIN all)\nMM quotes cancelled
HALTED --> ACTIVE : operator resume\n(risk.symbol_resume or\nrisk.circuit_breaker_resume_all)
Rolling reference window¶
The reference window is a sliding time window. Any trade older than
now - reference_window_ns is discarded before computing the average. This
means the reference tracks recent price behaviour — a slow steady trend will
not accumulate stale data that masks a sudden move.
reference_window_ns
←──────────────────────→
────────●──●───●───●──●────●──── now
(old trades) (new trade being evaluated)
reference = average of all trades in window (excluding new trade)
If the window is empty, the circuit breaker has no reference to compare against, so the trade is accepted and simply added to the history. At engine startup this empty-window case is avoided by seeding a baseline reference from the symbol's opening price (see Day one (IPO) behaviour). It can otherwise only recur mid-session if the seed and every real trade have aged out of the window during a long quiet period.
Day one (IPO) behaviour¶
Introducing a new symbol is the exchange equivalent of an IPO: there is no
trade history yet, only the opening reference price established during listing
(last_buy_price / last_sell_price). Two things happen at startup so that
the risk controls are active from the very first order rather than lying dormant
until trades accumulate:
- Collar reference — the static band anchors on the resolved opening price
(persisted
book_stats.jsonif present, otherwise the configuredlast_buy_price, thenlast_sell_price). - Circuit-breaker reference — the breaker's rolling history is pre-seeded with a single synthetic baseline point at that same resolved opening price. The first executed trade is therefore evaluated against the IPO reference, so a violent opening print can trip the breaker on day one.
Because both controls draw their reference from the same source, the collar and the circuit breaker agree at the open.
Notes and edge cases:
- The seed is a baseline for comparison only. It is not published as a trade and does not appear in market data.
- The synthetic seed sits in the rolling window like any trade, so it ages out
after
reference_window_ns. Under normal continuous trading, real fills have replaced it long before then; it matters only if a symbol stays completely silent for the whole window after the open. - If a symbol has no opening reference at all (no persisted stat and neither
last_buy_pricenorlast_sell_priceset), there is nothing to seed: the collar stays inactive and the circuit breaker has no reference until trades build up. This is why an opening reference price is required when listing a symbol — see Configuration - Symbol Universe.
Why a halt always reopens with an uncross¶
A halt is not a pause with the book frozen — it is the call phase of a
reopening auction. While a symbol is halted the engine accepts LIMIT orders
and rests them, rejects MARKET/FOK/IOC, and runs no matching. When the halt
ends, _flush_circuit_breakers() calls _run_uncross() for that symbol — the
same equilibrium-price algorithm used for scheduled auctions — and publishes
an auction.result.{symbol} carrying reason: "REOPEN" before the resume
event.
This is unconditional and there is no setting that skips it. Crossed interest
accumulates for the whole halt, so resuming straight into continuous matching
would begin on a crossed book. If nothing crossed, compute_equilibrium()
finds no equilibrium price and the uncross is a no-op, indistinguishable from
an immediate continuous resume.
Earlier versions exposed a per-level resumption_mode (AUCTION or
CONTINUOUS). It never changed engine behaviour — both values uncrossed —
and has been removed. The halt and resume payloads now carry halt_source
(CB or ADMIN) instead, which says what caused the halt; whether it ends
by itself is already expressed by the presence of resume_at_ns.
Automated Corridor Expansion (ACE)¶
Reopening at whatever price the accumulated interest implies has two problems, and ACE addresses both.
Problem one: the reopen price can be absurd. A halt fires precisely when prices are moving violently. The orders that pile up during the call phase are entered under uncertainty, often thinly, and the equilibrium they imply can sit far from any price the market would sustain. Printing it turns a protective halt into the cause of an erroneous execution.
Problem two: the reopen instant is a target. If everyone knows the exact nanosecond the uncross runs, the last order in — placed with full sight of the book, too late for anyone to react — is strictly advantaged. This is why real venues randomise the end of a call phase.
ACE is modelled on Deutsche Börse's mechanism of the same name and on Nasdaq Rule 4120(c)(7). It has two independent halves.
The corridor and the expansion ladder¶
When a halt begins, the engine latches a corridor reference: the circuit
breaker's rolling mean at the moment of the halt (CircuitBreakerState.
corridor_reference). It is latched, not recomputed, so an uncross elsewhere
cannot move the target mid-halt. The corridor is that reference plus and minus
initial_band_pct of it.
At the end of every call phase the engine runs compute_equilibrium() as a
dry run — it computes the price the symbol would reopen at without
executing anything:
- Inside the corridor → the symbol reopens.
_run_uncross()executes at the equilibrium andcircuit_breaker.resume.{symbol}is published. - Outside the corridor → the symbol does not reopen. The corridor
widens by the next rung's
widen_pct, a fresh call phase of that rung'smin_duration_nsbegins, andcircuit_breaker.extend.{symbol}is published. Orders keep resting throughout; nothing is cancelled.
Widening is additive on the reference price, not compounding on the
previous width. Each rung adds its widen_pct of the original reference.
This matters: it is what makes the corridor grow linearly and predictably
rather than exploding.
The last rung repeats indefinitely. This is the design's terminating argument, and it is why there is no maximum-extensions setting. Because the corridor grows without bound, it eventually contains any finite price, so the symbol always reopens on its own — the only question is when. A cap would force a choice between printing outside the corridor (defeating the purpose) and never reopening at all.
The random end¶
halt_duration_ns on the triggered level is the minimum length of the
first call phase, not its exact length. min_duration_ns plays the same role
for each extension. On top of the minimum the engine adds a delay drawn
uniformly from [0, random_end_max_ns], so no call phase — initial or
extended — ends at a time anyone can predict.
The generator is engine-wide (EngineProcess._reopening_rng). Leave
random_seed unset for OS entropy, which is what an operating venue wants.
Set it to an integer for reproducible teaching demos and tests. Setting
random_end_max_ns: 0 disables the random end entirely, making reopen times
exactly predictable — useful in a classroom, wrong in production.
Worked example¶
Configuration: initial_band_pct: 0.10, expansions [{0.10, 2min},
{0.20, 5min}], random_end_max_ns: 0 (so the timings below are exact).
Symbol ABC, reference price $100.00, an L1 halt with
halt_duration_ns of 5 minutes fires at 13:30:00.
Heavy one-sided buying accumulates during the call; the indicative price settles at $122.00 and stays there.
| Time | Event | Indicative | Corridor | Half-width | Outcome |
|---|---|---|---|---|---|
| 13:30:00 | L1 halt fires | – | 90.00 – 110.00 | ±10% | Call phase 1 opens (min 5 min) |
| 13:35:00 | Call 1 ends | $122.00 | 90.00 – 110.00 | ±10% | Outside → extend, widen +10% |
| 13:37:00 | Call 2 ends | $122.00 | 80.00 – 120.00 | ±20% | Outside → extend, widen +20% |
| 13:42:00 | Call 3 ends | $122.00 | 60.00 – 140.00 | ±40% | Inside → reopen, uncross at $122.00 |
Total halt: 12 minutes rather than the configured 5. The extra 7 minutes are the market being given time to supply offsetting interest against a 22% move before it prints. Had sellers arrived during call 2 and pulled the indicative back to $118, the symbol would have reopened at 13:37 inside the ±20% corridor.
These are exactly the numbers in the SEC order approving Nasdaq's rule ($100 reference, collars 90/110 → 80/120 → 60/140), which is a useful cross-check that the arithmetic is right.
With the random end at its default 30s, the three call ends above would instead fall somewhere in 13:35:00–13:35:30, 13:37:00–13:37:30 and 13:42:00–13:42:30.
End of day: the closing auction as backstop¶
ACE widens indefinitely, so on its own it never terminates — a symbol whose indicative price runs away could in principle extend past the close. The end of the trading day supplies the terminating condition.
On the transition to CLOSED, _run_closing_backstop() forces every symbol
still halted to resolve:
- The indicative price is computed one final time.
- Inside the corridor → it prints there, as a normal reopen would.
- Outside the corridor → it prints at the corridor boundary: the upper bound for a buy imbalance, the lower bound for a sell imbalance.
- No crossing interest at all → nothing prints; the halt is simply cleared.
Step 3 is the only place in the engine where a price is imposed rather than discovered, and it is deliberate. A clamped print can leave the book crossed — bids and asks beyond the boundary do not trade. That is the intended outcome: that interest survives to the next session rather than executing at a price the corridor was built to reject.
The ordering matters and is load-bearing. The backstop runs after the
scheduled CLOSING_AUCTION → CLOSED uncross, and _run_uncross() skips
symbols that are still halted. Were it otherwise, the session sweep would
uncross halted symbols at the true equilibrium and silently undo the entire
mechanism.
A level configured with halt_duration_ns: null (rest-of-day) never enters
the ACE cycle at all: it has no timed resume, so no call phase ever ends. It
waits for this backstop or for an ADMIN resume.
Observability¶
Every corridor adjustment is logged and published, so an extension sequence can be watched as it happens:
CIRCUIT BREAKER HALT ABC: level=L1 trigger=12200, ref=10000 ticks, corridor=[9000, 11000] ticks (+/-10.0%)
ACE EXTEND ABC: indicative=12200 ticks outside [9000, 11000] -> expansion=1 corridor=[8000, 12000] (+/-20.0%) qty=500 next_call_ends=...
ACE EXTEND ABC: indicative=12200 ticks outside [8000, 12000] -> expansion=2 corridor=[6000, 14000] (+/-40.0%) qty=500 next_call_ends=...
CIRCUIT BREAKER RESUME ABC: after 2 ACE extension(s)
and at the close:
CLOSING BACKSTOP ABC: indicative=12200 ticks outside [9000, 11000] -> clamped to 11000 (BUY imbalance), after 1 ACE extension(s)
On the wire, circuit_breaker.halt.{symbol} and the new
circuit_breaker.extend.{symbol} both carry corridor_low, corridor_high
and expansion; extend adds indicative_price, indicative_qty,
imbalance_side and the next resume_at_ns. On extend the three corridor
fields are always present — the event is published by the widening itself. On
halt all three are omitted when the halt has no corridor: either ACE is
disabled, or the halt began with no reference price to centre one on, or it is
an operator halt. A backstop resume carries reason: "CLOSING_BACKSTOP",
clamped and print_price.
Configuration¶
Define a global threshold ladder under circuit_breaker_defaults, then override
per symbol only where needed:
circuit_breaker_defaults:
reference_window_ns: 300000000000
levels:
L1:
price_shift_pct: 0.07
halt_duration_ns: 300000000000 # 5 minutes
L2:
price_shift_pct: 0.13
halt_duration_ns: 900000000000 # 15 minutes
L3:
price_shift_pct: 0.20
halt_duration_ns: # null => rest of trading day
reopening: # Automated Corridor Expansion
enabled: true
initial_band_pct: 0.10 # +/-10% corridor to start
random_end_max_ns: 30000000000 # up to 30s random tail per call
random_seed: # null => OS entropy
expansions: # last rung repeats indefinitely
- widen_pct: 0.10
min_duration_ns: 120000000000 # 2 minutes
- widen_pct: 0.20
min_duration_ns: 300000000000 # 5 minutes
symbols:
TSLA:
tick_decimals: 2
circuit_breaker:
levels:
L1:
halt_duration_ns: 600000000000 # symbol-specific override
reopening:
initial_band_pct: 0.05 # tighter corridor for TSLA only
For each trade, the engine computes:
The highest level where price_shift >= price_shift_pct fires.
| Parameter | Type | Default | Meaning |
|---|---|---|---|
reference_window_ns |
int | 300_000_000_000 |
Lookback window for rolling reference price |
levels.<L>.price_shift_pct |
float | required | Trigger threshold fraction in (0, 1) |
levels.<L>.halt_duration_ns |
int or null | required | Minimum length of the reopening call phase in ns, or null for rest-of-day |
reopening.enabled |
bool | true |
Apply ACE. When false, a halt reopens at the equilibrium price uncollared |
reopening.initial_band_pct |
float | 0.10 |
Corridor half-width in (0, 1), as a fraction of the reference price |
reopening.expansions |
list | Nasdaq ladder | Rungs of {widen_pct, min_duration_ns}. The last rung repeats indefinitely |
reopening.expansions[].widen_pct |
float | required | Added to the corridor half-width, in (0, 1). Additive on the reference, not compounding |
reopening.expansions[].min_duration_ns |
int | required | Minimum length of that extension's call phase, > 0 |
reopening.random_end_max_ns |
int | 30_000_000_000 |
Upper bound of the uniform random tail on every call phase. 0 disables it |
reopening.random_seed |
int or null | null |
Engine-wide. Only valid in circuit_breaker_defaults — setting it per symbol is an error (S110) |
Two of these are exchange-wide and rejected on a symbol: random_seed (S110)
and expansions (S112). The split is deliberate. initial_band_pct answers
how volatile is this instrument normally — a thin small-cap legitimately
needs a wider reopening corridor than a liquid blue chip, so it varies per
symbol. The ladder answers how quickly does the exchange stop protecting the
price and let it through, which is venue policy rather than an instrument
property; keeping it uniform is what makes halt durations comparable across the
book.
The line is not perfect — min_duration_ns lives in the ladder and is
arguably instrument-shaped, since a thin name may need longer call phases for
liquidity to arrive. Real venues disagree on where to draw it: Nasdaq varies
nothing per security (the collar rule is universal; only the reference price
differs), while Deutsche Börse publishes corridor widths and durations per
instrument in reference data. EduMatcher sits between them, and enforces the
choice rather than leaving it to whichever tool touched the file last.
When circuit_breaker_defaults and symbol-level circuit_breaker are both
present, per-symbol values override global defaults by level key. The
reopening block merges field-by-field the same way, so a symbol can override
initial_band_pct alone without restating the ladder.
Why there is no selected "default breaker level"¶
Circuit-breaker levels are trigger outcomes, not configuration profiles to pick one from. A symbol defines a ladder of levels; the observed price shift determines which level is activated at runtime.
Price collars vs circuit breakers¶
Both controls are configured on symbols, but they protect the market at different points in the flow and for different failure modes.
- A price collar is a pre-trade admission guardrail on priced orders.
- A circuit breaker is a post-trade volatility interrupt that can halt the full symbol after an extreme move is observed.
Side-by-side comparison¶
| Dimension | Price collar (symbol) | Circuit breaker (symbol) |
|---|---|---|
| Trigger moment | Before matching (order admission) | After a trade is executed |
| Data checked | Incoming order price vs static/dynamic bands | Trade price shift vs rolling reference |
| Scope of effect | Single incoming order | Entire symbol |
| Typical action | Reject offending order (STATIC_COLLAR_BREACH or DYNAMIC_COLLAR_BREACH) |
Halt symbol, cancel MM quotes, schedule/manual resume |
| Market state after trigger | Symbol keeps trading for other valid orders | Symbol is halted until resume condition is met |
| Configuration anchor | symbols.<SYM>.collar (or inherited level defaults) |
circuit_breaker_defaults + symbols.<SYM>.circuit_breaker overrides |
| Primary objective | Prevent fat-finger / outlier order entry | Pause disorderly market after extreme realized move |
| Dependence on trade history | Dynamic band needs last trade, static band does not | Uses rolling trade history/reference window |
Practical interpretation¶
Use collars to stop clearly invalid prices from entering the book. Use circuit breakers to pause trading when validly admitted orders still produce an abnormally large executed move. In practice, collars reduce bad inputs and circuit breakers contain fast market dislocations.
For field-level schema and merge precedence details, see Configuration - Circuit Breakers and Configuration - Risk Controls and Collars.
Interaction between mechanisms¶
All three admission-path controls can be active simultaneously on the same symbol. The engine applies them in this order for every incoming order:
1. Is the symbol halted? → reject MARKET/FOK/IOC; suppress matching for LIMIT
2. Is a collar configured? → validate price against static and dynamic bands
3. (After match) Did a trade fire
a circuit breaker? → halt symbol, schedule resume
A circuit breaker halt feeds back into step 1: any subsequent orders on a circuit-breaker-halted symbol are subject to the same halt rules as an operator-initiated halt.
Combined example¶
Opening reference price: 10000 ticks (100.00 in display)
→ also seeds the CB's rolling trade_history as a single baseline point
Static collar: ±20% → [8000, 12000] ticks
Dynamic collar: ±2% → depends on last trade
CB levels: L1=7%, L2=13%, L3=20%
A limit sell order arrives at price 7500 ticks:
→ Static collar: 7500 < 8000 → STATIC_COLLAR_BREACH → rejected
A limit buy at 10100 ticks trades.
→ CB reference = history average BEFORE this trade = 10000 (seed only)
→ deviation = |10100 - 10000| / 10000 = 1.0% < L1 (7%) → no halt
→ history becomes [10000, 10100] (sum=20100, len=2)
A limit buy at 10700 ticks trades.
→ CB reference = 20100 // 2 = 10050 (integer-tick average)
→ deviation = |10700 - 10050| / 10050 ≈ 6.5% < L1 (7%) → no halt
→ history becomes [10000, 10100, 10700] (sum=30800, len=3)
A limit buy at 11000 ticks trades.
→ CB reference = 30800 // 3 = 10266 (floor division)
→ deviation = |11000 - 10266| / 10266 ≈ 7.1% ≥ L1 (7%)
→ L1 circuit breaker fires, symbol halted for L1 duration (5 min default);
resume always runs a reopening uncross for the symbol
Separately, suppose a later session's rolling reference has settled at 10100
and a limit buy at 12200 ticks trades:
→ deviation = |12200 - 10100| / 10100 ≈ 20.8% ≥ L3 (20%)
→ L3 circuit breaker fires, symbol halted for rest of trading day
→ next order at this symbol: if MARKET → rejected; if LIMIT → accepted, no match
Reference price is an integer-tick rolling average
CircuitBreakerState.record_trade() computes the reference as
sum(prices in window) // len(prices in window) — floor (//) integer
division, consistent with all prices being stored as integer ticks. The
reference used for a given trade always excludes that trade itself; it is
only added to the rolling history after the deviation check.
What the gateway operator sees when a collar rejects an order:
TRADER01> NEW|SYM=AAPL|SIDE=SELL|TYPE=LIMIT|QTY=100|PRICE=75.00
[14:02:00.301] ORDER REJECTED reason="STATIC_COLLAR_BREACH"
ZeroMQ messages¶
Risk events are broadcast on the engine's PUB socket (port 5556).
Circuit breaker halt¶
topic: b"circuit_breaker.halt.MSFT"
payload: {
"symbol": "MSFT",
"trigger_price": 108.00, # display money, not ticks
"reference_price": 101.00,
"resume_at_ns": <timestamp>,
"halt_source": "CB",
"level": "L1",
"corridor_low": 90.00, # all three omitted when there is
"corridor_high": 110.00, # no corridor — see above
"expansion": 0
}
Circuit breaker extend (ACE)¶
Published when a call phase ends with the indicative price outside the corridor. The symbol stays halted and a fresh call phase begins.
topic: b"circuit_breaker.extend.MSFT"
payload: {
"symbol": "MSFT",
"indicative_price": 122.00, # what it would have reopened at
"indicative_qty": 500,
"imbalance_side": "BUY", # omitted entirely when balanced
"corridor_low": 80.00, # corridor AFTER widening
"corridor_high": 120.00,
"expansion": 1, # rungs consumed so far
"resume_at_ns": ... # end of the new call phase
}
Circuit breaker resume¶
A resume forced by the end-of-day backstop carries three extra fields:
payload: {
"symbol": "MSFT",
"halt_source": "CB",
"reason": "CLOSING_BACKSTOP",
"clamped": true, # printed at the corridor boundary
"print_price": 110.00
}
This detail is also on the CALF market-data wire
pm-md-gwy (the CALF gateway) exposes this same payload to external
clients on a dedicated CB channel — trigger_price, reference_price,
level, resume_at_ns, and halt_source all reach the wire,
alongside the coarse SESSION=HALTED transition CALF's STATE channel
already carried. See
CALF Protocol Reference — CB for the
field mapping (halt_source is carried as SRC). The reopening uncross
(auction.result.{SYMBOL}) reaches CALF's AUCTION channel with
REASON=REOPEN, which is what distinguishes it from the scheduled
opening and closing auctions.
ADMIN-role operator controls¶
An operator gateway configured with role: ADMIN can trigger and lift an
exchange-wide halt without waiting for a per-symbol circuit breaker to fire
or expire. This is the manual emergency control used when a venue-wide
technology incident, regulatory instruction, or extreme market dislocation
requires every symbol to be frozen simultaneously.
Prerequisites¶
Configure a dedicated gateway with role: ADMIN in engine_config.yaml:
gateways:
alf:
- id: GW_ADMIN
description: "Operations desk"
role: ADMIN
disconnect_behaviour: CANCEL_QUOTES_ONLY
The gateway connects to the engine via the standard PUSH socket (port 5555) and subscribes to the PUB socket (port 5556) to receive ack messages.
See Role Privileges for the full permissions matrix.
Not available from the ALF terminal
The ALF console (pm-alf-console) only exposes KILL for risk actions —
it has no HALT/RESUME/CANCEL_SYM command. To trigger an exchange-wide
halt, a per-symbol halt/resume, or a symbol-level mass cancel, send the raw
ZMQ frames shown below directly, or use the REST admin endpoints described
in API Gateway.
Triggering an exchange-wide halt¶
Send the following frame to the engine's PUSH socket (port 5555):
Python snippet using pyzmq:
import zmq, json
ctx = zmq.Context()
push = ctx.socket(zmq.PUSH)
push.connect("tcp://localhost:5555")
push.send_multipart([
b"risk.circuit_breaker_halt_all",
json.dumps({"gateway_id": "GW_ADMIN"}).encode(),
])
What the engine does:
- Verifies
GW_ADMINis connected and carries roleADMIN. - Collects every known symbol (order books, circuit-breaker state, engine configuration).
- Marks each symbol
HALTEDwithhalt_source = "ADMIN"(no auto-resume timer). - Cancels all outstanding market-maker quote legs for every symbol.
- Publishes one
circuit_breaker.halt.<SYMBOL>event per symbol on the PUB socket. - Sends the ack to the PUB socket.
Expected inbound events (subscribe to circuit_breaker.* and
risk.circuit_breaker_halt_all_ack.*):
topic: b"circuit_breaker.halt.AAPL"
payload: {
"symbol": "AAPL",
"trigger_price": null,
"reference_price": null,
"resume_at_ns": null,
"halt_source": "ADMIN",
"level": "ADMIN_ALL"
}
topic: b"circuit_breaker.halt.MSFT"
payload: { ...same structure... }
topic: b"risk.circuit_breaker_halt_all_ack.GW_ADMIN"
payload: {
"accepted": true,
"reason": "",
"halted_symbols": 4,
"cancelled_quotes": 12
}
If the gateway is not connected or does not carry role ADMIN, the engine
returns accepted: false and no symbols are halted:
topic: b"risk.circuit_breaker_halt_all_ack.GW_ADMIN"
payload: {
"accepted": false,
"reason": "Global circuit-breaker halt is only allowed for ADMIN participants"
}
Resuming all trading¶
Once the situation is resolved, send risk.circuit_breaker_resume_all on the
same PUSH socket:
Python snippet:
push.send_multipart([
b"risk.circuit_breaker_resume_all",
json.dumps({"gateway_id": "GW_ADMIN"}).encode(),
])
What the engine does:
- Verifies
GW_ADMINis connected and carries roleADMIN. - Collects every symbol currently marked as halted.
- Clears the halt flag and deactivates any circuit-breaker state for each symbol.
- Publishes one
circuit_breaker.resume.<SYMBOL>event per symbol. - Sends the ack.
Expected inbound events:
topic: b"circuit_breaker.resume.AAPL"
payload: { "symbol": "AAPL", "halt_source": "ADMIN" }
topic: b"circuit_breaker.resume.MSFT"
payload: { "symbol": "MSFT", "halt_source": "ADMIN" }
topic: b"risk.circuit_breaker_resume_all_ack.GW_ADMIN"
payload: {
"accepted": true,
"reason": "",
"resumed_symbols": 4
}
After the ack is received, normal order flow and quote submission resume for all previously halted symbols. Market makers are expected to re-enter fresh quotes; the engine will begin enforcing MM obligation checks again immediately.
Sequence diagram¶
sequenceDiagram
participant Op as Operator (GW_ADMIN)
participant Eng as Engine
participant Sub as Subscribers
Op->>Eng: risk.circuit_breaker_halt_all<br/>{gateway_id: "GW_ADMIN"}
Eng->>Sub: circuit_breaker.halt.AAPL {level: ADMIN_ALL}
Eng->>Sub: circuit_breaker.halt.MSFT {level: ADMIN_ALL}
Eng->>Op: risk.circuit_breaker_halt_all_ack.GW_ADMIN<br/>{accepted: true, halted_symbols: 2}
Note over Op,Sub: ... incident resolved ...
Op->>Eng: risk.circuit_breaker_resume_all<br/>{gateway_id: "GW_ADMIN"}
Eng->>Sub: circuit_breaker.resume.AAPL {halt_source: ADMIN}
Eng->>Sub: circuit_breaker.resume.MSFT {halt_source: ADMIN}
Eng->>Op: risk.circuit_breaker_resume_all_ack.GW_ADMIN<br/>{accepted: true, resumed_symbols: 2}
Key differences from automatic circuit breakers¶
| Property | Automatic CB (per-symbol) | ADMIN global halt |
|---|---|---|
| Trigger | Trade price deviation | Operator command |
| Scope | Single symbol | All symbols |
| Resume | Scheduled timer, always via uncross | Explicit risk.circuit_breaker_resume_all (or risk.symbol_resume for a single symbol) |
| Quotes cancelled on halt | Yes | Yes |
halt_source |
CB |
ADMIN |
| Who can send | Any connected gateway | ADMIN role only |
Halting or resuming a single symbol¶
Alongside the exchange-wide risk.circuit_breaker_halt_all / risk.circuit_breaker_resume_all
pair, an ADMIN gateway can halt or resume one symbol at a time using
risk.symbol_halt / risk.symbol_resume. This is the command used by the
/api/v1/admin/circuit-breaker/trigger and /circuit-breaker/resume REST
endpoints (see API Gateway) and is the mechanism behind
the "Per-symbol operator halt" row in the
Market-maker interaction table below.
Frame 0 (topic): b"risk.symbol_halt"
Frame 1 (payload): {"gateway_id": "GW_ADMIN", "symbol": "AAPL"}
What the engine does:
- Verifies
GW_ADMINis connected and carries roleADMIN; rejects with reason"Per-symbol halt is only allowed for ADMIN participants"otherwise. - Marks the symbol
HALTED(_halted_symbols[symbol] = True) and, if a circuit breaker is configured for the symbol, sets its state tohaltedwithtriggered_level = "ADMIN_SYMBOL"andhalt_source = "ADMIN". - Cancels all outstanding market-maker quote legs for that symbol only, with
cancellation reason
"Per-symbol halt". - Publishes
circuit_breaker.halt.<SYMBOL>with"level": "ADMIN_SYMBOL"and"halt_source": "ADMIN". - Sends
risk.symbol_halt_ack.<GW_ADMIN>with{"accepted": true, "symbol": "AAPL", "reason": "", "cancelled_quotes": <count>}.
risk.symbol_resume mirrors this: it requires ADMIN role (rejecting with
"Per-symbol resume is only allowed for ADMIN participants"), rejects with
"<SYMBOL> is not halted" if the symbol isn't currently halted, otherwise
clears the halt, deactivates the circuit breaker state, runs the same
unconditional uncross as an automatic resume, publishes
circuit_breaker.resume.<SYMBOL> with "halt_source": "ADMIN", and acks on
risk.symbol_resume_ack.<GW_ADMIN> with {"accepted": true, "symbol": "AAPL", "reason": ""}.
Both symbol_halt and symbol_resume also reject with "symbol required"
if the symbol field is missing, and symbol_halt additionally rejects with
"Unknown symbol: <SYMBOL>" if the engine has an allowlist of symbols and
the requested symbol isn't in it.
Symbol-level mass cancel¶
risk.cancel_symbol cancels every resting order and quote leg for one symbol,
across all gateways — not just the sender's own exposure like the kill
switch. It does not halt the symbol; trading continues immediately with an
empty book (aside from any order that arrives afterward). It is ADMIN-only
and is the command behind the /api/v1/admin/kill-switch/symbol REST endpoint
(see API Gateway).
Frame 0 (topic): b"risk.cancel_symbol"
Frame 1 (payload): {"gateway_id": "GW_ADMIN", "symbol": "AAPL"}
What the engine does:
- Verifies
GW_ADMINis connected and carries roleADMIN; rejects with reason"Symbol-level mass cancel is only allowed for ADMIN participants"otherwise. Also rejects with"symbol required"ifsymbolis missing. - Cancels every resting order for the symbol that did not originate from a
quote leg (
order.origin != OrderOrigin.QUOTE), across every gateway. - Cancels every quote leg resting for the symbol, across every gateway, with
cancellation reason
"Symbol mass cancel". - Sends
risk.cancel_symbol_ack.<GW_ADMIN>with{"accepted": true, "symbol": "AAPL", "reason": "", "cancelled_orders": <count>, "cancelled_quotes": <count>}.
Unlike the kill switch (which is scoped to one gateway's own resting
exposure) and the instrument halt (which stops the symbol from matching),
risk.cancel_symbol clears standing interest for a symbol from every
participant while leaving the symbol open for new orders.
Kill switch¶
The kill switch is a gateway-level emergency control that immediately cancels all resting orders and quotes owned by a specific gateway, without halting the symbol. It is designed for situations where a malfunctioning trading bot or gateway needs to be flushed without stopping the whole market.
Permissions¶
Unlike the exchange-wide halt, the kill switch does not require ADMIN
role. Any authenticated, connected gateway can trigger a kill switch against
its own orders.
Sending the command¶
An optional "symbol" field scopes the cancellation to a single instrument:
When "symbol" is empty or absent, all symbols are included.
What the engine does¶
- Collects all resting orders and quote legs for the specified gateway (and optionally symbol).
- Cancels each order and quote leg, excluding child orders that were derived from a quote (those are cancelled as part of the quote leg cancellation).
- Sends a
risk.kill_switch_ack.{GW_ID}reply with the count of cancelled items.
Reply¶
topic: b"risk.kill_switch_ack.GW01"
payload: {
"accepted": true,
"reason": "",
"cancelled_orders": <count>,
"cancelled_quotes": <count>
}
Differences from a halt¶
| Property | Kill switch | Instrument halt |
|---|---|---|
| Scope | One gateway's orders | All orders on a symbol |
| Symbol trading | Continues uninterrupted | Paused |
| New orders accepted | Yes | LIMIT/ICEBERG only |
| Requires ADMIN role | No | No when auto-triggered by a circuit breaker; Yes for an operator-initiated halt, whether per-symbol (risk.symbol_halt) or exchange-wide (risk.circuit_breaker_halt_all) |
| Auto-resume | Not applicable | Yes (CB) / Manual (operator) |
No cross-gateway kill switch
A kill switch always targets a single gateway. There is no command to
cancel all orders across all gateways at once — use a per-symbol
risk.symbol_halt or an exchange-wide risk.circuit_breaker_halt_all
(both require ADMIN role) to stop trading, or risk.cancel_symbol
(ADMIN role, see Symbol-level mass cancel)
to clear resting interest for a symbol across every gateway without a
kill switch's single-gateway scope.
Self-Match Prevention (SMP)¶
Motivation¶
A single trading firm often runs several independent strategies or bots
against the same market, each connected through its own gateway session — or
sometimes several sessions sharing one gateway_id. Without a safeguard,
one of that firm's aggressive orders can cross against another resting order
from the same firm. The trade prints, generates real fees and settlement
obligations, and often serves no economic purpose: the firm has simply
traded with itself. Self-Match Prevention (SMP) is the engine-level control
that detects this situation at the moment of matching and takes a
configurable, deliberate action instead of silently allowing the self-trade.
SMP is scoped to the gateway identity (gateway_id), not to individual
order IDs or symbols. Two resting orders from TRADER01 on AAPL are a
self-match risk; a TRADER01 order against a TRADER02 order is not, no
matter how similar the strategies behind them are.
The four actions¶
SmpAction is a four-value enum shared by every order-entry path in the
system (NEW, COMBO, and the REST API carry it explicitly per request;
QUOTE always inherits it from the gateway default — see
Which paths carry an explicit per-request SMP=
below):
| Value | Aggressor (incoming order) | Resting order | Net effect |
|---|---|---|---|
NONE |
Trades normally | Trades normally | Self-trade is allowed — this is "SMP off" |
CANCEL_AGGRESSOR |
Cancelled, no fill against this resting order | Untouched, stays on the book | The incoming order backs off; existing resting interest is preserved |
CANCEL_RESTING |
Continues matching against the next eligible resting order | Cancelled | The older resting order is cleared out of the way; the aggressor's intent to trade is honoured against other participants |
CANCEL_BOTH |
Cancelled | Cancelled | Both sides of the would-be self-match are pulled from the book |
SmpAction is evaluated per potential match, not once per order. A single
aggressor sweeping several price levels can encounter same-gateway resting
liquidity at one level and other-participant liquidity at another; SMP only
engages at the levels where the gateway IDs actually collide.
Where SMP is checked — the matching-engine mechanics¶
SMP is enforced inside the order book's price-time sweep, immediately after
the price-limit check and before a fill is generated. This applies uniformly
to every order type that sweeps the book: MARKET, LIMIT, IOC, FOK,
and ICEBERG (both as aggressor and, for iceberg-vs-iceberg matches, on the
resting side too).
flowchart TD
A([Aggressor reaches best\nopposite-side price level]) --> B{Price still\nwithin limit?}
B -- No --> STOP([Stop sweep\n— price exhausted])
B -- Yes --> C{Same gateway_id\nas resting order?}
C -- No --> FILL([Generate fill\nadvance to next level])
C -- Yes --> D{smp_action?}
D -- NONE --> FILL
D -- CANCEL_AGGRESSOR --> E([Cancel aggressor\nstop sweep immediately])
D -- CANCEL_RESTING --> F([Cancel resting order\ncontinue sweep at same level])
D -- CANCEL_BOTH --> G([Cancel resting order\ncancel aggressor\nstop sweep immediately])
F --> B
Three consequences follow directly from this being a per-level, in-sweep check rather than a pre-trade validation:
CANCEL_AGGRESSORandCANCEL_BOTHstop the sweep outright. If the aggressor had already filled against other participants at better price levels before reaching the self-match, those earlier fills stand — only the remainder is cancelled. The order can therefore end upPARTIAL+CANCELLED, exactly like a normal IOC/FOK partial-then-cancel outcome.CANCEL_RESTINGlets the sweep continue. The resting order that would have caused the self-match is removed from the book, and the aggressor keeps walking the book — it may still fill fully against other participants deeper in the book.- A resting
ICEBERG's SMP is evaluated against its currently displayed slice, the same price-time unit any other order competes with — hidden reserve quantity behind a cancelled iceberg peak is not separately protected; if the peak is cancelled by SMP, replenishment behaves like any other iceberg peak exhaustion.
FOK — self-match liquidity is excluded before the pre-check¶
FOK (Fill-Or-Kill) is the one order type that resolves SMP before the
sweep starts, because it needs to know up front whether the book has enough
eligible liquidity to fill the whole order — same-gateway resting
quantity that SMP would skip or cancel does not count as eligible liquidity:
- The engine walks the opposite side and sums
remaining_qtyexcluding any resting order belonging to the samegateway_id, whensmp_actionis anything other thanNONE. - If that filtered total is still less than the FOK's quantity, the order
cannot fill completely. SMP is then resolved the same way a sweep would
have resolved it —
CANCEL_RESTING/CANCEL_BOTHcancel the conflicting resting orders;CANCEL_AGGRESSOR/CANCEL_BOTHcancel the FOK itself — and the order is finalised asCANCELLEDif a same-gateway conflict was the cause, or plainlyREJECTEDif the shortfall is genuine (no same-gateway liquidity involved). - Only if eligible liquidity is sufficient does the FOK proceed into the normal sweep, which then also enforces SMP level by level as above (a defensive safety net; the pre-check should already guarantee the sweep completes cleanly).
This keeps FOK's all-or-nothing guarantee intact: a self-match candidate can never cause a partial fill.
Cancellation is visible, not silent¶
When SMP cancels a resting order, that order transitions to CANCELLED like
any other cancellation and the owning gateway receives a normal
order.cancelled.{GW_ID} message — there is no separate SMP-specific wire
message. On the binary BALF protocol, a system-cancelled order additionally
carries cancel_reason = 1 ("SMP") in the EXECUTION_REPORT so a
programmatic client can distinguish an SMP cancel from a plain client cancel
or a session-end/expiry cancel without needing to correlate against its own
NEW history — see
BALF Protocol Reference — EXECUTION_REPORT.
ALF (text) and REST clients only see the resulting order.cancelled /
CANCELLED event and infer the cause from context (a same-gateway order that
was resting a moment earlier is now gone).
Two ways to specify smp_action¶
SMP can be set in two places, and they serve different purposes:
- Per order or per combo leg — the
SMP=field on aNEW/COMBOcommand (ALF, BALF), thesmp_actionfield on the RESTOrderRequest/ComboRequestpayload (API Gateway), or thesmp_actionkey on amarket_maker_combos[].legs[]seed entry inengine_config.yaml. This is the client expressing "for this order, do X on self-match." gateways.alf[].smp_action— a per-gateway default inengine_config.yaml, applied by the engine when an order or leg does not specifySMP=at all. This is the operator expressing "for this gateway, when nobody says otherwise, do X."
gateways:
alf:
- id: TRADER01
description: "Prop desk algo 1"
smp_action: CANCEL_RESTING # gateway-level default
| Field | Location | Required | Values | Default |
|---|---|---|---|---|
SMP= |
NEW/COMBO command (ALF), smp byte (BALF), smp_action (REST) |
No | NONE, CANCEL_AGGRESSOR, CANCEL_RESTING, CANCEL_BOTH |
(unspecified — falls back, see below) |
gateways.alf[].smp_action |
engine_config.yaml |
No | NONE, CANCEL_AGGRESSOR, CANCEL_RESTING, CANCEL_BOTH |
NONE |
Precedence: why "omitted" and "explicit NONE" are not the same thing¶
This is the subtlety that makes SMP worth documenting carefully. SMP=NONE
sent by a client is a deliberate instruction: "I know this might
self-match, and I want the trade to happen anyway." An omitted SMP=
means the client simply didn't think about it. Collapsing those two cases
together would either force every client to think about SMP on every order
(bad ergonomics) or silently let an operator's safety default be overridden
by clients who never intended to override anything (a real safety gap). So
the engine treats them as genuinely different values:
- Internally,
smp_actionon anOrderorComboLegis typed as optional —Nonemeans "not specified," and is distinct from the concrete enum memberSmpAction.NONE, which means "specified as off." - Client-facing processes (
pm-alf-gwy,pm-alf-console, the API Gateway) preserve that distinction all the way from the wire request into the internal order object — an omitted field parses toNone, not toSmpAction.NONE. - The engine resolves the final, concrete
smp_actionexactly once, at the point an order or combo leg is accepted (Engine._handle_new_order,Engine._accept_combo), using this precedence:
1. Order/leg specified SMP= explicitly (including explicit SMP=NONE)
→ use that value, always. The gateway default is never consulted.
2. Order/leg omitted SMP= entirely
→ use gateways.alf[<this gateway>].smp_action
3. Gateway has no smp_action configured (or is unknown to the engine)
→ use SmpAction.NONE
By the time an order reaches the order book's matching logic, smp_action
is always a concrete value — the book itself never sees the "unspecified"
state and needs no special-casing for it.
flowchart TD
A(["Order or combo leg\narrives at the engine"]) --> B{SMP=\nspecified?}
B -- "Yes, incl. explicit NONE" --> C(["Use the specified value\nas-is"])
B -- No --> D{Gateway has\nsmp_action configured?}
D -- Yes --> E(["Use gateways.alf.smp_action"])
D -- No --> F(["Use SmpAction.NONE"])
C --> G(["Concrete smp_action\nreaches the order book"])
E --> G
F --> G
Which paths carry an explicit per-request SMP=, and which only have the gateway default¶
Not every order-entry path exposes its own SMP= field:
| Path | Explicit per-request SMP=? |
Effective SMP source |
|---|---|---|
ALF NEW (single order) |
Yes | Explicit value, else gateway default, else NONE |
ALF NEW\|TYPE=COMBO |
Yes — but one SMP= value for the whole combo, applied identically to every leg (no LEG<i>.SMP) |
Explicit value applied to all legs, else gateway default applied to all legs, else NONE |
BALF NEW_ORDER |
Yes (smp byte is mandatory in the fixed frame — see note below) |
Always the value on the wire — see BALF Protocol Reference — NEW_ORDER |
REST OrderRequest / ComboRequest |
Yes (JSON field, optional; ComboRequest.legs[] allows a different value per leg) |
Explicit value, else gateway default, else NONE — resolved independently per leg for combos |
Market-maker QUOTE (both pm-mm-bot and the REST quoting endpoint) |
No — quotes have no per-request SMP field | Always gateways.alf[].smp_action, else NONE |
market_maker_combos[].legs[] config-seeded combo |
Optional smp_action key, settable per leg |
Explicit per-leg value, else gateway default, else NONE — resolved independently per leg |
BALF's smp byte is mandatory, not omittable
BALF is a fixed-width binary protocol — the smp byte at offset 51 of
NEW_ORDER is always present on the wire (0x00–0x03; see
BALF Protocol Reference — NEW_ORDER).
There is no wire representation of "the client didn't send this field."
A BALF client that wants the gateway default to apply must send 0x00
and rely on gateways.alf[].smp_action being configured to something
other than NONE — sending 0x00 is indistinguishable from an
explicit SMP=NONE and does not fall back to the gateway default.
This is a deliberate scope decision: extending the None/omitted
distinction into the binary frame would require a wire format change
(e.g. a presence bitmap), which BALF does not currently have.
Market makers are the clearest illustration of why the gateway-level default
exists at all: a QUOTE submits two legs (bid and ask) in one call with no
room for a per-leg SMP=, yet a market maker's own stale bid and fresh ask
can easily cross each other after a quote refresh. Configuring
gateways.alf[<mm-gateway>].smp_action: CANCEL_RESTING is the only way to
protect a quoting gateway from this — see
Market-Maker Bot — Recommended settings
for the concrete example.
Worked examples¶
Example 1 — omitted SMP=, gateway has a configured default.
No SMP= on the wire → resolves to CANCEL_RESTING (the gateway default).
If TRADER01 already has a resting SELL 100 @ 150.00, that resting order
is cancelled and the incoming buy continues matching against other
participants (or rests itself, if none remain).
Example 2 — explicit SMP=NONE overrides a non-NONE gateway default.
Even though TRADER01's gateway default is CANCEL_RESTING, the explicit
SMP=NONE wins: the order is allowed to trade against TRADER01's own
resting sell, producing a genuine self-trade. This is the "I meant it"
escape hatch — useful for, e.g., internal cross facilitation the firm has
separately reconciled for.
Example 3 — quote leg, no gateway default configured.
A QUOTE from MM_AAPL_02 has no SMP= field to omit or specify — it
always falls back to the gateway default, which here is unset, so it
resolves to SmpAction.NONE. A stale leg from a prior quote can self-trade
against the fresh replacement leg. This is the exact gap
gateways.alf[].smp_action closes when configured — see Example 1's
pattern applied to a market maker.
Example 4 — combo on the ALF text protocol: one SMP= value for every leg.
TRADER01> NEW|TYPE=COMBO|COMBO_ID=spread-1|COMBO_TYPE=AON|TIF=DAY|LEG_COUNT=2|
LEG0.SYM=AAPL|LEG0.SIDE=BUY|LEG0.QTY=100|LEG0.PRICE=150.00|
LEG1.SYM=MSFT|LEG1.SIDE=SELL|LEG1.QTY=50|LEG1.PRICE=400.00|SMP=CANCEL_BOTH
On the ALF text protocol there is a single combo-level SMP= field, not a
per-leg LEG<i>.SMP — the parsed value (or, if SMP= is omitted entirely,
the None sentinel) is applied identically to every leg's ComboLeg. Here
CANCEL_BOTH applies to both LEG0 and LEG1.
Example 5 — combo submitted via REST: per-leg override.
The REST ComboRequest schema and the market_maker_combos[].legs[] config
seed path are less restrictive: each leg carries its own optional
smp_action, resolved independently.
{
"combo_id": "spread-1",
"combo_type": "AON",
"tif": "DAY",
"legs": [
{"symbol": "AAPL", "side": "BUY", "quantity": 100, "price": 150.00, "smp_action": "NONE"},
{"symbol": "MSFT", "side": "SELL", "quantity": 50, "price": 400.00}
]
}
AAPL's leg explicitly sets smp_action: "NONE" and keeps it, regardless
of TRADER01's gateway default. MSFT's leg omits smp_action entirely
and falls back to TRADER01's gateways.alf[].smp_action independently —
each leg resolves on its own in this path, unlike the single combo-wide
value on the ALF text protocol.
SMP and the other risk controls¶
SMP is orthogonal to the four admission-path/exposure controls described
above: it does not reject orders at admission time, does not halt a symbol,
and is not something any gateway can trigger against another gateway. It
only ever inspects and acts on orders belonging to the same gateway_id
that are about to trade against each other during matching. An instrument
halt, price collar, or circuit-breaker rejection all happen before SMP
would ever be reached, since none of those orders get as far as the sweep.
| Property | SMP | Kill switch |
|---|---|---|
| Trigger | Automatic, at match time, when both sides share a gateway_id |
Explicit command from the gateway |
| Scope | One aggressor vs. one resting order at a time | All of a gateway's resting orders (optionally one symbol) |
| Configurable outcome | Yes — 4 actions | No — always cancels |
| Can a gateway opt out | Yes — SMP=NONE (explicit) or an unconfigured/NONE gateway default |
No |
Configuration reference¶
See Configuration — Gateway Fields
for the full gateways.alf[].smp_action field definition and
Configuration Spec §5.2
for the normative schema entry, including how the same default extends to
market_maker_combos[].legs[] config-seeded combos. Per-request field
definitions live alongside each protocol's NEW/COMBO/QUOTE command
reference: ALF Console — NEW,
ALF Console — NEW (Combo),
Combos — SMP=, and
API Gateway for the REST field.
Market-maker interaction¶
When any halt fires — whether triggered by a circuit breaker, a per-symbol operator halt, or the exchange-wide ADMIN halt — all outstanding market-maker quote legs for the affected symbols are cancelled immediately. This protects market makers from having stale quotes executed against them during a disorderly market. The cancellation reason included in the order.cancelled message distinguishes the halt source:
| Halt source | Cancellation reason text |
|---|---|
| Circuit breaker | "Circuit breaker halt" |
| Per-symbol operator halt | "Per-symbol halt" |
| Exchange-wide ADMIN halt | "Global circuit breaker halt" |
When a symbol resumes, market makers are expected to submit fresh quotes at updated prices. The engine will begin enforcing MM obligation checks again immediately upon resumption.
See also¶
- Configuration — full
engine_config.yamlreference including collar, CB ladder, andsmp_actionconfig - Order Types — how different order types behave under halt, and how SMP interacts with each type's sweep
- Drop Copy — how fill events are forwarded to risk systems
- Auctions & Session Scheduling — the equilibrium-price uncross algorithm that circuit-breaker resumption always runs
- ALF Console —
KILLcommand for triggering the kill switch via the ALF terminal, and theNEW/COMBOSMP=field - Combos — per-leg
SMP=on multi-leg orders - Market-Maker Bot — why quoting gateways rely entirely on the
smp_actiongateway default - Configuration Spec — normative schema for
gateways.alf[].smp_actionandComboLegSpec.smp_action