Skip to content

Architecture

Learning objectives

After reading this page you will understand:

  • Why a multi-process message-passing architecture was chosen over a monolith or a shared-memory design, and how this mirrors real trading systems
  • The fundamental messaging concepts: topics, publish/subscribe, point-to-point, broadcast, and subscription filters
  • How the ZeroMQ brokerless topology connects all processes in EduMatcher
  • The internal data structures of an order book — heaps, lazy deletion, price-level indexes — and how to read a visual order book depth diagram
  • The time complexity of every core operation: insert, match, cancel, stop trigger

Why This Architecture?

Three ways to build a trading system

Before looking at what EduMatcher does, it helps to understand the alternatives and why they fall short for this use case.

Option 1 — Monolith : One process, one program. The user types an order, matching runs, results print. Simple to build, simple to debug. Falls apart as soon as two users need to trade simultaneously, or when you want to add an audit log without touching the matching code.

Option 2 — Shared memory / threads : Multiple threads share an in-process order book protected by a lock. Common in C++ HFT cores. Fast, but extremely hard to reason about: lock contention, priority inversion, and data races all wait to bite you. Also single-machine — you can't run the viewer on a separate screen without redesigning everything.

Option 3 — Multi-process message passing : Each process owns its own state and communicates by sending messages. No shared memory, no locks. Processes can run on different machines. Adding a new observer (an audit logger, a new data feed) means writing a new subscriber — the engine doesn't change.

EduMatcher uses Option 3. It is also how most real exchange systems are actually built: a matching engine core that publishes every event on an internal bus, with a fleet of downstream consumers (clearing, risk, market data distribution, surveillance) that subscribe to the topics they care about.

Core messaging concepts

Before reading the topology diagram, it is worth having clear definitions:

Topic : A string label attached to every message that identifies what kind of event it carries. Examples: trade.executed, order.fill.GW01, book.AAPL. Think of it like the subject line of an email — the sender sets it, the receiver uses it to decide whether to read the body.

Publish / Subscribe (PUB/SUB) : One sender, many potential receivers. The sender publishes to a topic; every process that has subscribed to that topic receives a copy. Processes that have not subscribed never see the message. This is how the engine broadcasts book updates: one book.AAPL message is published; the viewer, the stats recorder, and the board all receive it independently.

Subscription filter : A subscriber declares which topics it wants to receive. In ZeroMQ, the filter is a simple prefix match: a subscription for "order.fill.GW01" receives only messages whose topic starts with that string. A subscription for "book." receives book updates for all symbols. An empty-string subscription receives everything.

Broadcast : A message sent to all subscribers simultaneously with no specific address. The engine broadcasts session.state when the trading phase changes — every process (gateways, viewers, stats, board) reacts according to its own logic.

Point-to-point (PUSH/PULL) : One sender, exactly one receiver. EduMatcher uses PUSH/PULL for order submission: a gateway pushes an order message and the engine pulls it. No other process sees that message; no routing table is needed because the engine is the only process that ever binds the PULL socket.

Message routing : In a broker-based system, the broker reads the topic and decides which queue to put the message in. In EduMatcher, routing is done by the ZeroMQ layer using the subscription filter — no broker, no routing table, no extra process.

Private reply over PUB/SUB : The subscription prefix enables a lightweight request/reply pattern without a dedicated REQ/REP socket. When a gateway pushes a system.symbols_request command, the engine publishes the answer on system.symbols.GW01 — a topic only that gateway's SUB filter will match; every other subscriber silently discards it at the ZMQ layer. This pattern covers every personalised engine response: order.ack.{GW}, system.symbols.{GW}, system.quote_bootstrap.{GW}, and more. It requires every connected process to register a unique gateway ID.

Overview

EduMatcher uses a broker-less ZeroMQ topology.
The matching engine is the only process that binds sockets — all other processes connect to it.
No ZMQ broker daemon, no message queue server, no external dependencies beyond ZMQ itself.

ZMQ Topology

graph TD
    ALF["pm-alf-console\nALF"]
    APIGW["pm-api-gwy"]
    MMBOT["pm-mm-bot"]
    SCH["pm-scheduler"]

    subgraph Engine["Matching Engine — sole binder"]
        PULL["PULL :5555\norder commands"]
        PUB56["PUB :5556\nmarket events"]
        PUB57["PUB :5557\ndrop-copy"]
    end

    GW_R["pm-alf-console\nprivate events"]
    APIR["pm-api-gwy\nWebSocket bridge"]
    OPS["pm-stats · pm-clearing · pm-audit"]
    UI["pm-viewer · pm-board · pm-ticker · pm-orders"]
    IDX["pm-index"]
    MD["pm-md-gwy\nCALF TCP bridge"]
    PT["pm-ralf-gwy\nRALF TCP bridge"]
    DCC["Risk / compliance consumers"]

    subgraph IndexBus["Index bus  (pm-index binds)"]
        IPUB["PUB :5558\nindex events"]
        IPULL["PULL :5559\nindex commands"]
    end

    ExtMD["External market-data clients"]
    ExtPT["External post-trade clients"]

    ALF & APIGW & MMBOT & SCH -->|PUSH| PULL
    PUB56 -->|SUB| GW_R & APIR & OPS & UI & IDX & MD & PT
    PUB57 -->|SUB| DCC
    IDX -->|binds| IPUB & IPULL
    ALF & APIGW -.->|"SUB / PUSH"| IndexBus
    MD -->|"CALF / TCP"| ExtMD
    PT -->|"RALF / TCP"| ExtPT

Message Topics

All messages are two-frame ZMQ multipart:

  • frame[0] — topic string (used for SUB filtering)
  • frame[1] — JSON payload

Commands — GW / operator → Engine (PUSH :5555)

