Skip to main content

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;