69 lines
4.4 KiB
Markdown
69 lines
4.4 KiB
Markdown
# Optivex — Reference Solution
|
||
|
||
Reference implementation of the Optivex exchange and the system tests used to evaluate participant submissions.
|
||
|
||
## Architecture Overview
|
||
|
||
Four components communicate over TCP using length-prefixed Protobuf 3 messages:
|
||
|
||
```
|
||
┌──────────┐
|
||
│ Admin │
|
||
└──┬───┬───┘
|
||
│ │
|
||
┌───────┘ └────────┐
|
||
▼ ▼
|
||
┌──────────────┐ ┌──────────────┐
|
||
│ Order Book │◄───│ Info │
|
||
└──────────────┘ └──────────────┘
|
||
▲ ▲
|
||
│ ┌───────────────┘
|
||
│ │
|
||
┌──────┴────┴─────┐
|
||
│ Risk Gateway │
|
||
└─────────────────┘
|
||
```
|
||
|
||
- **Order Book** — Matching engine. No upstream dependencies.
|
||
- **Info** — Market data. Mirrors order book state to serve TOP_OF_BOOK and PRICE_DEPTH_BOOK subscriptions.
|
||
- **Admin** — Instrument lifecycle. Orchestrates creation across Order Book and Info.
|
||
- **Risk Gateway** — Execution + risk. Enforces limits before forwarding orders to Order Book. Implements both the `execution` and `risk_limits` protocols.
|
||
|
||
## Protocol Extensions vs Template
|
||
|
||
The solution adds **internal management messages** for Admin ↔ Order Book and Admin ↔ Info communication. These are not part of the participant-facing API.
|
||
|
||
Added to `common.proto` (numbered at 130+/140+ to avoid collisions with the public API range 0–67):
|
||
|
||
| Message Type | Purpose |
|
||
|---|---|
|
||
| `INFO_CREATE_INSTRUMENT_REQUEST/RESPONSE` (130–131) | Admin → Info: register instrument with `order_book_id` |
|
||
| `ORDER_BOOK_CREATE_REQUEST/RESPONSE` (140–141) | Admin → Order Book: create order book |
|
||
| `ORDER_BOOK_DELETE_REQUEST/RESPONSE` (142–143) | Admin → Order Book: delete order book |
|
||
|
||
Note that `info.CreateInstrumentRequest` carries `order_book_id` (book already exists), while `admin.CreateInstrumentRequest` carries `tick_size` (book doesn't exist yet). The Admin component bridges this gap with a two-phase flow: create order book first, then register the instrument in Info with the resulting ID.
|
||
|
||
## Non-Obvious Design Decisions
|
||
|
||
### Matching engine uses negated prices for max-heap
|
||
|
||
Python's `heapq` is min-heap only. Bids need max-heap behavior (highest price first). The sort key negates the price for bids: `(-price, timestamp, order_id)` vs `(+price, timestamp, order_id)` for asks. This gives price-time priority with `order_id` as a final tiebreaker.
|
||
|
||
Cancel uses `list.remove()` + `heapify()` — O(n) rather than O(log n) with lazy deletion, but sufficient for this scale.
|
||
|
||
### Info mirrors order book state from the event stream
|
||
|
||
Info doesn't access the matching engine directly. `OrderBookClientAggregator` reconstructs book state from `OnOrderInserted`, `OnOrderCancelled`, and `OnTrade` events, maintaining quantity-per-price-level aggregates.
|
||
|
||
A **pending trades mechanism** handles the ordering issue where `OnOrderInserted` references `trade_ids` for trades that haven't arrived yet. While pending trades exist, the book is considered inconsistent and market data updates are suppressed. Reads (`get_top_of_book`, `get_price_depth_book`) assert consistency.
|
||
|
||
### Rolling window limits use in-place list pruning
|
||
|
||
`RiskLimitsStore` maintains timestamped event lists for each rolling metric (message rate, order quantity, order amount). On each check, entries older than `now - window_seconds` are pruned with `del list[:first_valid]`. This mutates the list in place — O(n) per check but amortized O(1) per entry since each is pruned exactly once.
|
||
|
||
Limit checks follow a **check-then-record** pattern: `check_order_limits()` validates without side effects, and `record_order_attempt()` is called only after the check passes. This avoids polluting rolling windows with rejected orders.
|
||
|
||
### Decimal precision avoids float artifacts
|
||
|
||
All prices and amounts go through `decimal_from_float()`: `Decimal(str(value)).quantize(Decimal("0.0001"))`. The `str()` intermediate avoids the well-known `Decimal(0.1)` → `0.100000000000000005...` problem.
|