Topic Description
order.new Submit a new order
order.cancel Cancel a resting order
order.amend Amend the price and/or quantity of a resting order
order.combo Submit a multi-leg combo order
order.combo_cancel Cancel a combo and all its child legs
order.oco Link two resting orders into an OCO pair
order.oco_cancel Cancel both legs of an OCO pair
quote.new Submit or replace a two-sided market-maker quote
quote.cancel Cancel the active quote for a symbol
risk.kill_switch Cancel all resting orders and quotes for a gateway
risk.symbol_halt Operator halt of a single symbol
risk.symbol_resume Resume a previously halted symbol
risk.cancel_symbol Cancel all resting orders across all gateways on one symbol
risk.circuit_breaker_halt_all Administrative global halt
risk.circuit_breaker_resume_all Administrative global resume
system.gateway_connect Authenticate a gateway ID on startup
system.gateway_disconnect Graceful disconnect notice from a gateway
system.symbols_request Request the list of configured symbols
book.snapshot_request Request an immediate book snapshot for a symbol
order.orders_request Request the current resting order list for a gateway
system.quote_bootstrap_request Request active quote state for a gateway
system.quote_legs_request Request quote leg snapshot (QLEGS)
system.session_state_request Request current session state
system.gateways_request Request the list of configured gateways and connection status
system.volume_request Request cumulative traded volume for all symbols
system.halt_status_request Request a snapshot of all currently halted symbols
system.position_request Request per-symbol position snapshot for a gateway
session.transition Request a session-phase change (sent by pm-scheduler)

Private replies — Engine → GW (PUB :5556, personalised prefix)

Topic Description
order.ack.{GW_ID} Order accepted or rejected
order.fill.{GW_ID} Partial or full fill notification
order.cancelled.{GW_ID} Cancel confirmed or SMP-forced cancellation
order.amended.{GW_ID} Amendment confirmed
order.expired.{GW_ID} DAY / ATO / ATC order expired at phase change or shutdown
order.orders.{GW_ID} Response to order.orders_request: full resting order list
combo.ack.{GW_ID} Combo order accepted or rejected
combo.status.{GW_ID} Combo lifecycle state change
oco.ack.{GW_ID} OCO link accepted
oco.cancelled.{GW_ID} Sibling leg cancelled after other leg filled or was cancelled
quote.ack.{GW_ID} Quote accepted or rejected
quote.status.{GW_ID} Quote lifecycle state change
risk.kill_switch_ack.{GW_ID} Kill-switch execution confirmed
system.gateway_auth.{GW_ID} Authentication accepted or rejected
system.symbols.{GW_ID} Response to system.symbols_request
system.quote_bootstrap.{GW_ID} Active quote bootstrap state
system.quote_legs.{GW_ID} Quote leg snapshot response
system.session_status.{GW_ID} Current session state and enforcement flag
system.gateways.{GW_ID} Configured gateways and connection status
system.volume.{GW_ID} Cumulative traded volume per symbol
system.halt_status.{GW_ID} Currently halted symbols snapshot
system.position.{GW_ID} Per-symbol position snapshot (net qty, avg cost)

Broadcasts — Engine → all subscribers (PUB :5556)

Topic Description
session.state Session phase changed; every subscriber reacts accordingly
auction.result.{SYMBOL} Auction uncross result: equilibrium price, quantity, imbalance
trade.executed A trade was matched; consumed by clearing, stats, RALF, viewers
book.{SYMBOL} Full order-book snapshot after every state change for this symbol
depth.{SYMBOL} Depth-of-market statistics (bid/ask imbalance, cost-to-move) published alongside book.{SYMBOL}
circuit_breaker.halt.{SYMBOL} Symbol halted by the circuit breaker
circuit_breaker.resume.{SYMBOL} Symbol resumed after a circuit-breaker halt
system.eod End-of-day shutdown broadcast; signals all subscribers to flush and stop

Drop-copy feed — Engine → compliance consumers (PUB :5557)

Topic Description
drop_copy.event.{GW_ID} Sequenced fill event with nanosecond timestamp, one per filled order leg
drop_copy.replay.{CLIENT_ID} Replayed historical fill events in response to a replay request

Index bus — pm-index ↔ clients (PUB :5558 / PULL :5559)

pm-index is its own process with its own dedicated sockets at ports :5558 and :5559. It subscribes to the engine PUB at :5556 and publishes index events independently.

Topic Direction Description
index.update pm-index → all Current index level, OHLC, and session state
index.history_request GW → pm-index Query index history by time range
index.history.{GW_ID} pm-index → GW History query response
index.corp_action GW → pm-index Apply a corporate action to an index constituent
index.corp_action_ack.{GW_ID} pm-index → GW Corporate action acknowledgement
index.constituent_change GW → pm-index Add or remove an index constituent
index.constituent_change_ack.{GW_ID} pm-index → GW Constituent-change acknowledgement

Process Roles

Process ZMQ Sockets Binds/Connects Role
pm-engine PULL :5555, PUB :5556, PUB :5557 Binds all three Matching, session state, combo/OCO/quote tracking, drop-copy
pm-alf-console (ALF) PUSH→:5555, SUB→:5556, SUB→:5558, PUSH→:5559 Connects Interactive order entry; ALF line protocol; index data display
pm-api-gwy PUSH→:5555, SUB→:5556, SUB→:5558, PUSH→:5559 Connects REST and WebSocket order gateway for programmatic clients
pm-mm-bot PUSH→:5555, SUB→:5556 Connects Automated market-maker; manages two-sided quotes
pm-scheduler PUSH→:5555 Connects Drives session phase transitions on a time schedule
pm-viewer SUB→:5556 Connects Real-time order book display for one symbol
pm-orders SUB→:5556 Connects Order status monitor across all gateways
pm-audit SUB→:5556 Connects Universal event log (subscribes to all topics)
pm-clearing SUB→:5556 Connects P&L tracking and trade settlement
pm-stats SUB→:5556 Connects OHLCV statistics and SQLite persistence
pm-ticker SUB→:5556 Connects Scrolling market data display
pm-board SUB→:5556 Connects Multi-symbol paged market display
pm-index PULL :5559, PUB :5558, SUB→:5556 Binds :5558 and :5559; connects→:5556 Index calculation, OHLC, corporate actions
pm-md-gwy (CALF) SUB→:5556, SUB→:5558 Connects Translates engine events to CALF TCP for external market-data subscribers
pm-ralf-gwy (RALF) SUB→:5556 Connects Translates trade events to RALF TCP for external post-trade / clearing parties

Key Design Decisions

The Engine as the Sole Binder

The engine is the only process that binds sockets (:5555, :5556, :5557). Every other process connects to the engine. This has three practical consequences:

  • Start the engine first. A gateway that connects before the engine is started will block on send until the engine comes up (ZMQ queues outgoing messages).
  • Adding a new subscriber requires no engine change. Any process can connect to :5556 and subscribe to the topics it needs. The engine has no knowledge of who is listening.
  • Restart isolation. Any subscriber can crash and restart without disturbing the engine or other subscribers. It simply reconnects and resumes.

