melin_exchange_core/exchange.rs
1//! Exchange: dispatches orders to per-instrument order books.
2//!
3//! All order books run on a single thread (LMAX-style). This keeps event
4//! ordering deterministic and allows portfolio-wide risk checks (margin,
5//! exposure limits) without cross-thread coordination.
6//!
7//! If throughput exceeds a single core, shard by instrument — each shard
8//! stays single-threaded. Note: portfolio risk checks then require
9//! cross-shard message passing, adding latency and complexity.
10
11mod cancel_replace;
12mod execute;
13mod instrument;
14mod snapshot_methods;
15mod token_bucket;
16
17use self::instrument::{inst_mut, inst_ref};
18use self::token_bucket::TokenBucket;
19// Re-exported so existing crate paths (`crate::exchange::InstrumentState`,
20// used by `crate::snapshot`) keep working after the move.
21pub(crate) use self::instrument::InstrumentState;
22use crate::account::AccountManager;
23use crate::orderbook::OrderBook;
24use crate::scheduler::{ScheduledTask, ScheduledTaskHeap, ScheduledTaskKind};
25use crate::types::{
26 AccountId, CircuitBreakerConfig, CurrencyId, ExecutionReport, FeeSchedule, FxHashSet, HashMap,
27 HashMap4, InstrumentSpec, InstrumentStatus, OrderId, Price, RejectReason, ReservationSlot,
28 RiskLimits, Side, Symbol,
29};
30
31/// Top-level exchange managing multiple instruments.
32pub struct Exchange {
33 /// Flat Vec indexed by `Symbol.0` for true O(1) instrument dispatch with
34 /// zero hashing overhead. Boxed to keep empty slots at 8 bytes (null ptr)
35 /// since InstrumentState is large (contains OrderBook). Typical exchanges
36 /// have <100 instruments, so the Vec is tiny.
37 instruments: Vec<Option<Box<InstrumentState>>>,
38 /// Shared account balance manager across all instruments.
39 accounts: AccountManager,
40 /// Currently-live (account, order_id) pairs across all instruments.
41 /// A submission with an `(account, order_id)` already in this set is
42 /// rejected as `DuplicateOrderId` — required because cancel/replace
43 /// look up by that same key, so two simultaneously-live orders sharing
44 /// it would make the lookup ambiguous. Entries are removed when the
45 /// order leaves the book (full fill, cancel, expiry, instrument
46 /// disable). Reuse of an `OrderId` after its original closes is
47 /// permitted by design — the dedup invariant is "no two live orders
48 /// share `(account, order_id)`," not "an `OrderId` is consumed
49 /// forever," which keeps the gateway's session-local id_map workable
50 /// across reconnects without needing to query the engine for HWMs.
51 /// Used as a set: the unit value carries no information.
52 /// Open-addressing (hashbrown) set rather than the project's
53 /// HashMap4 (astenn) — `(AccountId, OrderId)` has unbounded
54 /// distinct keys under the bench's churn pattern but bounded live
55 /// count, exactly the workload extendible hashing handles poorly
56 /// (directory grows with lifetime inserts). Hashbrown's backshift
57 /// deletion keeps capacity tracking the live set.
58 live_order_ids: FxHashSet<(AccountId, OrderId)>,
59 /// Per-account count of resting orders (on the book or pending stops).
60 /// Used to reject withdrawals while orders are outstanding.
61 /// Entries are removed when the count reaches zero.
62 order_counts: HashMap4<AccountId, u32>,
63 /// Per-key high-water mark for request sequences. Prevents duplicate
64 /// processing on retry after network failure. Keyed by u64 hash of
65 /// the client's Ed25519 public key. Never evicted — key count is
66 /// small (~100 max for any exchange).
67 key_hwm: HashMap<u64, u64>,
68 /// Min-heap of pending time-driven tasks (GTD expiry, halt evaluation,
69 /// session transitions). Drained at the head of every event the matching
70 /// stage processes — see `drain_due_scheduled_tasks`. Empty until a
71 /// feature pushes a task; the substrate alone never schedules anything.
72 scheduled_tasks: ScheduledTaskHeap,
73 /// Pre-allocated empty `OrderBook`s, populated by
74 /// [`Self::prefault_seed`] and indexed by symbol. When
75 /// `add_instrument` runs on the matching thread, it takes the book
76 /// from this pool instead of allocating a fresh one — avoiding the
77 /// 5–11 ms first-touch + mlock spike that would otherwise show up
78 /// during seed (matching thread is mlock-MCL_FUTURE so any new
79 /// allocation triggers a per-page lock, faulting thousands of pages
80 /// at once). Empty slot in the pool means the book has been taken
81 /// or was never pre-allocated for that symbol; `add_instrument`
82 /// falls back to a fresh allocation in that case.
83 instrument_pool: Vec<Option<OrderBook>>,
84 /// When true, new order books are created with generous pre-allocation
85 /// to avoid HashMap resize spikes on the hot path.
86 presized: bool,
87 /// Maximum number of open orders (resting limits + pending stops, across
88 /// all instruments) per account. New submissions are rejected with
89 /// `ExceedsMaxOpenOrders` once an account reaches this count. `0` means
90 /// unlimited (opt-out). Bounds the per-account contribution to the
91 /// global `order_index`/`stop_index` and the matching-stage hash maps;
92 /// without it an authenticated client can submit unbounded resting
93 /// limits at distinct prices and OOM the server (SEC-03).
94 ///
95 /// `u32` matches the type of the `order_counts` value field.
96 ///
97 /// Determinism note: the cap shapes Rejected reports, which are
98 /// observable state. Primary and every replica must run with the same
99 /// value or replay will diverge — the cap is operator config, not a
100 /// journaled event, so it is the operator's responsibility to keep it
101 /// consistent across the cluster (same shape as `--authorized-keys`).
102 max_open_orders_per_account: u32,
103 /// Per-account order-submission rate limit (token bucket, SEC-04).
104 /// `max_orders_per_second` is the steady-state refill rate;
105 /// `max_orders_burst` is the bucket capacity (max consecutive orders
106 /// after a quiet period). `0` for either field disables the limiter
107 /// (opt-out). Buckets are populated lazily in `order_buckets` on first
108 /// submission per account.
109 ///
110 /// Determinism note: same as the open-orders cap above — Rejected
111 /// reports are observable, so primary and every replica must run
112 /// with matching values. The bucket math uses the journaled
113 /// `ApplyCtx::now_ns` (stamped by the reader at ingest), not wall-
114 /// clock, so bit-for-bit replay holds across the cluster.
115 ///
116 /// Snapshot continuity: per-account bucket state (`tokens` +
117 /// `last_refill_ns`) is serialised in snapshot format v18+ via
118 /// [`Exchange::snapshot_order_buckets`] /
119 /// [`Exchange::restore_order_buckets`]. A replica restoring from a
120 /// snapshot taken mid-throttle sees the same bucket state the
121 /// primary had, so the very next event produces an identical
122 /// accept/reject decision — no divergence window.
123 ///
124 /// `u32` for both fields: covers the realistic operator range
125 /// (1..=10_000_000 orders/sec) without bloating the per-account
126 /// `TokenBucket` row.
127 max_orders_per_second: u32,
128 max_orders_burst: u32,
129 /// Per-account token-bucket state for the rate limiter. Lazily inserted
130 /// on the first *submission attempt* (not first rest) per account, and
131 /// evicted on the close path (`release_open_order`) once the account's
132 /// open-order count reaches zero AND the bucket has refilled to full
133 /// capacity at the current event time. See [`Exchange::try_evict_bucket`]
134 /// for the policy and why "at full capacity" makes the eviction
135 /// observationally equivalent to keeping the entry.
136 ///
137 /// Steady-state size therefore tracks accounts currently inside a
138 /// throttle window (or holding open orders), not the cumulative ever-
139 /// active account count.
140 ///
141 /// Empty when `max_orders_per_second == 0` or `max_orders_burst == 0`
142 /// (limiter disabled). HashMap4 for the same reason as `order_counts`:
143 /// fxhash on a 4-byte key is cheaper than the default hasher on the
144 /// hot path.
145 order_buckets: HashMap4<AccountId, TokenBucket>,
146 /// `now_ns` of the most recently applied event. Stashed by
147 /// [`Application::apply`](crate::application_impl) before dispatching
148 /// to per-event methods (`execute`, `cancel`, etc.) so the rate limiter
149 /// can read a deterministic clock without threading a parameter through
150 /// every public method's signature. Initialised to `0` and overwritten
151 /// every time `apply` is called — never reset between events; readers
152 /// outside `apply` see whatever the last `apply` left here.
153 ///
154 /// Footgun for direct callers (tests, embedded users): if the rate
155 /// limiter is active (`max_orders_per_second > 0 &&
156 /// max_orders_burst > 0`) and a caller invokes `Exchange::execute`
157 /// without ever calling [`Self::set_current_event_ts_ns`], the
158 /// limiter operates against a frozen `now_ns = 0` clock — buckets
159 /// don't refill, so each account hits a hard ceiling at its
160 /// initial burst. Engine-library users who never activate the rate
161 /// limiter (engine-default `max_orders_per_second == 0`) bypass
162 /// the limiter entirely and can ignore this. Test code in this
163 /// crate uses the `execute_at(exchange, now_ns, …)` helper in the
164 /// test module to wrap the stamp + execute pair; embedded users
165 /// must call `set_current_event_ts_ns` themselves before each
166 /// `execute` (or call through `Application::apply`, which stamps).
167 current_event_ts_ns: u64,
168 /// Scratch buffer for consumed-slot drain during `execute`. Reused
169 /// across calls to eliminate the per-event Vec allocation that
170 /// `inst.book.drain_consumed_slots().collect()` would otherwise
171 /// perform; the allocator's first-touch on a freshly-mmap'd page
172 /// was the dominant source of the engine's deep-tail outliers
173 /// (~100µs spikes at p99.99999 under realistic flow on EPYC 9255). Vec for sequential append + iterate; capacity held
174 /// across calls via `mem::take` / put-back at the end of `execute`.
175 scratch_consumed: Vec<(AccountId, OrderId, Side, ReservationSlot)>,
176 /// Scratch buffer for `freed` tracking inside `execute`. Same
177 /// rationale as `scratch_consumed`. Vec rather than HashSet because
178 /// typical depth is small (0-5 entries) — linear `.contains()` beats
179 /// hashing at this size. Caveat for future tuning: at 10M ord/s a
180 /// pathological deep-cross (thousands of fills against a single
181 /// aggressive market order) would push `freed.contains()` into
182 /// O(n²) — switch to a small ahash set or sort+binary-search if
183 /// that workload becomes realistic.
184 scratch_freed: Vec<(AccountId, OrderId)>,
185}
186
187/// Default per-account open-order cap when no operator override is set.
188/// Sized for active trading accounts (institutional market-makers running
189/// hundreds of resting orders × dozens of instruments) while still
190/// bounding worst-case `order_index` growth from a single rogue account.
191pub const DEFAULT_MAX_OPEN_ORDERS_PER_ACCOUNT: u32 = 10_000;
192
193/// Default per-account sustained order rate (orders/sec) when no operator
194/// override is set. `0` = limiter disabled — engine library users not going
195/// through the `melin-server` CLI start unthrottled. The CLI applies its
196/// own non-zero default (see `--max-orders-per-second` in `crates/exchange/server`),
197/// keeping production deployments protected while leaving in-process tests
198/// and embedded users unaffected. Same opt-out shape as the open-orders
199/// cap (which was switched to be on-by-default in SEC-03 because it has
200/// no time dependency; the rate limiter does, and most non-server harnesses
201/// don't advance `now_ns`).
202pub const DEFAULT_MAX_ORDERS_PER_SECOND: u32 = 0;
203/// Default per-account burst capacity (max consecutive orders after a
204/// quiet period). Paired with `DEFAULT_MAX_ORDERS_PER_SECOND = 0`, this is
205/// inert at the engine default — the CLI provides the production value.
206pub const DEFAULT_MAX_ORDERS_BURST: u32 = 0;
207
208impl Exchange {
209 pub fn new() -> Self {
210 Self {
211 instruments: Vec::new(),
212 accounts: AccountManager::new(),
213 live_order_ids: FxHashSet::default(),
214 order_counts: HashMap4::default(),
215 key_hwm: HashMap::default(),
216 scheduled_tasks: ScheduledTaskHeap::new(),
217 instrument_pool: Vec::new(),
218 presized: false,
219 max_open_orders_per_account: DEFAULT_MAX_OPEN_ORDERS_PER_ACCOUNT,
220 max_orders_per_second: DEFAULT_MAX_ORDERS_PER_SECOND,
221 max_orders_burst: DEFAULT_MAX_ORDERS_BURST,
222 order_buckets: HashMap4::default(),
223 current_event_ts_ns: 0,
224 // Match OrderBook::consumed_slots's 64-element pre-alloc so
225 // typical fills (0-5 entries) never trigger growth.
226 scratch_consumed: Vec::with_capacity(64),
227 scratch_freed: Vec::with_capacity(64),
228 }
229 }
230
231 /// Create an Exchange pre-sized for production workloads.
232 pub fn with_capacity() -> Self {
233 Self {
234 // 64 instrument slots — each empty slot is 8 bytes (null Box ptr).
235 instruments: Vec::with_capacity(64),
236 accounts: AccountManager::with_capacity(),
237 // 1M live-order slots × ~24 bytes per entry ≈ 24 MB. Sized
238 // for the default benchmark's peak resting depth — orders
239 // turn over fast at 10M ord/s so the live count is much
240 // smaller than the lifetime total. hashbrown-backed: the
241 // bounded-live-count + unbounded-distinct-keys workload
242 // doesn't grow the table once warmup settles.
243 live_order_ids: FxHashSet::with_capacity_and_hasher(1_000_000, Default::default()),
244 // 1M accounts × ~32 bytes per entry ≈ 32 MB. Covers the
245 // default benchmark (1M accounts) with no hot-path resizes.
246 // Pages are faulted during prefault() via insert/clear.
247 order_counts: HashMap4::with_capacity_and_hasher(1_000_000, Default::default()),
248 key_hwm: HashMap::default(),
249 scheduled_tasks: ScheduledTaskHeap::new(),
250 instrument_pool: Vec::new(),
251 presized: true,
252 max_open_orders_per_account: DEFAULT_MAX_OPEN_ORDERS_PER_ACCOUNT,
253 max_orders_per_second: DEFAULT_MAX_ORDERS_PER_SECOND,
254 max_orders_burst: DEFAULT_MAX_ORDERS_BURST,
255 // Same 1M sizing as `order_counts` — bucket count tracks the
256 // active-account count.
257 order_buckets: HashMap4::with_capacity_and_hasher(1_000_000, Default::default()),
258 current_event_ts_ns: 0,
259 scratch_consumed: Vec::with_capacity(64),
260 scratch_freed: Vec::with_capacity(64),
261 }
262 }
263
264 /// Pre-allocate collections for a known bulk-seed workload.
265 ///
266 /// Sizes the balance map to `num_accounts × num_instruments × 2`
267 /// (base + quote per instrument per account) so the seed phase
268 /// doesn't hit multi-hundred-ms rehash stalls as the map grows.
269 ///
270 /// Populates the instrument pool with one `OrderBook` per expected
271 /// instrument (indexed by symbol). `add_instrument` pulls from
272 /// this pool instead of allocating fresh, avoiding the 5-11 ms
273 /// first-touch + mlock spike during seed (matching thread runs
274 /// under MCL_FUTURE so any new allocation faults thousands of
275 /// pages at once).
276 pub fn prefault_seed(&mut self, num_accounts: usize, num_instruments: usize) {
277 let balance_capacity = num_accounts
278 .saturating_mul(num_instruments)
279 .saturating_mul(2);
280 self.accounts = AccountManager::with_balance_capacity(balance_capacity);
281 self.instruments.reserve(num_instruments.max(64));
282 self.instrument_pool = (0..num_instruments)
283 .map(|i| Some(OrderBook::with_capacity(Symbol(i as u32))))
284 .collect();
285 self.live_order_ids = FxHashSet::with_capacity_and_hasher(1_000_000, Default::default());
286 self.order_counts =
287 HashMap4::with_capacity_and_hasher(num_accounts.max(1_000_000), Default::default());
288 self.order_buckets =
289 HashMap4::with_capacity_and_hasher(num_accounts.max(1_000_000), Default::default());
290 }
291
292 /// Reconstruct from pre-built parts (used by snapshot restore).
293 pub(crate) fn from_parts(
294 instruments: Vec<Option<Box<InstrumentState>>>,
295 accounts: AccountManager,
296 key_hwm: HashMap<u64, u64>,
297 scheduled_tasks: ScheduledTaskHeap,
298 ) -> Self {
299 // Derive order_counts and live_order_ids from order_index across
300 // all instruments. Both are fully reconstructible from the books,
301 // so the snapshot doesn't carry them — the only source of truth
302 // is the order index.
303 let mut order_counts: HashMap4<AccountId, u32> = HashMap4::default();
304 let mut live_order_ids: FxHashSet<(AccountId, OrderId)> = FxHashSet::default();
305 for inst in &instruments {
306 if let Some(inst) = inst.as_deref() {
307 for ((account, order_id), _) in inst.book.active_order_slots() {
308 *order_counts.entry(account).or_default() += 1;
309 live_order_ids.insert((account, order_id));
310 }
311 for ((account, order_id), _) in inst.book.active_stop_slots() {
312 *order_counts.entry(account).or_default() += 1;
313 live_order_ids.insert((account, order_id));
314 }
315 }
316 }
317 Self {
318 instruments,
319 accounts,
320 live_order_ids,
321 order_counts,
322 key_hwm,
323 scheduled_tasks,
324 instrument_pool: Vec::new(),
325 presized: false,
326 max_open_orders_per_account: DEFAULT_MAX_OPEN_ORDERS_PER_ACCOUNT,
327 max_orders_per_second: DEFAULT_MAX_ORDERS_PER_SECOND,
328 max_orders_burst: DEFAULT_MAX_ORDERS_BURST,
329 // Snapshot restore: limiter starts disabled by default and
330 // the bucket map starts empty. `restore_state` calls
331 // `restore_order_buckets` after `from_parts` to repopulate
332 // from the snapshot's v18+ bucket section, and the server
333 // wiring then reapplies the operator config (which, going
334 // from disabled `(0, 0)` to active, preserves the restored
335 // buckets — see `set_max_orders_per_second`).
336 order_buckets: HashMap4::default(),
337 current_event_ts_ns: 0,
338 scratch_consumed: Vec::with_capacity(64),
339 scratch_freed: Vec::with_capacity(64),
340 }
341 }
342
343 /// Configure the per-account open-order cap (`0` = unlimited). See the
344 /// field doc on `max_open_orders_per_account` for semantics and the
345 /// primary/replica determinism constraint.
346 pub fn set_max_open_orders_per_account(&mut self, max: u32) {
347 self.max_open_orders_per_account = max;
348 }
349
350 /// Read back the configured per-account open-order cap. Test/admin only.
351 pub fn max_open_orders_per_account(&self) -> u32 {
352 self.max_open_orders_per_account
353 }
354
355 /// Configure the per-account order-submission rate limit (SEC-04).
356 /// Argument semantics (active values, `0` = disabled, etc.) live on
357 /// the `max_orders_per_second` / `max_orders_burst` field docs above.
358 ///
359 /// Bucket-clearing rule: existing per-account bucket state is
360 /// cleared **only** when transitioning between two active
361 /// configurations whose `(rate, burst)` values differ — the online-
362 /// reconfig case where tokens credited at the old rate could over-
363 /// credit under the new one. All other transitions preserve buckets:
364 ///
365 /// - **Initial activation** (previous config was `(0, _)` or
366 /// `(_, 0)`, i.e. limiter was disabled): buckets that exist on
367 /// the map can only have come from a snapshot restore via
368 /// `restore_order_buckets`, and that is exactly the state we
369 /// need to preserve to close the SEC-04 divergence window. A
370 /// fresh engine with no restored buckets is unaffected.
371 /// - **Deactivation** (new config is `(0, _)` or `(_, 0)`): the
372 /// limiter is off, so bucket contents are unobserved. Keeping
373 /// them is harmless and avoids losing state if the operator
374 /// later re-enables with the same values.
375 /// - **No-op reapply** (values unchanged): obviously preserve.
376 ///
377 /// Determinism: must match across primary and replicas — see the field
378 /// docs on `max_orders_per_second` / `max_orders_burst`.
379 ///
380 /// Side effect (online-reconfig path only): clearing buckets resets
381 /// every account to a full burst at next first-touch. Operators
382 /// should treat online reconfiguration as a rare, audit-logged
383 /// change — frequent re-tuning is effectively a throttle bypass.
384 /// Engine library users embedding the matching core should gate the
385 /// call behind their own auth path.
386 pub fn set_max_orders_per_second(&mut self, rate: u32, burst: u32) {
387 let was_active = self.max_orders_per_second > 0 && self.max_orders_burst > 0;
388 let will_be_active = rate > 0 && burst > 0;
389 let values_differ = rate != self.max_orders_per_second || burst != self.max_orders_burst;
390 self.max_orders_per_second = rate;
391 self.max_orders_burst = burst;
392 if was_active && will_be_active && values_differ {
393 // Online reconfig between two active configs — drop stale
394 // tokens so the new rate applies uniformly from the next
395 // event. Cheap: the limiter cleared accounts will repopulate
396 // lazily on their first post-reconfig submission.
397 self.order_buckets.clear();
398 }
399 }
400
401 /// Read back the configured rate limit `(rate_per_sec, burst)`.
402 /// Test/admin only.
403 pub fn max_orders_per_second(&self) -> (u32, u32) {
404 (self.max_orders_per_second, self.max_orders_burst)
405 }
406
407 /// Stash the current event's `now_ns` so per-event methods (`execute`,
408 /// `cancel`, …) can read a deterministic clock without each method
409 /// taking a `now_ns` parameter. Called by `Application::apply` exactly
410 /// once per event before dispatch.
411 #[inline]
412 pub fn set_current_event_ts_ns(&mut self, now_ns: u64) {
413 self.current_event_ts_ns = now_ns;
414 }
415
416 /// Decrement `account`'s open-order count by one and, if it just
417 /// reached zero, drop the `order_counts` entry and try to evict the
418 /// rate-limiter bucket. Single chokepoint for the per-event close
419 /// paths (cancel, end-of-day, disable, GTD expiry, taker/maker
420 /// completion) so the bucket-eviction policy lives in one place.
421 /// Bulk close paths (`cancel_all`) decrement by N and call
422 /// [`Self::try_evict_bucket`] directly.
423 #[inline]
424 fn release_open_order(&mut self, account: AccountId) {
425 let Some(count) = self.order_counts.get_mut(&account) else {
426 return;
427 };
428 *count = count.saturating_sub(1);
429 if *count == 0 {
430 self.order_counts.remove(&account);
431 self.try_evict_bucket(account);
432 }
433 }
434
435 /// Drop the rate-limiter bucket for `account` if (and only if) it
436 /// has refilled back to full capacity at the current event time.
437 ///
438 /// Eviction is observationally equivalent to keeping a full bucket:
439 /// a fresh bucket created on the next submission is initialised at
440 /// burst (`TokenBucket::new`), and `refill` caps at burst, so an
441 /// existing bucket at capacity would itself refill to burst on
442 /// next access regardless of elapsed time. Buckets below capacity
443 /// are *not* evicted — that would let an account escape a partial
444 /// throttle by cancelling all its orders and trigger a free fresh
445 /// burst on its next submission.
446 ///
447 /// Memory-bound rationale: without this hook, `order_buckets`
448 /// would grow with every account that ever submitted an order
449 /// (`order_counts` releases its entry, `order_buckets` does not),
450 /// turning the limiter into a slow memory leak proportional to
451 /// total ever-active accounts. With it, the steady-state size
452 /// tracks accounts currently inside a throttle window, which is
453 /// what an operator would expect to pay for.
454 ///
455 /// Determinism: uses `current_event_ts_ns` (stamped by `apply` and
456 /// also by `drain_due_scheduled_tasks`), so the eviction decision
457 /// reproduces bit-for-bit on every replica replaying the same
458 /// journal — the snapshot's bucket-map shape is part of the
459 /// replicated state.
460 #[inline]
461 fn try_evict_bucket(&mut self, account: AccountId) {
462 let burst = self.max_orders_burst;
463 let rate = self.max_orders_per_second;
464 // Limiter disabled (either knob is zero). Two reasons to skip:
465 // (a) The hot path never inserts into the bucket map when
466 // disabled, so the common case is "map is empty, lookup
467 // wasted."
468 // (b) Deactivation transitions in `set_max_orders_per_second`
469 // *preserve* buckets so the operator can re-enable with
470 // the same values without losing per-account throttle
471 // state. Cleaning up on close-zero would silently erode
472 // that preserved state.
473 if rate == 0 || burst == 0 {
474 return;
475 }
476 let now_ns = self.current_event_ts_ns;
477 let Some(bucket) = self.order_buckets.get_mut(&account) else {
478 return;
479 };
480 bucket.refill(now_ns, rate, burst);
481 if bucket.tokens >= burst as u64 {
482 self.order_buckets.remove(&account);
483 }
484 }
485
486 /// Current count of open orders (resting limits + pending stops +
487 /// in-flight) for `account`, across all instruments. Returns `0` if
488 /// the account has never traded. Used by proptests and admin queries
489 /// to inspect the same counter the SEC-03 cap reads.
490 pub fn open_order_count(&self, account: AccountId) -> u32 {
491 self.order_counts.get(&account).copied().unwrap_or(0)
492 }
493
494 /// Pending count of scheduled tasks (including tombstones). Test-only
495 /// helper for asserting heap state.
496 #[cfg(test)]
497 pub(crate) fn scheduled_task_count(&self) -> usize {
498 self.scheduled_tasks.len()
499 }
500
501 /// Live rate-limiter bucket count. Used by the server's startup
502 /// path to detect a primary↔replica config mismatch after snapshot
503 /// restore (non-empty buckets paired with a disabled limiter
504 /// indicates the operator forgot to wire the rate-limit config) and
505 /// by tests to assert bucket-eviction behaviour.
506 pub fn order_bucket_count(&self) -> usize {
507 self.order_buckets.len()
508 }
509
510 /// Drain every scheduled task whose `fire_ns <= now_ns`. Called at the
511 /// head of every event the matching stage processes, so time-driven work
512 /// runs in lockstep with the journal. Tombstones — tasks that point to
513 /// orders that have already been cancelled or filled — are silently
514 /// dropped via the `find_gtd_expiry` lookup.
515 pub fn drain_due_scheduled_tasks(&mut self, now_ns: u64, reports: &mut Vec<ExecutionReport>) {
516 // `tick` reaches us without going through `Application::apply`,
517 // so stamp the event clock here too — the bucket-eviction probe
518 // in `release_open_order` reads `current_event_ts_ns` and would
519 // otherwise see a stale stamp from the previous `apply` call.
520 self.current_event_ts_ns = now_ns;
521 while let Some(task) = self.scheduled_tasks.pop_due(now_ns) {
522 match task.kind {
523 ScheduledTaskKind::ExpireOrder {
524 symbol,
525 account,
526 order_id,
527 } => {
528 let Some(inst) = inst_mut(&mut self.instruments, symbol) else {
529 // Instrument removed between schedule and fire — tombstone.
530 continue;
531 };
532 // Skip tombstones: if the order is no longer GTD on the
533 // book, it's already been cancelled or filled. The task
534 // is just stale; drop it without side effect.
535 if inst.book.find_gtd_expiry(account, order_id).is_none() {
536 continue;
537 }
538 if let Some((_side, slot)) = inst.book.cancel(account, order_id, reports) {
539 self.accounts.release(slot);
540 self.live_order_ids.remove(&(account, order_id));
541 self.release_open_order(account);
542 }
543 }
544 }
545 }
546 }
547
548 /// Schedule an `ExpireOrder` task for a GTD order that just rested on
549 /// the book (or registered as a pending stop).
550 fn schedule_gtd_expiry(
551 &mut self,
552 symbol: Symbol,
553 account: AccountId,
554 order_id: OrderId,
555 expiry_ns: u64,
556 ) {
557 self.scheduled_tasks.push(ScheduledTask {
558 fire_ns: expiry_ns,
559 kind: ScheduledTaskKind::ExpireOrder {
560 symbol,
561 account,
562 order_id,
563 },
564 });
565 }
566
567 /// Check per-key request sequence for idempotency dedup.
568 /// Returns true if this is a new request (should be processed).
569 /// Returns false if duplicate (caller should reject with DuplicateRequest).
570 /// Exempt when key_hash == 0 (internal/seed events with no authenticated key).
571 #[inline]
572 pub fn check_request_seq(&mut self, key_hash: u64, request_seq: u64) -> bool {
573 if key_hash == 0 {
574 return true; // exempt: internal/seed events
575 }
576 let hwm = self.key_hwm.entry(key_hash).or_insert(0);
577 if request_seq <= *hwm {
578 return false; // duplicate
579 }
580 *hwm = request_seq;
581 true
582 }
583
584 /// Current request_seq HWM for `key_hash`, or `0` if no event has
585 /// ever been accepted from that key. Read-only; safe to call from
586 /// the matching stage at any point. Used by the `QueryRequestSeq`
587 /// query handler so reconnecting clients can resume their outbound
588 /// seq past whatever the engine has already seen.
589 pub fn request_seq_hwm(&self, key_hash: u64) -> u64 {
590 self.key_hwm.get(&key_hash).copied().unwrap_or(0)
591 }
592
593 /// Number of active instruments (for diagnostics).
594 pub fn instrument_count(&self) -> usize {
595 self.instruments.iter().filter(|s| s.is_some()).count()
596 }
597
598 /// Set fat finger risk limits for an instrument. No-op if the
599 /// instrument doesn't exist (matches previous behavior).
600 pub fn set_risk_limits(&mut self, symbol: Symbol, limits: RiskLimits) {
601 if let Some(inst) = inst_mut(&mut self.instruments, symbol) {
602 inst.risk_limits = limits;
603 }
604 }
605
606 /// Set circuit breaker configuration for an instrument. No-op if the
607 /// instrument doesn't exist (matches previous behavior).
608 pub fn set_circuit_breaker(&mut self, symbol: Symbol, config: CircuitBreakerConfig) {
609 if let Some(inst) = inst_mut(&mut self.instruments, symbol) {
610 inst.circuit_breaker = config;
611 }
612 }
613
614 /// Set the maker/taker fee schedule for an instrument.
615 ///
616 /// When the effective max fee rate changes, all affected buy-side
617 /// orders have their reservations adjusted:
618 /// - Resting limit buys and pending stop-limit buys: reservation
619 /// topped up from available balance, or cancelled if insufficient.
620 /// - Pending stop-market buys: `quote_budget` recalculated so the
621 /// fill leaves room for the new fee.
622 ///
623 /// No-op if the instrument doesn't exist, or if either rate is
624 /// outside the documented ±10_000 bps range (±100%) — see below.
625 pub fn set_fee_schedule(
626 &mut self,
627 symbol: Symbol,
628 schedule: FeeSchedule,
629 reports: &mut Vec<ExecutionReport>,
630 ) {
631 // Range-validate here, at the single choke point every path
632 // (wire request, journal replay) goes through — the admin TUI
633 // validates client-side but nothing else on the decode path
634 // does. Out-of-range bps are dangerous, not just nonsensical:
635 // with extreme cost the fee math's final `as i64` narrowing can
636 // wrap a huge fee into an arbitrary-sign value that settles as
637 // a phantom rebate. Deterministic ignore (not clamp): journal
638 // replay of a bad event must reproduce exactly this no-op.
639 // `contains` rather than `.abs()` — `i16::MIN.abs()` overflows.
640 // warn!: an operator's schedule change silently not applying
641 // needs attention, and a valid client can never trigger this.
642 const VALID_BPS: std::ops::RangeInclusive<i16> = -10_000..=10_000;
643 if !VALID_BPS.contains(&schedule.maker_fee_bps)
644 || !VALID_BPS.contains(&schedule.taker_fee_bps)
645 {
646 tracing::warn!(
647 symbol = symbol.0,
648 maker_fee_bps = schedule.maker_fee_bps,
649 taker_fee_bps = schedule.taker_fee_bps,
650 "fee schedule outside ±10000 bps ignored — client-side validation bypassed"
651 );
652 return;
653 }
654 let Some(inst) = inst_mut(&mut self.instruments, symbol) else {
655 return;
656 };
657
658 // Under the received-asset fee model, reservations are pure
659 // notional and don't depend on the schedule — a fee change
660 // simply takes effect on subsequent fills, with no need to
661 // re-reserve resting orders or recompute stop-market budgets.
662 // `reports` is unused here but kept in the signature so callers
663 // (and journal replay) don't need to branch on the return shape.
664 let _ = reports;
665 inst.fee_schedule = schedule;
666 }
667
668 /// Touch all pre-allocated HashMap pages so page faults happen at startup,
669 /// not on the hot path. Call once after adding instruments, before accepting
670 /// orders. Skips maps that already contain data — their pages are already
671 /// faulted from the insertions that populated them.
672 pub fn prefault(&mut self) {
673 // Fault live_order_ids and order_counts pages. with_capacity()
674 // allocated the backing table but didn't write to it — insert
675 // dummy entries and clear to touch every page before the hot path.
676 if self.live_order_ids.is_empty() {
677 let cap = self.live_order_ids.capacity();
678 for i in 0..cap as u32 {
679 self.live_order_ids
680 .insert((AccountId(i), OrderId(i as u64)));
681 }
682 self.live_order_ids.clear();
683 }
684 if self.order_counts.is_empty() {
685 let cap = self.order_counts.capacity();
686 for i in 0..cap as u32 {
687 self.order_counts.insert(AccountId(i), 0);
688 }
689 self.order_counts.clear();
690 }
691
692 self.accounts.prefault();
693
694 for slot in &mut self.instruments {
695 if let Some(inst) = slot.as_deref_mut() {
696 inst.book.prefault();
697 }
698 }
699 }
700
701 /// Register a new instrument with its currency pair specification.
702 /// Grows the instrument Vec if needed (admin operation, not hot path).
703 pub fn add_instrument(&mut self, spec: InstrumentSpec) {
704 let idx = spec.symbol.0 as usize;
705 // Grow Vec to accommodate the new symbol index.
706 if idx >= self.instruments.len() {
707 self.instruments.resize_with(idx + 1, || None);
708 }
709 // Only insert if slot is empty (don't overwrite existing instrument).
710 if self.instruments[idx].is_none() {
711 // Take a pre-allocated book from the pool if one is waiting at
712 // this symbol's index — see `instrument_pool` field doc. Falls
713 // back to fresh allocation otherwise; the matching thread is
714 // mlock-MCL_FUTURE so the fresh path can stall for a few ms
715 // while pages are locked. The pool path keeps that cost on
716 // the main thread at startup.
717 let book = self
718 .instrument_pool
719 .get_mut(idx)
720 .and_then(|slot| slot.take())
721 .unwrap_or_else(|| {
722 if self.presized {
723 OrderBook::with_capacity(spec.symbol)
724 } else {
725 OrderBook::new(spec.symbol)
726 }
727 });
728 self.instruments[idx] = Some(Box::new(InstrumentState {
729 spec,
730 book,
731 risk_limits: RiskLimits::default(),
732 circuit_breaker: CircuitBreakerConfig::default(),
733 fee_schedule: FeeSchedule::default(),
734 disabled: false,
735 }));
736 }
737 }
738
739 /// Deposit funds into an account.
740 pub fn deposit(&mut self, account: AccountId, currency: CurrencyId, amount: u64) {
741 self.accounts.deposit(account, currency, amount);
742 }
743
744 /// Provision an account with `amount` deposited in every currency of
745 /// every registered instrument. Replaces O(instruments) individual
746 /// Deposit calls with a single operation for bulk seeding.
747 pub fn provision_account(&mut self, account: AccountId, amount: u64) {
748 for state in self.instruments.iter().flatten() {
749 self.accounts.deposit(account, state.spec.base, amount);
750 self.accounts.deposit(account, state.spec.quote, amount);
751 }
752 }
753
754 /// Get the account manager (for balance queries).
755 pub fn accounts(&self) -> &AccountManager {
756 &self.accounts
757 }
758
759 /// Cancel all resting orders and pending stops for an account across
760 /// all instruments (kill switch). Releases all associated reservations.
761 pub fn cancel_all(&mut self, account: AccountId, reports: &mut Vec<ExecutionReport>) {
762 for idx in 0..self.instruments.len() {
763 let Some(inst) = self.instruments[idx].as_deref_mut() else {
764 continue;
765 };
766
767 let report_start = reports.len();
768
769 inst.book.cancel_all_for_account(account, reports);
770
771 // cancel_all_for_account collects returned slots in consumed_slots.
772 let consumed: Vec<(AccountId, OrderId, Side, ReservationSlot)> =
773 inst.book.drain_consumed_slots().collect();
774 for &(consumed_account, order_id, _, slot) in &consumed {
775 self.accounts.release(slot);
776 self.live_order_ids.remove(&(consumed_account, order_id));
777 }
778
779 let n_cancelled = reports.len() - report_start;
780 if let Some(count) = self.order_counts.get_mut(&account) {
781 *count = count.saturating_sub(n_cancelled as u32);
782 if *count == 0 {
783 self.order_counts.remove(&account);
784 self.try_evict_bucket(account);
785 }
786 }
787 }
788 }
789
790 /// Cancel all resting orders and pending stops with `TimeInForce::Day`
791 /// across all instruments. Called at end-of-session.
792 pub fn end_of_day(&mut self, reports: &mut Vec<ExecutionReport>) {
793 for idx in 0..self.instruments.len() {
794 let Some(inst) = self.instruments[idx].as_deref_mut() else {
795 continue;
796 };
797
798 inst.book.cancel_day_orders(reports);
799
800 // Collect before iterating so the `inst` mutable borrow is
801 // released before we re-borrow `self` via `release_open_order`.
802 let consumed: Vec<(AccountId, OrderId, Side, ReservationSlot)> =
803 inst.book.drain_consumed_slots().collect();
804 for (account, order_id, _, slot) in consumed {
805 self.accounts.release(slot);
806 self.live_order_ids.remove(&(account, order_id));
807 self.release_open_order(account);
808 }
809 }
810 }
811
812 /// Disable an instrument: reject future orders and cancel all resting
813 /// orders and pending stops. Idempotent — disabling an already-disabled
814 /// instrument is a no-op (no reports emitted).
815 pub fn disable_instrument(&mut self, symbol: Symbol, reports: &mut Vec<ExecutionReport>) {
816 let Some(inst) = inst_mut(&mut self.instruments, symbol) else {
817 return;
818 };
819 if inst.disabled {
820 return;
821 }
822 inst.disabled = true;
823
824 inst.book.cancel_all_orders(reports);
825
826 // Release reservations — same pattern as end_of_day. Collect
827 // before iterating so the `inst` borrow is released before
828 // re-borrowing `self` via `release_open_order`.
829 let consumed: Vec<(AccountId, OrderId, Side, ReservationSlot)> =
830 inst.book.drain_consumed_slots().collect();
831 for (account, order_id, _, slot) in consumed {
832 self.accounts.release(slot);
833 self.live_order_ids.remove(&(account, order_id));
834 self.release_open_order(account);
835 }
836
837 reports.push(ExecutionReport::InstrumentStatusChanged {
838 symbol,
839 status: InstrumentStatus::Disabled,
840 });
841 }
842
843 /// Re-enable a previously disabled instrument, allowing new orders.
844 pub fn enable_instrument(&mut self, symbol: Symbol, reports: &mut Vec<ExecutionReport>) {
845 let Some(inst) = inst_mut(&mut self.instruments, symbol) else {
846 return;
847 };
848 if !inst.disabled {
849 return;
850 }
851 inst.disabled = false;
852
853 reports.push(ExecutionReport::InstrumentStatusChanged {
854 symbol,
855 status: InstrumentStatus::Enabled,
856 });
857 }
858
859 /// Permanently remove a disabled instrument, reclaiming memory.
860 /// Only succeeds if the instrument is disabled and has no resting orders
861 /// (which disable guarantees). Active instruments must be disabled first.
862 pub fn remove_instrument(&mut self, symbol: Symbol, reports: &mut Vec<ExecutionReport>) {
863 let idx = symbol.0 as usize;
864 if idx >= self.instruments.len() {
865 return;
866 }
867 let dominated = self.instruments[idx]
868 .as_ref()
869 .is_some_and(|inst| inst.disabled && inst.book.is_empty());
870 if !dominated {
871 return;
872 }
873 self.instruments[idx] = None;
874
875 reports.push(ExecutionReport::InstrumentStatusChanged {
876 symbol,
877 status: InstrumentStatus::Removed,
878 });
879 }
880
881 /// Cancel a resting order on the given instrument.
882 #[inline]
883 pub fn cancel(
884 &mut self,
885 symbol: Symbol,
886 account: AccountId,
887 order_id: OrderId,
888 reports: &mut Vec<ExecutionReport>,
889 ) {
890 let Some(inst) = inst_mut(&mut self.instruments, symbol) else {
891 return;
892 };
893
894 if let Some((_side, slot)) = inst.book.cancel(account, order_id, reports) {
895 self.accounts.release(slot);
896 self.live_order_ids.remove(&(account, order_id));
897 self.release_open_order(account);
898 }
899 }
900
901 /// Best (highest) bid on `symbol`'s book, or `None` if the bid side is
902 /// empty or the symbol is not registered. Read-only book introspection
903 /// (market-data / audit queries); not on the matching hot path.
904 pub fn best_bid(&self, symbol: Symbol) -> Option<Price> {
905 inst_ref(&self.instruments, symbol).and_then(|inst| inst.book.best_bid())
906 }
907
908 /// Best (lowest) ask on `symbol`'s book, or `None` if the ask side is
909 /// empty or the symbol is not registered.
910 pub fn best_ask(&self, symbol: Symbol) -> Option<Price> {
911 inst_ref(&self.instruments, symbol).and_then(|inst| inst.book.best_ask())
912 }
913
914 /// Total resting quantity at one exact price level on `symbol`'s book,
915 /// or 0 if the level does not exist or the symbol is not registered.
916 pub fn depth_at(&self, symbol: Symbol, price: Price, side: Side) -> u64 {
917 inst_ref(&self.instruments, symbol).map_or(0, |inst| inst.book.depth_at(price, side))
918 }
919
920 /// Withdraw funds from an account. Rejects if the account has resting
921 /// orders (must `CancelAll` first) or insufficient available balance.
922 /// Removes the balance entry if it reaches zero (memory cleanup).
923 pub fn withdraw(
924 &mut self,
925 account: AccountId,
926 currency: CurrencyId,
927 amount: u64,
928 ) -> Result<(), RejectReason> {
929 // Reject withdrawal if the account has resting orders — funds might
930 // be reserved. Caller must CancelAll first.
931 if self.order_counts.get(&account).copied().unwrap_or(0) > 0 {
932 return Err(RejectReason::HasRestingOrders);
933 }
934 self.accounts.withdraw(account, currency, amount)
935 }
936}
937
938impl Default for Exchange {
939 fn default() -> Self {
940 Self::new()
941 }
942}
943
944#[cfg(test)]
945mod cancel_replace_tests;
946#[cfg(test)]
947mod circuit_breaker_tests;
948#[cfg(test)]
949mod gtd_tests;
950#[cfg(test)]
951mod instrument_lifecycle_tests;
952#[cfg(test)]
953mod open_order_cap_tests;
954#[cfg(test)]
955mod stp_tests;
956#[cfg(test)]
957mod test_helpers;
958#[cfg(test)]
959mod tests;
960#[cfg(test)]
961mod token_bucket_tests;