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;
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, RejectReason, ReservationSlot, RiskLimits,
28 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.
624 pub fn set_fee_schedule(
625 &mut self,
626 symbol: Symbol,
627 schedule: FeeSchedule,
628 reports: &mut Vec<ExecutionReport>,
629 ) {
630 let Some(inst) = inst_mut(&mut self.instruments, symbol) else {
631 return;
632 };
633
634 // Under the received-asset fee model, reservations are pure
635 // notional and don't depend on the schedule — a fee change
636 // simply takes effect on subsequent fills, with no need to
637 // re-reserve resting orders or recompute stop-market budgets.
638 // `reports` is unused here but kept in the signature so callers
639 // (and journal replay) don't need to branch on the return shape.
640 let _ = reports;
641 inst.fee_schedule = schedule;
642 }
643
644 /// Touch all pre-allocated HashMap pages so page faults happen at startup,
645 /// not on the hot path. Call once after adding instruments, before accepting
646 /// orders. Skips maps that already contain data — their pages are already
647 /// faulted from the insertions that populated them.
648 pub fn prefault(&mut self) {
649 // Fault live_order_ids and order_counts pages. with_capacity()
650 // allocated the backing table but didn't write to it — insert
651 // dummy entries and clear to touch every page before the hot path.
652 if self.live_order_ids.is_empty() {
653 let cap = self.live_order_ids.capacity();
654 for i in 0..cap as u32 {
655 self.live_order_ids
656 .insert((AccountId(i), OrderId(i as u64)));
657 }
658 self.live_order_ids.clear();
659 }
660 if self.order_counts.is_empty() {
661 let cap = self.order_counts.capacity();
662 for i in 0..cap as u32 {
663 self.order_counts.insert(AccountId(i), 0);
664 }
665 self.order_counts.clear();
666 }
667
668 self.accounts.prefault();
669
670 for slot in &mut self.instruments {
671 if let Some(inst) = slot.as_deref_mut() {
672 inst.book.prefault();
673 }
674 }
675 }
676
677 /// Register a new instrument with its currency pair specification.
678 /// Grows the instrument Vec if needed (admin operation, not hot path).
679 pub fn add_instrument(&mut self, spec: InstrumentSpec) {
680 let idx = spec.symbol.0 as usize;
681 // Grow Vec to accommodate the new symbol index.
682 if idx >= self.instruments.len() {
683 self.instruments.resize_with(idx + 1, || None);
684 }
685 // Only insert if slot is empty (don't overwrite existing instrument).
686 if self.instruments[idx].is_none() {
687 // Take a pre-allocated book from the pool if one is waiting at
688 // this symbol's index — see `instrument_pool` field doc. Falls
689 // back to fresh allocation otherwise; the matching thread is
690 // mlock-MCL_FUTURE so the fresh path can stall for a few ms
691 // while pages are locked. The pool path keeps that cost on
692 // the main thread at startup.
693 let book = self
694 .instrument_pool
695 .get_mut(idx)
696 .and_then(|slot| slot.take())
697 .unwrap_or_else(|| {
698 if self.presized {
699 OrderBook::with_capacity(spec.symbol)
700 } else {
701 OrderBook::new(spec.symbol)
702 }
703 });
704 self.instruments[idx] = Some(Box::new(InstrumentState {
705 spec,
706 book,
707 risk_limits: RiskLimits::default(),
708 circuit_breaker: CircuitBreakerConfig::default(),
709 fee_schedule: FeeSchedule::default(),
710 disabled: false,
711 }));
712 }
713 }
714
715 /// Deposit funds into an account.
716 pub fn deposit(&mut self, account: AccountId, currency: CurrencyId, amount: u64) {
717 self.accounts.deposit(account, currency, amount);
718 }
719
720 /// Provision an account with `amount` deposited in every currency of
721 /// every registered instrument. Replaces O(instruments) individual
722 /// Deposit calls with a single operation for bulk seeding.
723 pub fn provision_account(&mut self, account: AccountId, amount: u64) {
724 for state in self.instruments.iter().flatten() {
725 self.accounts.deposit(account, state.spec.base, amount);
726 self.accounts.deposit(account, state.spec.quote, amount);
727 }
728 }
729
730 /// Get the account manager (for balance queries).
731 pub fn accounts(&self) -> &AccountManager {
732 &self.accounts
733 }
734
735 /// Cancel all resting orders and pending stops for an account across
736 /// all instruments (kill switch). Releases all associated reservations.
737 pub fn cancel_all(&mut self, account: AccountId, reports: &mut Vec<ExecutionReport>) {
738 for idx in 0..self.instruments.len() {
739 let Some(inst) = self.instruments[idx].as_deref_mut() else {
740 continue;
741 };
742
743 let report_start = reports.len();
744
745 inst.book.cancel_all_for_account(account, reports);
746
747 // cancel_all_for_account collects returned slots in consumed_slots.
748 let consumed: Vec<(AccountId, OrderId, Side, ReservationSlot)> =
749 inst.book.drain_consumed_slots().collect();
750 for &(consumed_account, order_id, _, slot) in &consumed {
751 self.accounts.release(slot);
752 self.live_order_ids.remove(&(consumed_account, order_id));
753 }
754
755 let n_cancelled = reports.len() - report_start;
756 if let Some(count) = self.order_counts.get_mut(&account) {
757 *count = count.saturating_sub(n_cancelled as u32);
758 if *count == 0 {
759 self.order_counts.remove(&account);
760 self.try_evict_bucket(account);
761 }
762 }
763 }
764 }
765
766 /// Cancel all resting orders and pending stops with `TimeInForce::Day`
767 /// across all instruments. Called at end-of-session.
768 pub fn end_of_day(&mut self, reports: &mut Vec<ExecutionReport>) {
769 for idx in 0..self.instruments.len() {
770 let Some(inst) = self.instruments[idx].as_deref_mut() else {
771 continue;
772 };
773
774 inst.book.cancel_day_orders(reports);
775
776 // Collect before iterating so the `inst` mutable borrow is
777 // released before we re-borrow `self` via `release_open_order`.
778 let consumed: Vec<(AccountId, OrderId, Side, ReservationSlot)> =
779 inst.book.drain_consumed_slots().collect();
780 for (account, order_id, _, slot) in consumed {
781 self.accounts.release(slot);
782 self.live_order_ids.remove(&(account, order_id));
783 self.release_open_order(account);
784 }
785 }
786 }
787
788 /// Disable an instrument: reject future orders and cancel all resting
789 /// orders and pending stops. Idempotent — disabling an already-disabled
790 /// instrument is a no-op (no reports emitted).
791 pub fn disable_instrument(&mut self, symbol: Symbol, reports: &mut Vec<ExecutionReport>) {
792 let Some(inst) = inst_mut(&mut self.instruments, symbol) else {
793 return;
794 };
795 if inst.disabled {
796 return;
797 }
798 inst.disabled = true;
799
800 inst.book.cancel_all_orders(reports);
801
802 // Release reservations — same pattern as end_of_day. Collect
803 // before iterating so the `inst` borrow is released before
804 // re-borrowing `self` via `release_open_order`.
805 let consumed: Vec<(AccountId, OrderId, Side, ReservationSlot)> =
806 inst.book.drain_consumed_slots().collect();
807 for (account, order_id, _, slot) in consumed {
808 self.accounts.release(slot);
809 self.live_order_ids.remove(&(account, order_id));
810 self.release_open_order(account);
811 }
812
813 reports.push(ExecutionReport::InstrumentStatusChanged {
814 symbol,
815 status: InstrumentStatus::Disabled,
816 });
817 }
818
819 /// Re-enable a previously disabled instrument, allowing new orders.
820 pub fn enable_instrument(&mut self, symbol: Symbol, reports: &mut Vec<ExecutionReport>) {
821 let Some(inst) = inst_mut(&mut self.instruments, symbol) else {
822 return;
823 };
824 if !inst.disabled {
825 return;
826 }
827 inst.disabled = false;
828
829 reports.push(ExecutionReport::InstrumentStatusChanged {
830 symbol,
831 status: InstrumentStatus::Enabled,
832 });
833 }
834
835 /// Permanently remove a disabled instrument, reclaiming memory.
836 /// Only succeeds if the instrument is disabled and has no resting orders
837 /// (which disable guarantees). Active instruments must be disabled first.
838 pub fn remove_instrument(&mut self, symbol: Symbol, reports: &mut Vec<ExecutionReport>) {
839 let idx = symbol.0 as usize;
840 if idx >= self.instruments.len() {
841 return;
842 }
843 let dominated = self.instruments[idx]
844 .as_ref()
845 .is_some_and(|inst| inst.disabled && inst.book.is_empty());
846 if !dominated {
847 return;
848 }
849 self.instruments[idx] = None;
850
851 reports.push(ExecutionReport::InstrumentStatusChanged {
852 symbol,
853 status: InstrumentStatus::Removed,
854 });
855 }
856
857 /// Cancel a resting order on the given instrument.
858 #[inline]
859 pub fn cancel(
860 &mut self,
861 symbol: Symbol,
862 account: AccountId,
863 order_id: OrderId,
864 reports: &mut Vec<ExecutionReport>,
865 ) {
866 let Some(inst) = inst_mut(&mut self.instruments, symbol) else {
867 return;
868 };
869
870 if let Some((_side, slot)) = inst.book.cancel(account, order_id, reports) {
871 self.accounts.release(slot);
872 self.live_order_ids.remove(&(account, order_id));
873 self.release_open_order(account);
874 }
875 }
876
877 /// Withdraw funds from an account. Rejects if the account has resting
878 /// orders (must `CancelAll` first) or insufficient available balance.
879 /// Removes the balance entry if it reaches zero (memory cleanup).
880 pub fn withdraw(
881 &mut self,
882 account: AccountId,
883 currency: CurrencyId,
884 amount: u64,
885 ) -> Result<(), RejectReason> {
886 // Reject withdrawal if the account has resting orders — funds might
887 // be reserved. Caller must CancelAll first.
888 if self.order_counts.get(&account).copied().unwrap_or(0) > 0 {
889 return Err(RejectReason::HasRestingOrders);
890 }
891 self.accounts.withdraw(account, currency, amount)
892 }
893}
894
895impl Default for Exchange {
896 fn default() -> Self {
897 Self::new()
898 }
899}
900
901#[cfg(test)]
902mod cancel_replace_tests;
903#[cfg(test)]
904mod circuit_breaker_tests;
905#[cfg(test)]
906mod gtd_tests;
907#[cfg(test)]
908mod instrument_lifecycle_tests;
909#[cfg(test)]
910mod open_order_cap_tests;
911#[cfg(test)]
912mod stp_tests;
913#[cfg(test)]
914mod test_helpers;
915#[cfg(test)]
916mod tests;
917#[cfg(test)]
918mod token_bucket_tests;