pm-index is the one exception: it binds its own pair of sockets (:5558, :5559) because it is a second-tier publisher, not a consumer of the engine.

Two Tiers of Gateways

EduMatcher has two distinct gateway roles:

Order-entry gateways (pm-alf-console, pm-api-gwy, pm-mm-bot) connect directly to the engine over ZMQ PUSH/PULL. They authenticate via system.gateway_connect and receive personalised events on their subscribed order.*, quote.*, and system.* topics. These processes are internal to the exchange operator.

Protocol bridges (pm-md-gwy, pm-ralf-gwy) subscribe to the engine PUB socket and translate engine events into an external TCP line protocol for third-party consumers:

  • pm-md-gwy (CALF) — real-time order book and trade data for market-data subscribers, with session management, subscription filtering, and gap-recovery replay
  • pm-ralf-gwy (RALF) — post-trade execution events for external clearing, drop-copy, and audit parties over authenticated TCP sessions

Bus Segmentation by Function

Separating traffic onto distinct ports serves both operational and compliance needs:

Port Socket Purpose
:5555 PULL Order commands — only the engine reads these
:5556 PUB Market events — all internal subscribers
:5557 PUB Drop-copy — sequenced fill feed for compliance consumers
:5558 PUB Index events — separate bus owned by pm-index
:5559 PULL Index commands — received only by pm-index

The drop-copy feed on :5557 deliberately isolates the compliance feed from the full market event stream. Each fill event carries a monotonic sequence number and a nanosecond timestamp so consumers can detect gaps.

Single-Threaded Engine as a Correctness Guarantee

The engine's main loop processes one message at a time in a single thread. There are no locks, no shared mutable state, and no concurrent modifications. Combo status transitions, cascade-cancels, and session phase changes are all fully serialised. The price is that a long auction uncross blocks all other message processing until it finishes — acceptable for an educational system, but a production engine would decompose the critical path or use non-blocking event dispatch.

Order Book Data Structures

Visual: order depth at each price level

Before looking at the heap internals, here is what an order book actually looks like from a market participant's perspective. Each price level accumulates all the resting orders placed at that price. Time priority determines which order fills first within a level.

        BID SIDE                            ASK SIDE
  (buyers waiting to buy)            (sellers waiting to sell)

  Price    Qty   Orders               Price    Qty   Orders
  ──────  ─────  ──────               ──────  ─────  ──────
  150.00   800     3    ◄── best bid  150.25   500     1    ◄── best ask
  149.75   650     2                  150.50   300     2
  149.50   200     1                  150.75   450     3
  149.25   500     2                  151.00   200     1
  149.00   300     1                  151.25   350     2

                    ▲                  ▲
                    └── SPREAD ────────┘
                       (150.25 - 150.00 = 0.25)

Reading this table:

  • The best bid (150.00) and best ask (150.25) define the current spread.
  • A MARKET BUY would consume the ask side top-down (best ask first): 500 @ 150.25, then 300 @ 150.50, etc., until its quantity is filled.
  • A MARKET SELL would consume the bid side top-down: 800 @ 150.00, then 650 @ 149.75, etc.
  • A LIMIT BUY at 150.10 would not cross the spread (best ask is 150.25 > 150.10), so it would rest on the bid side at a new level between 150.00 and 150.25.
  • Within the 150.00 bid level, 3 separate orders are resting. The order with the earliest timestamp fills first when a sell aggressor arrives.

The "Qty" column shows total visible quantity at that level. Iceberg orders only contribute their visible_qty (the current peak); the hidden reserve is not shown.

Internal representation

OrderBook (per symbol)
├── _bids        max-heap  [(-price, timestamp, order), ...]
├── _asks        min-heap  [( price, timestamp, order), ...]
├── _buy_stops   min-heap  [( stop_price, timestamp, order), ...]
├── _sell_stops   max-heap  [(-stop_price, timestamp, order), ...]
├── _order_index  dict[order_id → Order]   (all resting orders)
└── _entry_index  dict[order_id → HeapEntry] (bid/ask heap entries)

Price-time priority: within the same price level, earlier-submitted orders are filled first. Lazy deletion is used — heap entries are marked invalid on cancel/fill and skipped on next access.

Thread Model

Each process is single-process, single-thread for the main logic, with one optional background thread for ZMQ receiving in interactive processes (gateway, viewer, orders, clearing).

The engine runs a single-threaded event loop using zmq.Poller with a 200 ms timeout, making it safe from concurrent modification without locks.

Core Matching Algorithm — In Depth

This section describes the exact data structures, algorithms, and time complexities used by the matching engine.

Order Book Organization

Each symbol gets its own OrderBook instance. Internally it maintains six primary data structures organized for fast price-time-priority matching:

OrderBook("AAPL")
├── _bids          max-heap  [HeapEntry(-price, timestamp, order), ...]
├── _asks          min-heap  [HeapEntry( price, timestamp, order), ...]
├── _buy_stops     min-heap  [(stop_price, timestamp, order), ...]
├── _sell_stops    max-heap  [(-stop_price, timestamp, order), ...]
├── _bid_qty       dict[int, int]      price_ticks → total visible resting qty
├── _ask_qty       dict[int, int]      price_ticks → total visible resting qty
├── _order_index   dict[order_id, Order]       all resting orders (fast cancel lookup)
└── _entry_index   dict[order_id, HeapEntry]   heap entry pointers (lazy delete)

Why heaps? Python's heapq gives us O(log n) insertion and O(1) peek at best price. Since we always match against the best available price, a heap is the natural choice.

Bids use negated prices so that heapq (a min-heap) pops the highest bid first:

# Bid key:  (-price, timestamp)  →  highest price wins, ties broken by earliest time
# Ask key:  ( price, timestamp)  →  lowest  price wins, ties broken by earliest time

Price-level quantity indexes (_bid_qty, _ask_qty) are auxiliary dict[price, int] maps that track the aggregate visible quantity at each price level. They enable O(p) FOK pre-checks (where p = number of distinct price levels) instead of walking every heap entry.

Heap Entry and Lazy Deletion

Each heap entry is a wrapper object:

@dataclass
class HeapEntry:
    key:   tuple       # (-price, ts) for bids; (price, ts) for asks
    order: Order
    valid: bool = True # set False on cancel/fill → "tombstone"

When an order is cancelled or filled, we do not remove it from the heap immediately (which would require O(n) search + O(log n) sift). Instead we mark entry.valid = False (O(1)). Stale entries are garbage-collected lazily when they bubble to the top during _peek():

┌────────────────────────────────────────────────────────┐
│               _asks min-heap                           │
│                                                        │
│   top → [100.0, t=1, VALID]  ← best ask                │
│          [100.0, t=3, INVALID]  ← tombstone, skipped   │
│          [101.5, t=2, VALID]                           │
│          [102.0, t=4, VALID]                           │
└────────────────────────────────────────────────────────┘

_peek() pops invalid entries until a valid one is at the top.
Amortized O(1) access to the best price.

Matching Algorithm: The Sweep

The core of the matching engine is the _sweep() function. It is called by MARKET, LIMIT, FOK, and ICEBERG order types.

Pseudocode

function SWEEP(aggressor, opposite_heap, price_limit):
    while aggressor.remaining_qty > 0:
        best ← PEEK(opposite_heap)        // O(1) amortized (lazy GC)
        if best is None:
            break                          // no more resting orders
        if price_limit exists:
            if BUY  and best.price > price_limit: break
            if SELL and best.price < price_limit: break

        // Self-match prevention (SMP)
        if aggressor.gateway_id == best.gateway_id and SMP enabled:
            handle SMP action (cancel aggressor / resting / both)
            continue or return

        fill_qty   ← min(aggressor.remaining_qty, best.remaining_qty)
        fill_price ← best.price             // passive price wins (maker gets their price)

        APPLY_FILL(aggressor, best, fill_qty, fill_price)

Visual Flow

                 Incoming BUY LIMIT @ 101.0, qty=25
          ┌─────── ASKS HEAP (min-heap) ──────────┐
          │                                       │
          │  [100.0, t=1, qty=10]  ← best ask     │ ← fills 10 @ 100.0
          │  [100.5, t=2, qty=8 ]  ← next best    │ ← fills 8  @ 100.5
          │  [101.0, t=3, qty=20]  ← crosses      │ ← fills 7  @ 101.0
          │  [102.0, t=4, qty=5 ]  ← above limit  │ ← STOP: price > 101.0
          │                                       │
          └───────────────────────────────────────┘
          Result: 3 trades (10+8+7 = 25 filled), aggressor FILLED

Order-Type Dispatch

incoming order
      ├─ MARKET  ──→ SWEEP(no price_limit) → discard unfilled remainder
      ├─ LIMIT   ──→ SWEEP(price=order.price) → REST unfilled portion on own side
      ├─ FOK     ──→ PRE-CHECK available qty via _bid_qty/_ask_qty
      │                 if insufficient → REJECT immediately
      │                 else → SWEEP(price=order.price)
      ├─ ICEBERG ──→ SWEEP visible slice → replenish peak from hidden qty → REST
      └─ STOP / STOP_LIMIT ──→ add to stop heap → no immediate match
                                 triggers later when last_trade_price crosses stop_price

Apply Fill

When a match is found, _apply_fill performs these updates atomically:

function APPLY_FILL(aggressor, passive, fill_qty, fill_price):
    1. Create Trade object (symbol, buyer/seller, price, qty, timestamp)
    2. Update last_trade_price, last_buy/sell_price
    3. aggressor.remaining_qty -= fill_qty
       → status = FILLED if 0 else PARTIAL
    4. passive.remaining_qty   -= fill_qty
       → status = FILLED if 0 else PARTIAL
       → if FILLED: mark HeapEntry.valid = False (tombstone)
    5. Deduct fill_qty from price-level qty index (_bid_qty / _ask_qty)
    6. If passive is ICEBERG and displayed_qty exhausted:
       → replenish displayed_qty from hidden remainder
       → update timestamp (loses time priority — back of queue)
       → re-insert into heap with fresh key

Stop Order Trigger Mechanism

Stop orders live in separate heaps, sorted by trigger price:

_buy_stops:  min-heap by ( stop_price, timestamp) — triggers when price RISES to/above
_sell_stops: max-heap by (-stop_price, timestamp) — triggers when price FALLS to/below

After every trade, _check_stops() peeks at both heaps:

function CHECK_STOPS(last_trade_price):
    triggered = []

    // BUY stops: sorted cheapest first; fire all where last_price >= stop
    while _buy_stops not empty:
        if top.stop_price > last_trade_price: break
        pop entry
        convert STOP → MARKET (or STOP_LIMIT → LIMIT)
        triggered.append(order)

    // SELL stops: sorted most expensive first; fire all where last_price <= stop
    while _sell_stops not empty:
        if top.stop_price < last_trade_price: break
        pop entry
        convert STOP → MARKET (or STOP_LIMIT → LIMIT)
        triggered.append(order)

    // Re-process each triggered order through the book
    for order in triggered:
        process(order)  → may produce additional trades → may trigger more stops (recursion)

Resting an Order

When a LIMIT order does not fully cross the spread, its remainder is placed on the book:

function REST(order):
    if BUY:
        key = (-order.price, order.timestamp)   # negated → max-heap behavior
        heappush(_bids, HeapEntry(key, order))
        _bid_qty[order.price] += order.remaining_qty
    else:
        key = (order.price, order.timestamp)
        heappush(_asks, HeapEntry(key, order))
        _ask_qty[order.price] += order.remaining_qty

    _order_index[order.id] = order
    _entry_index[order.id] = entry

Cancellation

function CANCEL(order_id):
    order = _order_index[order_id]          // O(1) lookup
    entry = _entry_index[order_id]          // O(1) lookup
    entry.valid = False                     // tombstone — O(1)
    deduct remaining qty from _bid_qty or _ask_qty
    order.status = CANCELLED
    return order

No heap restructuring needed — lazy deletion handles cleanup on next _peek().

Time Complexity Summary

Operation Complexity Notes
Insert (rest on book) O(log n) heapq.heappush
Best-price access O(1) amortized _peek() with lazy GC of tombstones
Match one level O(log n) Pop from heap
Full sweep (k fills) O(k log n) k = number of resting orders matched
Cancel O(1) Tombstone + dict lookup
FOK pre-check O(p) p = distinct price levels (via qty index)
Stop trigger check O(k log s) k triggered stops out of s total
Snapshot (book image) O(n) Walk all valid entries
Order lookup by ID O(1) _order_index dict

Where n = total resting orders on one side of one book.

Combo Orders — Data Structures and Tracking

Combo orders are parent containers that decompose into normal child orders. The engine uses two dictionaries to track the parent-child relationship:

Engine
├── _combos           dict[combo_internal_id → ComboOrder]
└── _order_to_combo   dict[child_order_id → combo_internal_id]

A ComboOrder holds per-leg state:

ComboOrder
├── id                 str (internal UUID)
├── combo_id           str (user label)
├── gateway_id         str
├── combo_type         AON
├── tif                DAY | GTC
├── legs               list[ComboLeg]        (2–10 entries)
├── status             PENDING | PARTIALLY_MATCHED | MATCHED | FAILED | CANCELLED
├── child_order_ids    list[str]             (parallel to legs by index)
├── leg_fill_qty       dict[leg_index → int] (filled qty per leg)
└── leg_statuses       dict[leg_index → str] (OrderStatus.value per leg)

Combo Lifecycle State Machine

stateDiagram-v2
    [*] --> PENDING
    PENDING --> PARTIALLY_MATCHED : first leg fills
    PENDING --> CANCELLED : user cancel → cascade-cancel all legs
    PARTIALLY_MATCHED --> MATCHED : all legs filled
    PARTIALLY_MATCHED --> FAILED : any leg cancelled/expired → cascade-cancel siblings
    MATCHED --> [*]
    CANCELLED --> [*]
    FAILED --> [*]

Combo Entry Algorithm

function HANDLE_COMBO_ORDER(payload):
    combo = ComboOrder.from_dict(payload)

    // === Validation phase ===
    validate gateway auth                            O(1)
    validate 2 ≤ legs ≤ 10                          O(1)
    validate no duplicate symbols                    O(L) where L=leg count
    validate all symbols in allowlist                O(L)
    validate each leg (qty > 0, price if needed)    O(L)

    ACK combo to gateway

    // === Child order creation phase ===
    for i, leg in enumerate(combo.legs):             O(L)
        child = Order.create(from leg fields)
        child.combo_parent_id = combo.id
        child.leg_index = i

        combo.child_order_ids.append(child.id)       O(1)
        _order_to_combo[child.id] = combo.id         O(1)
        _order_symbol[child.id]   = leg.symbol       O(1)

        trades, events = book.process(child)         O(k log n) per leg

        // Publish fills/rejects for immediate matches
        for event in events:
            publish fill/reject messages

        combo.leg_statuses[i] = child.status.value
        combo.leg_fill_qty[i] = filled amount

    _combos[combo.id] = combo                        O(1)
    UPDATE_COMBO_STATUS(combo)                       O(L)

Combo Status Update (after any child event)

function CHECK_COMBO_AFTER_CHILD_EVENT(child_order):
    combo_id = _order_to_combo[child_order.id]       O(1)
    combo    = _combos[combo_id]                     O(1)

    idx = child_order.leg_index
    combo.leg_statuses[idx] = child_order.status
    combo.leg_fill_qty[idx] = filled amount

    if child_order.status in (CANCELLED, EXPIRED):
        CASCADE_CANCEL(combo, FAILED)                O(L)
        return

    UPDATE_COMBO_STATUS(combo)                       O(L)

function UPDATE_COMBO_STATUS(combo):
    if all leg_statuses == FILLED:                   O(L)
        combo.status = MATCHED
        publish combo.status MATCHED
    elif any leg has PARTIAL or FILLED fill:
        combo.status = PARTIALLY_MATCHED
        publish combo.status PARTIALLY_MATCHED

Cascade Cancel

function CASCADE_CANCEL(combo, terminal_status):
    combo.status = terminal_status

    for child_id in combo.child_order_ids:           O(L)
        symbol = _order_symbol[child_id]             O(1)
        book   = books[symbol]                       O(1)
        book.cancel_order(child_id)                  O(1) — tombstone
        publish order.cancelled
        remove from _order_symbol, _order_to_combo   O(1)

    publish combo.status

Combo Event Propagation Flow

  ┌─────────────────────────────────────────────────────────────────────┐
  │                       MATCHING ENGINE                               │
  │                                                                     │
  │   incoming order ──► OrderBook.process()                            │
  │         │                    │                                      │
  │         │              fills/trades                                 │
  │         │                    │                                      │
  │         ▼                    ▼                                      │
  │   publish fill       was this a combo child?                        │
  │   publish trade       │                                             │
  │                       ├── NO  → done                                │
  │                       └── YES → _check_combo_after_child_event()    │
  │                                      │                              │
  │                         ┌────────────┴────────────┐                 │
  │                         │                         │                 │
  │                    child FILLED?            child CANCELLED?        │
  │                         │                         │                 │
  │                         ▼                         ▼                 │
  │                  update leg_fill_qty       CASCADE_CANCEL           │
  │                  update leg_statuses         │                      │
  │                         │                    ├── cancel siblings    │
  │                         ▼                    └── publish FAILED     │
  │                  all legs FILLED?                                   │
  │                    │          │                                     │
  │                   YES         NO                                    │
  │                    │          │                                     │
  │                    ▼          ▼                                     │
  │              publish      publish                                   │
  │              MATCHED    PARTIALLY_MATCHED                           │
  │                                                                     │
  └─────────────────────────────────────────────────────────────────────┘

Combo Time Complexity Summary

Operation Complexity Notes
Validate combo O(L) L = leg count (2–10, bounded constant)
Create & post all children O(L × k log n) k matches per leg
Lookup child→combo parent O(1) _order_to_combo dict
Update combo status O(L) Iterate leg_statuses
Cascade-cancel O(L) One tombstone per child
Combo cancel by user O(L) Lookup by combo_id is O(C) worst-case*

* User-facing cancel searches _combos by combo_id string (not internal UUID). With C active combos, worst-case is O(C). In practice C is small and could be indexed if needed.

Since L is bounded at 10, all combo-specific operations are effectively O(1) in big-O terms relative to book size n.

Session State Machine

The engine manages a session state that controls which order types are accepted and whether matching occurs. The scheduler process drives transitions by sending session.transition messages.

States and Transitions

stateDiagram-v2
    [*] --> PRE_OPEN
    PRE_OPEN --> OPENING_AUCTION : session.transition
    PRE_OPEN --> CONTINUOUS : session.transition (shortcut)
    OPENING_AUCTION --> CONTINUOUS : uncross all books + expire ATO orders
    CONTINUOUS --> CLOSING_AUCTION : session.transition
    CONTINUOUS --> CLOSED : session.transition (shortcut)
    CLOSING_AUCTION --> CLOSED : uncross all books + expire ATC orders
    CLOSED --> [*]

Phase Behavior

Phase Matching? Accepts Rejects
PRE_OPEN No LIMIT, STOP, STOP_LIMIT, ICEBERG MARKET, FOK, IOC, ATO, ATC
OPENING_AUCTION No Same as PRE_OPEN + ATO MARKET, FOK, IOC, ATC
CONTINUOUS Yes All types ATO, ATC
CLOSING_AUCTION No Same as PRE_OPEN + ATC MARKET, FOK, IOC, ATO
CLOSED Nothing All

During no-matching phases, accepted orders rest on the book but the sweep is never called. Stop orders are stored but do not fire (no trades occur to trigger them).

Handling a Transition

function HANDLE_SESSION_TRANSITION(to_state):
    if transition not in VALID_TRANSITIONS[current_state]:
        log warning, ignore
        return

    prev_state = current_state
    current_state = to_state

    // If exiting an auction phase → uncross all books
    if prev_state in (OPENING_AUCTION, CLOSING_AUCTION):
        for each symbol book:
            UNCROSS(book)

    // Expire phase-specific orders
    if prev_state == OPENING_AUCTION:
        expire all ATO orders → publish order.expired
    if prev_state == CLOSING_AUCTION:
        expire all ATC orders → publish order.expired

    publish session.state { state, prev_state }

Auction Uncross Algorithm — Equilibrium Price

When exiting an auction phase, accumulated orders execute at a single equilibrium price. This is the price that maximizes total traded quantity.

Algorithm

function UNCROSS(book):
    // 1. Collect all candidate prices (every distinct bid and ask price)
    candidates = sorted(unique(bid_prices ∪ ask_prices))

    best_price    = None
    best_exec_qty = 0
    best_surplus  = ∞

    // 2. Evaluate each candidate
    for P in candidates:
        buy_qty  = Σ resting bid qty where bid_price ≥ P
        sell_qty = Σ resting ask qty where ask_price ≤ P
        exec_qty = min(buy_qty, sell_qty)
        surplus  = |buy_qty − sell_qty|

        // 3. Selection: maximize exec_qty, then minimize surplus, then lowest price
        if exec_qty > best_exec_qty:
            best_price, best_exec_qty, best_surplus = P, exec_qty, surplus
        elif exec_qty == best_exec_qty and surplus < best_surplus:
            best_price, best_exec_qty, best_surplus = P, exec_qty, surplus
        elif exec_qty == best_exec_qty and surplus == best_surplus and P < best_price:
            best_price = P

    if best_exec_qty == 0:
        publish auction.result { eq_price: null, eq_qty: 0 }
        return

    // 4. Execute: fill orders at equilibrium price using price-time priority
    remaining = best_exec_qty
    while remaining > 0:
        // match best bid against best ask, fill_price = best_price
        best_bid = PEEK(bids)
        best_ask = PEEK(asks)
        fill_qty = min(best_bid.remaining, best_ask.remaining, remaining)
        APPLY_FILL(best_bid, best_ask, fill_qty, best_price)
        remaining -= fill_qty

    publish auction.result { eq_price, eq_qty, trades_count, imbalance_side, imbalance_qty }

Key difference from continuous matching: In continuous mode, each fill happens at the resting order's price (price improvement for the aggressor). In auction uncross, ALL fills happen at the same computed equilibrium price — neither the bid's limit nor the ask's limit is used directly.

Complexity

Operation Complexity Notes
Collect candidates O(n) Scan all resting orders
Evaluate all candidates O(p × n) p prices × n cumulative sums (optimizable to O(n) with prefix sums)
Execute fills O(k log n) k fills at equilibrium price

Where p = distinct price levels, n = total resting orders, k = orders matched.

Gateway Authentication

Before a gateway can submit orders, it must authenticate with the engine. If the engine has a gateways.alf section in its config, only listed gateway IDs are accepted.

sequenceDiagram
    participant G as Gateway
    participant E as Engine

    G->>E: system.gateway_connect {gateway_id: "GW01"}
    Note over G,E: PUSH -> PULL
    Note over E: check config allowlist
    E-->>G: system.gateway_auth.GW01 {accepted: true, description: "..."}
    Note over E,G: PUB -> SUB
    G->>E: order.new (now allowed)

If accepted: false, the gateway prints the rejection reason and exits. If no gateways.alf section exists in config, all gateway IDs are auto-accepted (backward-compatible mode).

Orders from gateways that have not completed the auth handshake are rejected with reason "Gateway not connected: {GW_ID}".

Engine Event Loop

The engine processes messages sequentially in a single thread:

function RUN():
    restore_gtc()          // reload GTC orders + combos from disk
    load_config()          // seed stats, inject MM orders

    loop:
        poll PULL socket (200 ms timeout)

        if message available:
            topic, payload = decode(message)
            dispatch:
                "order.new"              → _handle_new_order(payload)
                "order.cancel"           → _handle_cancel(payload)
                "order.combo"            → _handle_combo_order(payload)
                "order.combo_cancel"     → _handle_combo_cancel(payload)
                "session.transition"     → _handle_session_transition(payload)
                "system.gateway_connect" → _handle_gateway_connect(payload)
                "system.symbols_request" → _handle_symbols_request(payload)
                "book.snapshot_request"  → _handle_snapshot_request(payload)
                "order.orders_request"   → _handle_orders_request(payload)

        flush_snapshots()  // publish throttled book images for dirty symbols

        if shutdown requested:
            _shutdown()    // save GTC, expire DAY, save combos, publish EOD
            break

No locks, no shared memory, no race conditions. The sequential dispatch guarantees that combo status transitions and cascade-cancels are atomic from the system's perspective.

Performance Optimizations

The matching engine was optimized from ~57,000 orders/second to ~160,000 orders/second — a 2.8× improvement — using only pure-Python changes (no C extensions, no Cython, no multiprocessing). This section explains each technique and why it works.

How to read the numbers

Every number below was measured with the engine running in a single thread, processing orders through the full hot path (deserialize → validate → match → build messages → publish). "µs" means microseconds (one millionth of a second).

__slots__ on hot-path classes

What it does: By default, Python objects store their attributes in a hidden dictionary (__dict__). When you add __slots__ = ('x', 'y') to a class, Python stores attributes in a fixed-size C array instead.

Why it's faster:

  • Attribute access goes from a hash-table lookup (~60–80 ns) to a direct offset lookup (~30 ns) — roughly 2× faster per access.
  • Each instance uses ~40% less memory (no per-object dict allocation), which means less work for the garbage collector.

Where we applied it:

  • Order (the most common object — one per incoming request)
  • Trade (one per fill)
  • _HeapEntry (internal wrapper — thousands live on the book at once)
  • OrderBook (only a few instances, but accessed on every single order)

Before/after for a dataclass:

# Before
@dataclass
class Order:
    id: str
    symbol: str
    ...

# After — just add slots=True
@dataclass(slots=True)
class Order:
    id: str
    symbol: str
    ...

For classes that aren't dataclasses (like _HeapEntry), you define it manually:

class _HeapEntry:
    __slots__ = ('key', 'order', 'valid')

    def __init__(self, key, order, valid=True):
        self.key   = key
        self.order = order
        self.valid = valid

Drawbacks: You can no longer add arbitrary attributes at runtime (e.g. order.debug_tag = "test" will raise AttributeError). Multiple inheritance becomes tricky — all parent classes must also declare __slots__ or you lose the benefit. Adding a new field requires updating the __slots__ tuple, which is easy to forget.

Fast enum lookup dictionaries

What it does: Replaces Side("BUY") with a pre-built dictionary lookup _SIDE_MAP["BUY"].

Why it's faster: Python's Enum(value) constructor iterates through all members comparing each string (~600–800 ns). A dictionary lookup is ~50 ns. With 5 enums per order, this saves ~3 µs per deserialization call.

# Build once at module load time
_SIDE_MAP = {v.value: v for v in Side}      # {"BUY": Side.BUY, "SELL": Side.SELL}
_TYPE_MAP = {v.value: v for v in OrderType}

# Then in from_dict():
side = _SIDE_MAP[d["side"]]   # ~50 ns instead of Side("BUY") at ~700 ns

Drawbacks: If you add a new enum member, you must remember that the lookup dict was built at import time — it won't automatically include the new member unless you rebuild it. Also, an invalid value like _SIDE_MAP["INVALID"] raises a KeyError instead of the more descriptive ValueError that Side("INVALID") would give, making debugging slightly harder.

Single time.time() call per order (cached timestamp)

What it does: Calls time.time() once at the top of the hot path and passes the result (now) down to every function that needs a timestamp.

Why it's faster: time.time() is a system call — it crosses from Python into the OS kernel and back. On macOS this costs ~300–500 ns. A single aggressive order that triggers stops could call time.time() 4–6 times. By caching it, we pay the cost exactly once.

def _handle_new_order(self, payload):
    now = time.time()                    # one syscall
    ...
    trades, events = book.process(order, now=now)  # passed through

# Inside the order book:
def _apply_fill(self, aggressor, passive, qty, price, trades, events, now):
    trade = Trade.create(..., now=now)   # reuses cached timestamp

Drawbacks: All fills and stop triggers within the same order share the exact same timestamp, even if matching takes a few microseconds. This means you lose sub-microsecond precision for sequencing events within a single order. It also pollutes the function signatures — every internal method now carries an extra now parameter, making the code slightly harder to read and test.

Monotonic integer trade IDs (replacing uuid4())

What it does: Generates trade IDs as sequential integers (1, 2, 3, ...) instead of random UUIDs.

Why it's faster: uuid.uuid4() reads from /dev/urandom (a kernel call) and then formats 128 random bits into a hyphenated string — total cost ~1.5 µs. next(counter) is a pure-Python operation costing ~30 ns.

import itertools

_trade_counter = itertools.count(1)

@classmethod
def create(cls, ...):
    return cls(id=str(next(_trade_counter)), ...)

Trade IDs only need to be unique within a single engine session, not globally, so a monotonic counter is safe here.

Drawbacks: IDs are no longer globally unique — if you restart the engine, counter resets to 1 and old trade IDs may collide with new ones. This makes it unsafe to merge trade logs from multiple sessions without adding a session prefix. Also, sequential IDs leak information (e.g. competitors can estimate your trade volume by observing the ID gap between two of their fills).

orjson instead of stdlib json

What it does: Replaces json.dumps(payload).encode() with orjson.dumps(payload).

Why it's faster: orjson is a C-extension JSON serializer that:

  • Encodes directly to bytes (no intermediate str.encode() step)
  • Uses SIMD instructions for string escaping
  • Is ~9× faster than stdlib json for typical 10-key dictionaries
Serializer Cost per call
json.dumps().encode() ~2,100 ns
orjson.dumps() ~230 ns

With 2–4 messages published per order, this alone saved ~4–7 µs on aggressive orders.

try:
    import orjson
    def _dumps(obj): return orjson.dumps(obj)
except ImportError:
    import json
    def _dumps(obj): return json.dumps(obj).encode()

The fallback ensures the code works in environments where orjson isn't installed — it just runs slower.

Drawbacks: Adds a third-party dependency (orjson) that must be installed and kept updated. orjson is stricter than stdlib json — it rejects NaN, Infinity, and non-string dict keys that stdlib silently accepts. If your payloads ever contain these edge cases, you'll get a TypeError at runtime. The library is also platform-specific (compiled C/Rust), so it may not be available on all architectures (e.g. some Alpine Docker images).

Eliminate redundant serialization (to_dict() removal)

What it does: Instead of calling order.to_dict() to build a full 18-key dictionary and then passing it to a message function, we build only the keys the message actually needs, directly from object attributes.

Why it's faster: to_dict() unconditionally copies all 18 fields and calls .value on every enum — cost ~3–4 µs. The ack message only needs 6 of those fields. Building a minimal dict inline costs ~250 ns.

# Before — wasteful
self.pub_sock.send_multipart(
    make_fill_msg(evt.gateway_id, evt.id, ..., order=evt.to_dict())
)

# After — only the fields the consumer needs
self.pub_sock.send_multipart([
    fill_topic_bytes,
    _dumps({
        "order_id": evt.id,
        "fill_qty": evt.quantity - evt.remaining_qty,
        "status":   evt.status.value,
        ...
    }),
])

Drawbacks: The message schema is now implicitly defined at each call site instead of in one canonical to_dict() method. If a downstream consumer adds a field requirement, you need to find and update every inline dict that builds that message type. Forgetting one is a subtle bug. It also makes writing integration tests harder — you can't just mock to_dict() and check its output.

Inlined message construction

What it does: Bypasses helper functions like make_ack_msg() and builds the two-frame ZMQ message ([topic_bytes, payload_bytes]) directly at the call site.

Why it's faster: Each helper function allocates a dict, conditionally merges fields via .update(), then calls encode() which does another function call. That's 3 function calls + 2 dict allocations. Inlining collapses all of that into a single dict literal + one orjson.dumps():

Approach Cost
make_ack_msg(...) ~950 ns
Inlined with _dumps() ~450 ns

Drawbacks: Code duplication — the message format is now repeated at each call site instead of living in one helper. If the message protocol changes (e.g. adding a "version" field to all messages), you must update every inline construction point. The engine code also becomes longer and denser, making code reviews harder. For low-frequency messages (rejects, cancels), the savings are negligible and the readability cost isn't justified.

Pre-cached topic bytes

What it does: ZMQ topic strings like "order.ack.GW01" are the same for every message sent to a given gateway. We encode them once and store the bytes in a dictionary, avoiding repeated f"order.ack.{gw}".encode() calls.

Why it's faster: f-string + .encode() costs ~100 ns per call. With 3–4 messages per order, that's ~300–400 ns wasted on creating the same bytes. A dict lookup is ~50 ns total.

# Populate on first use
self._topic_cache[gw] = f"order.ack.{gw}".encode()

# Hot path
topic = self._topic_cache[gw]  # ~50 ns instead of ~100 ns

Drawbacks: The cache grows unboundedly — if gateways connect and disconnect frequently with unique IDs, the dict accumulates stale entries (a minor memory leak). In practice this is a non-issue since gateway IDs are static, but in a general-purpose system you'd want eviction logic. It also adds a layer of indirection that can confuse readers unfamiliar with the pattern.

Local variable caching in tight loops

What it does: Before entering the matching loop, we copy frequently-accessed object attributes into local variables.

Why it's faster: In CPython, local variable access (LOAD_FAST bytecode) is a direct array-index operation (~30 ns). Attribute access (LOAD_ATTR) involves a descriptor lookup even with __slots__ (~50–70 ns). In a loop that runs 5–50 iterations, this adds up.

def _sweep(self, aggressor, opposite_heap, ...):
    # Cache once before the loop
    _side       = aggressor.side         # LOAD_FAST inside loop
    _smp_action = aggressor.smp_action
    _peek       = self._peek             # avoid self.__dict__ lookup per iteration

    while aggressor.remaining_qty > 0 and opposite_heap:
        best = _peek(opposite_heap)      # local call, not self._peek(...)
        if _side == Side.BUY and best.price > price_limit:
            break
        ...

Drawbacks: Makes the code less obvious — a reader seeing _peek(heap) has to scroll up to understand it's actually self._peek. If the cached attribute is mutable and gets reassigned on self during the loop (unlikely here, but possible in other contexts), the local copy becomes stale and introduces bugs. Debugging is also harder because inspecting self._peek in a debugger won't show the value actually used inside the loop.

__new__ for fast deserialization

What it does: Uses object.__new__(cls) + direct slot writes instead of calling the dataclass-generated __init__ with 19 keyword arguments.

Why it's faster: CPython's function-call machinery needs to parse and bind each keyword argument — with 19 kwargs this alone costs ~400 ns. __new__ creates a bare instance in ~80 ns, and then each o.field = value is a simple STORE_ATTR (~30 ns with slots).

@classmethod
def from_dict(cls, d: dict) -> "Order":
    o = object.__new__(cls)
    o.id         = d["id"]
    o.symbol     = d["symbol"]
    o.side       = _SIDE_MAP[d["side"]]
    ...
    return o

Drawbacks: Completely bypasses __init__, so any validation, default value assignment, or __post_init__ logic in the dataclass is skipped. If you later add a new field with a default value to the dataclass, from_dict won't automatically pick it up — you'll get an AttributeError on first access. This pattern also breaks the implicit contract that dataclass instances are always fully initialized, which can confuse static analysis tools like mypy.

Summary

# Technique Savings per order Applies to
1 __slots__ ~1.5–2 µs All hot-path objects
2 Enum lookup dicts ~3 µs Order.from_dict()
3 Cached timestamp ~1–2 µs Fills + stop triggers
4 Monotonic trade IDs ~1.5 µs Every fill
5 orjson ~4–7 µs Every published message
6 No redundant to_dict() ~3 µs Ack + fill messages
7 Inlined messages ~500 ns Ack + fill + trade
8 Pre-cached topic bytes ~300 ns All messages
9 Local var caching ~200–800 ns Sweep loop
10 __new__ deserialization ~400 ns Order.from_dict()

Result:

Metric Before After Change
Throughput 57,000 TPS 160,000 TPS +180%
Median latency 23.5 µs 8.5 µs −64%
P90 latency 25.7 µs 10.0 µs −61%

All of these improvements are pure Python — no C extensions, no multi-threading, no unsafe hacks. The key insight is that in a tight loop, small costs (100 ns here, 200 ns there) compound rapidly. At 160,000 orders/second, each order has a budget of only 6.25 µs — every nanosecond matters.

Tick And Time Representation

  • Internal prices are stored as integer ticks in core engine/model logic.
  • Internal timestamps are stored as integer nanoseconds (time_ns).
  • Conversion happens at boundaries only:
    • Inbound user/config prices (decimal) -> ticks.
    • Outbound UI/messages/reporting values -> decimal prices.
    • Outbound display timestamps use seconds derived from ns (ns / 1e9).
  • Matching, queue priority, auction price selection, and stop logic all operate on integer values to avoid float drift.