Skip to main content

melin_exchange_core/exchange/
execute.rs

1//! The order-submission hot path. Pulled into its own submodule because
2//! it dominates `exchange.rs` by line count and is the most heavily
3//! exercised path in the engine — keeping it isolated makes targeted
4//! review and perf work easier.
5
6use super::Exchange;
7use super::instrument::{inst_mut, inst_ref};
8use super::token_bucket::TokenBucket;
9use crate::types::{ExecutionReport, Order, OrderType, RejectReason, Side, Symbol, TimeInForce};
10
11/// Basis-point denominator (1 bp = 0.01%). The split identity in
12/// `fee_from_bps` is only valid when every division and modulus in the
13/// function uses this same value — a named const ties them together.
14/// u64: the base type of the fast-path arithmetic; widened at use sites.
15const BPS_DENOM: u64 = 10_000;
16
17/// `value * bps / 10_000` with the exact truncating semantics of the
18/// naive i128 expression, but without a 128-bit division on the hot
19/// path: LLVM does not strength-reduce `i128 / 10_000` and emits a
20/// `__divti3` library call (~2.5% of the matching thread in profiles).
21///
22/// Splitting `value = q·10_000 + r` gives
23/// `value·bps/10_000 == q·bps + (r·bps)/10_000` exactly — the first
24/// term is an integer and `trunc(k + x) == k + trunc(x)` for integer
25/// `k` — and the two remaining divisions are 64-bit by-constant, which
26/// compile to multiply-shift. The `q·bps` multiply still widens to
27/// i128 (`q` can reach `u64::MAX/10_000` and `|bps|` up to `i16::MAX`);
28/// 128-bit *multiplication* is cheap, only division is a library call.
29/// `r·bps` fits i64 comfortably (`r < 10_000`).
30#[inline]
31fn fee_from_bps(value: u128, bps: i16) -> i64 {
32    match u64::try_from(value) {
33        Ok(v) => {
34            let q = (v / BPS_DENOM) as i128;
35            let r = (v % BPS_DENOM) as i64;
36            let head = q * bps as i128;
37            let tail = (r * bps as i64) / BPS_DENOM as i64;
38            (head + tail as i128) as i64
39        }
40        Err(_) => {
41            // Unreachable through order flow: buy reservations bound
42            // notional to u64 (`AccountManager::required_reserve`'s
43            // `u64::try_from(cost)`), market/stop buys are clamped to
44            // the quote budget in `OrderBook::execute_market`, and
45            // `fill` clamps its own cost the same way. Loud in checked
46            // builds; in release the fallback matches the old
47            // expression bit-for-bit (both wrap the >i128::MAX product
48            // identically under release semantics).
49            debug_assert!(
50                false,
51                "fee_from_bps: cost {value} exceeds u64::MAX — a notional bound upstream is broken"
52            );
53            fee_from_bps_slow(value, bps)
54        }
55    }
56}
57
58/// Naive i128-division fallback for `fee_from_bps`, outlined so the
59/// `__divti3` call sequence stays out of the fill path's instruction
60/// stream and the fast path compiles to a branch-free fall-through
61/// (`#[cold]`/`#[inline(never)]`, same convention as the account
62/// module's `log_underflow`/`log_overflow`).
63#[cold]
64#[inline(never)]
65fn fee_from_bps_slow(value: u128, bps: i16) -> i64 {
66    ((value as i128) * (bps as i128) / BPS_DENOM as i128) as i64
67}
68
69impl Exchange {
70    /// Submit an order to the matching engine for the given instrument.
71    ///
72    /// Validates the instrument exists, reserves funds, then executes.
73    /// On fill, balances are updated. On reject/cancel, reserves are released.
74    ///
75    /// Under `feature = "skip-order-exec"` the body is short-circuited
76    /// to a single `Rejected{NoLiquidity}` push, used by the server's
77    /// transport-only benchmark build to isolate transport throughput
78    /// from matching cost. Same wire shape — bench clients still see
79    /// one response per `SubmitOrder` — but no order book / account
80    /// state touched.
81    #[inline]
82    pub fn execute(&mut self, symbol: Symbol, order: Order, reports: &mut Vec<ExecutionReport>) {
83        #[cfg(feature = "skip-order-exec")]
84        {
85            reports.push(ExecutionReport::Rejected {
86                order_id: order.id,
87                symbol,
88                account: order.account,
89                reason: RejectReason::NoLiquidity,
90            });
91            return;
92        }
93        #[cfg_attr(feature = "skip-order-exec", allow(unreachable_code))]
94        let Some(inst) = inst_ref(&self.instruments, symbol) else {
95            reports.push(ExecutionReport::Rejected {
96                order_id: order.id,
97                symbol,
98                account: order.account,
99                reason: RejectReason::UnknownSymbol,
100            });
101            return;
102        };
103        // Disabled instruments reject before HWM advance — the order is
104        // never "processed", same as UnknownSymbol.
105        if inst.disabled {
106            reports.push(ExecutionReport::Rejected {
107                order_id: order.id,
108                symbol,
109                account: order.account,
110                reason: RejectReason::InstrumentDisabled,
111            });
112            return;
113        }
114        // Copy spec before taking mutable borrow on instruments below.
115        // InstrumentSpec is Copy (3 × u32 = 12 bytes).
116        let spec = inst.spec;
117
118        // Dedup: reject if `(account, order_id)` already names a live
119        // order. Cancel/replace look up by the same key, so two live
120        // orders sharing it would make those operations ambiguous.
121        // Replay-safety is provided one layer up by `check_request_seq`
122        // (transport-level idempotency on `(key_hash, request_seq)`),
123        // not here — duplicate journaled SubmitOrder events never reach
124        // this point. Reuse of an `OrderId` after the original closes
125        // is permitted by design.
126        if self.live_order_ids.contains(&(order.account, order.id)) {
127            reports.push(ExecutionReport::Rejected {
128                order_id: order.id,
129                symbol,
130                account: order.account,
131                reason: RejectReason::DuplicateOrderId,
132            });
133            return;
134        }
135
136        // Existence already established by the `let Some(inst) = inst_ref(...)
137        // else { ... return; }` guard at the top of `execute`. The matcher
138        // is single-threaded and no instrument deregistration runs between
139        // events, so the slot is still populated here.
140        let inst = inst_ref(&self.instruments, symbol).expect("instrument verified to exist above");
141
142        // Circuit breaker checks: trading halt rejects all orders; price
143        // bands reject limit/stop-limit orders outside [lower, upper].
144        // No HashMap lookup — circuit breaker is in the same struct.
145        let cb = &inst.circuit_breaker;
146        if cb.halted {
147            reports.push(ExecutionReport::Rejected {
148                order_id: order.id,
149                symbol,
150                account: order.account,
151                reason: RejectReason::TradingHalted,
152            });
153            return;
154        }
155        // Price band check applies only to orders with a known price.
156        // Market and Stop orders have no submission-time price and
157        // bypass bands by design (SEC-12). A large market order can
158        // fill far outside the intended bands. Mitigation: use the
159        // trading halt flag, or implement automatic volatility halts
160        // (Phase 3 of the circuit breaker plan).
161        let limit_price = match order.order_type {
162            OrderType::Limit { price, .. } => Some(price),
163            OrderType::StopLimit { limit_price, .. } => Some(limit_price),
164            OrderType::Market | OrderType::Stop { .. } => None,
165        };
166        if let Some(price) = limit_price {
167            if let Some(lower) = cb.price_band_lower
168                && price < lower
169            {
170                reports.push(ExecutionReport::Rejected {
171                    order_id: order.id,
172                    symbol,
173                    account: order.account,
174                    reason: RejectReason::OutsidePriceBand,
175                });
176                return;
177            }
178            if let Some(upper) = cb.price_band_upper
179                && price > upper
180            {
181                reports.push(ExecutionReport::Rejected {
182                    order_id: order.id,
183                    symbol,
184                    account: order.account,
185                    reason: RejectReason::OutsidePriceBand,
186                });
187                return;
188            }
189        }
190
191        // Fat finger checks: reject orders exceeding per-instrument limits.
192        let limits = &inst.risk_limits;
193        if let Some(max_qty) = limits.max_order_qty
194            && order.quantity.get() > max_qty.get()
195        {
196            reports.push(ExecutionReport::Rejected {
197                order_id: order.id,
198                symbol,
199                account: order.account,
200                reason: RejectReason::ExceedsMaxOrderQty,
201            });
202            return;
203        }
204        if let Some(max_notional) = limits.max_order_notional {
205            // Notional check applies only to orders with a known price.
206            // Market and Stop orders have no submission-time price.
207            // StopLimit uses limit_price (worst-case resting price).
208            let limit_price = match order.order_type {
209                OrderType::Limit { price, .. } => Some(price),
210                OrderType::StopLimit { limit_price, .. } => Some(limit_price),
211                OrderType::Market | OrderType::Stop { .. } => None,
212            };
213            if let Some(price) = limit_price {
214                let notional = price.get() as u128 * order.quantity.get() as u128;
215                if notional > max_notional as u128 {
216                    reports.push(ExecutionReport::Rejected {
217                        order_id: order.id,
218                        symbol,
219                        account: order.account,
220                        reason: RejectReason::ExceedsMaxNotional,
221                    });
222                    return;
223                }
224            }
225        }
226
227        // GTD validation: GTD orders must carry an expiry strictly in the
228        // future of the event clock; zero ("no expiry set") is covered by
229        // the same comparison. An expiry at or before the clock has no
230        // valid lifetime — the head-of-event expiry drain already ran for
231        // this timestamp, so an accepted order would rest (or, for a stop
232        // whose trigger is already satisfied, even fire and trade) despite
233        // being past its deadline, until some later event reaps it. `<=`
234        // matches the scheduler's due condition (`fire_ns <= now`).
235        if order.time_in_force == TimeInForce::GTD && order.expiry_ns <= self.current_event_ts_ns {
236            reports.push(ExecutionReport::Rejected {
237                order_id: order.id,
238                symbol,
239                account: order.account,
240                reason: RejectReason::InvalidExpiry,
241            });
242            return;
243        }
244        if order.time_in_force != TimeInForce::GTD && order.expiry_ns != 0 {
245            reports.push(ExecutionReport::Rejected {
246                order_id: order.id,
247                symbol,
248                account: order.account,
249                reason: RejectReason::InvalidExpiry,
250            });
251            return;
252        }
253
254        // Per-account open-order cap (SEC-03). Runs after every other
255        // reject reason (UnknownSymbol, InstrumentDisabled, DuplicateOrderId,
256        // TradingHalted, OutsidePriceBand, ExceedsMaxOrderQty,
257        // ExceedsMaxNotional, InvalidExpiry) so an order that would have
258        // been rejected for a venue-side or order-shape reason still
259        // reports that reason — the cap is account-state, akin to
260        // InsufficientBalance, and belongs adjacent to reservation.
261        // Order: cap before reservation so a capped account doesn't churn
262        // the slab. `order_counts` tracks (resting + pending stops +
263        // in-flight) per account; `>=` rejects when accepting this order
264        // would push the count past the limit. `0` = unlimited (opt-out).
265        if self.max_open_orders_per_account > 0
266            && self.order_counts.get(&order.account).copied().unwrap_or(0)
267                >= self.max_open_orders_per_account
268        {
269            reports.push(ExecutionReport::Rejected {
270                order_id: order.id,
271                symbol,
272                account: order.account,
273                reason: RejectReason::ExceedsMaxOpenOrders,
274            });
275            return;
276        }
277
278        // Per-account order-submission rate limit (SEC-04). Token bucket
279        // refilled at `max_orders_per_second`, capped at `max_orders_burst`,
280        // metered against the journaled event timestamp
281        // (`current_event_ts_ns`) so primary and replicas see identical
282        // accept/reject decisions. Sits next to the open-orders cap above
283        // because both are per-account policy gates that take effect
284        // *before* any reservation work — a throttled order should not
285        // perturb the slab or `order_counts`. Disabled when either knob
286        // is `0`.
287        if self.max_orders_per_second > 0 && self.max_orders_burst > 0 {
288            let now_ns = self.current_event_ts_ns;
289            let rate = self.max_orders_per_second;
290            let burst = self.max_orders_burst;
291            let bucket = self
292                .order_buckets
293                .entry(order.account)
294                .or_insert_with(|| TokenBucket::new(burst, now_ns));
295            if !bucket.refill_and_consume(now_ns, rate, burst) {
296                reports.push(ExecutionReport::Rejected {
297                    order_id: order.id,
298                    symbol,
299                    account: order.account,
300                    reason: RejectReason::ExceedsOrderRate,
301                });
302                return;
303            }
304        }
305
306        // Reserve pure notional (no fee cushion). Fees are settled from
307        // the fill's received asset, not from this reservation, so a
308        // schedule change after placement can never make the reservation
309        // insufficient — by construction.
310        let (reserved, slot) = match self.accounts.try_reserve(&order, &spec) {
311            Ok(result) => result,
312            Err(reason) => {
313                reports.push(ExecutionReport::Rejected {
314                    order_id: order.id,
315                    symbol,
316                    account: order.account,
317                    reason,
318                });
319                return;
320            }
321        };
322
323        // For buy-side market/stop-market orders, pass a cost budget so
324        // the matching engine stops before exceeding the reservation. The
325        // budget is exactly the reservation amount — no fee carve-out
326        // needed since fees come out of the buyer's base credit, not the
327        // quote reservation.
328        let quote_budget = match (order.side, order.order_type) {
329            (Side::Buy, OrderType::Market) | (Side::Buy, OrderType::Stop { .. }) => Some(reserved),
330            _ => None,
331        };
332
333        *self.order_counts.entry(order.account).or_default() += 1;
334        // Tentatively claim the (account, order_id) slot for the live
335        // dedup check. If the order closes within this `execute` call
336        // (IOC/FOK fill, FOK kill, etc.) the entry is freed in the
337        // `freed` loop below; if it rests, the entry stays put.
338        self.live_order_ids.insert((order.account, order.id));
339
340        let taker_account = order.account;
341        let taker_id = order.id;
342        let report_start = reports.len();
343
344        // Take scratch buffers out of `self` BEFORE the `inst_mut` borrow
345        // below. `inst` mutably borrows `self.instruments` for the rest
346        // of the function, so we can't touch `self.scratch_*` once it's
347        // live. `mem::take` swaps with an empty Vec (no allocation —
348        // `Vec::new()` is const) and the populated buffer is restored
349        // at the end. Net effect: the inner loop has the same shape as
350        // before but no per-event Vec allocation.
351        //
352        // The leading `clear()` calls are belt-and-braces: the put-back
353        // at function end leaves the field empty, so under normal
354        // control flow the take yields an already-empty Vec. The clear
355        // only does work if a previous `execute` panicked between take
356        // and put-back, leaving stale entries in the scratch.
357        let mut consumed = std::mem::take(&mut self.scratch_consumed);
358        consumed.clear();
359        let mut freed = std::mem::take(&mut self.scratch_freed);
360        freed.clear();
361
362        // Single mutable lookup: book, fees all from the same struct.
363        // Existence was established by the `inst_ref` guard at the top of
364        // `execute`; same single-threaded invariant as the earlier
365        // re-lookup applies.
366        let inst =
367            inst_mut(&mut self.instruments, symbol).expect("instrument verified to exist above");
368        let taker_rested = inst.book.execute(order, quote_budget, slot, reports);
369
370        // Capture the fee schedule for use inside the loop (we need
371        // `maker_side` to attribute maker_fee/taker_fee to base vs quote
372        // legs, so fees must be computed alongside the maker/taker slot
373        // lookup rather than in a separate pre-pass).
374        let fee_schedule = inst.fee_schedule;
375
376        // Process reports to update balances. Mirrors the old process_reports
377        // logic but resolves slots from the book instead of a separate HashMap.
378        //
379        // consumed_slots: fully-filled or STP-cancelled makers, with their
380        // reservation slots. Typically 0-5 entries per aggressive order.
381        consumed.extend(inst.book.drain_consumed_slots());
382
383        for report in &mut reports[report_start..] {
384            match report {
385                ExecutionReport::Fill {
386                    maker_order_id,
387                    taker_order_id,
388                    symbol: _,
389                    maker_account,
390                    taker_account: fill_taker_account,
391                    price,
392                    quantity,
393                    maker_fee,
394                    taker_fee,
395                } => {
396                    // Dereference for clarity; the `&mut` references are
397                    // used only to write maker_fee/taker_fee below.
398                    let maker_order_id = *maker_order_id;
399                    let taker_order_id = *taker_order_id;
400                    let maker_account = *maker_account;
401                    let fill_taker_account = *fill_taker_account;
402                    let price = *price;
403                    let quantity = *quantity;
404                    // Resolve maker slot: consumed list (fully filled) or
405                    // order_index (partially filled, still on book).
406                    let maker_info = consumed
407                        .iter()
408                        .find(|(a, id, _, _)| *a == maker_account && *id == maker_order_id)
409                        .map(|(_, _, side, slot)| (*side, *slot))
410                        .or_else(|| {
411                            inst.book
412                                .peek_order_location(maker_account, maker_order_id)
413                                .map(|(side, _, slot)| (side, slot))
414                        });
415
416                    let Some((maker_side, maker_slot)) = maker_info else {
417                        continue;
418                    };
419
420                    // Resolve taker slot. The fill's taker may be the original
421                    // order (use `slot`) or a triggered stop (consumed_slots
422                    // if fully filled/cancelled, or order_index if it rested).
423                    let taker_slot = if fill_taker_account == taker_account
424                        && taker_order_id == taker_id
425                    {
426                        slot
427                    } else {
428                        // Triggered stop's slot — check consumed first,
429                        // then order_index (stop-limit that partially
430                        // filled and rested).
431                        match consumed
432                            .iter()
433                            .find(|(a, id, _, _)| *a == fill_taker_account && *id == taker_order_id)
434                            .map(|(_, _, _, s)| *s)
435                            .or_else(|| {
436                                inst.book
437                                    .peek_order_location(fill_taker_account, taker_order_id)
438                                    .map(|(_, _, s)| s)
439                            }) {
440                            Some(s) => s,
441                            None => continue,
442                        }
443                    };
444
445                    // Compute fees from the schedule. The wire-format
446                    // report carries fees in **quote currency** (cost-based)
447                    // for both legs — that's the economic value of the
448                    // fee, stable across A's received-asset settlement.
449                    // Internally, fill() takes the buyer fee in base
450                    // units and the seller fee in quote units (each
451                    // deducted from that side's received asset).
452                    let cost = price.get() as u128 * quantity.get() as u128;
453                    let (buyer_slot, seller_slot, buyer_fee_bps, seller_fee_bps) = match maker_side
454                    {
455                        Side::Buy => (
456                            maker_slot,
457                            taker_slot,
458                            fee_schedule.maker_fee_bps,
459                            fee_schedule.taker_fee_bps,
460                        ),
461                        Side::Sell => (
462                            taker_slot,
463                            maker_slot,
464                            fee_schedule.taker_fee_bps,
465                            fee_schedule.maker_fee_bps,
466                        ),
467                    };
468                    let buyer_quote_fee_report = fee_from_bps(cost, buyer_fee_bps);
469                    let seller_quote_fee = fee_from_bps(cost, seller_fee_bps);
470                    let buyer_base_fee = fee_from_bps(quantity.get() as u128, buyer_fee_bps);
471                    // Update the report fields (quote-denominated).
472                    match maker_side {
473                        Side::Buy => {
474                            *maker_fee = buyer_quote_fee_report;
475                            *taker_fee = seller_quote_fee;
476                        }
477                        Side::Sell => {
478                            *maker_fee = seller_quote_fee;
479                            *taker_fee = buyer_quote_fee_report;
480                        }
481                    }
482                    self.accounts.fill(
483                        buyer_slot,
484                        seller_slot,
485                        price,
486                        quantity,
487                        buyer_base_fee,
488                        seller_quote_fee,
489                        &spec,
490                    );
491
492                    // Free fully consumed reservation slots (remaining == 0).
493                    if self.accounts.reservation_remaining(maker_slot) == 0 {
494                        self.accounts.free_slot(maker_slot);
495                        freed.push((maker_account, maker_order_id));
496                    }
497                    if self.accounts.reservation_remaining(taker_slot) == 0 {
498                        self.accounts.free_slot(taker_slot);
499                        freed.push((fill_taker_account, taker_order_id));
500                    }
501                }
502                ExecutionReport::Cancelled {
503                    order_id, account, ..
504                } => {
505                    let order_id = *order_id;
506                    let account = *account;
507                    let key = (account, order_id);
508                    if freed.contains(&key) {
509                        continue;
510                    }
511                    // Cancelled: taker or STP-cancelled maker.
512                    if account == taker_account && order_id == taker_id {
513                        self.accounts.release(slot);
514                    } else if let Some((_, _, _, maker_slot)) = consumed
515                        .iter()
516                        .find(|(a, id, _, _)| *a == account && *id == order_id)
517                    {
518                        self.accounts.release(*maker_slot);
519                    }
520                    freed.push(key);
521                }
522                ExecutionReport::Rejected {
523                    order_id, account, ..
524                } => {
525                    let order_id = *order_id;
526                    let account = *account;
527                    let key = (account, order_id);
528                    if freed.contains(&key) {
529                        continue;
530                    }
531                    if account == taker_account && order_id == taker_id {
532                        self.accounts.release(slot);
533                    } else if let Some((_, _, _, triggered_slot)) = consumed
534                        .iter()
535                        .find(|(a, id, _, _)| *a == account && *id == order_id)
536                    {
537                        self.accounts.release(*triggered_slot);
538                    }
539                    freed.push(key);
540                }
541                _ => {}
542            }
543        }
544
545        // Release leftover reservations for orders no longer on the book
546        // (price improvement, market buy budget surplus, etc.).
547        // Determined from report analysis — no HashMap lookup needed.
548        if !taker_rested && !freed.contains(&(taker_account, taker_id)) {
549            self.accounts.release(slot);
550            freed.push((taker_account, taker_id));
551        }
552        for &(account, order_id, _, maker_slot) in &consumed {
553            if !freed.contains(&(account, order_id)) {
554                self.accounts.release(maker_slot);
555                freed.push((account, order_id));
556            }
557        }
558
559        // Decrement order_counts and free the live_order_ids entry
560        // for every order that closed this turn (consumed maker slots
561        // plus the taker if it didn't rest). Both maps are kept in
562        // lockstep — they have to agree on "which orders are live."
563        for &(account, order_id) in &freed {
564            self.live_order_ids.remove(&(account, order_id));
565            self.release_open_order(account);
566        }
567
568        // Schedule GTD expiry if the order rested (limit) or is now pending
569        // (stop). Stop orders that triggered and fully filled in this same
570        // execute call won't appear in the book any more — find_gtd_expiry
571        // will return None and we won't schedule. Triggered stops that
572        // re-rest as limits keep the same OrderId/expiry_ns, so the single
573        // task scheduled here covers both lifecycle stages.
574        if order.time_in_force == TimeInForce::GTD
575            && order.expiry_ns > 0
576            && inst_ref(&self.instruments, symbol)
577                .and_then(|inst| inst.book.find_gtd_expiry(taker_account, taker_id))
578                .is_some()
579        {
580            self.schedule_gtd_expiry(symbol, taker_account, taker_id, order.expiry_ns);
581        }
582
583        // Clear before restoring so the next call starts from an empty
584        // Vec; capacity is retained. (`consumed` is iterated by reference
585        // in the loop above and may still hold entries; `freed` is also
586        // by-reference in its loop. Neither is drained as a side effect.)
587        consumed.clear();
588        freed.clear();
589        self.scratch_consumed = consumed;
590        self.scratch_freed = freed;
591    }
592}
593
594#[cfg(test)]
595mod tests {
596    use super::{fee_from_bps, fee_from_bps_slow};
597    use proptest::prelude::*;
598
599    /// The naive expression `fee_from_bps` must match exactly. Written
600    /// independently of production code (including its own `10_000`
601    /// literal) so it can serve as a differential oracle.
602    fn naive(value: u128, bps: i16) -> i64 {
603        ((value as i128) * (bps as i128) / 10_000) as i64
604    }
605
606    proptest! {
607        /// Full-domain differential check: the split-division fast path
608        /// must agree with the naive oracle for every representable
609        /// (value, bps) pair, not just the hand-picked grid below.
610        #[test]
611        fn fee_from_bps_matches_naive_for_any_input(v in any::<u64>(), b in any::<i16>()) {
612            prop_assert_eq!(fee_from_bps(v as u128, b), naive(v as u128, b));
613        }
614    }
615
616    #[test]
617    fn fee_from_bps_matches_naive_division_across_edge_grid() {
618        // Cross product of boundary-heavy values and fee/rebate rates,
619        // including the split points around multiples of 10_000 where
620        // the q/r decomposition changes, and u64::MAX where the i128
621        // wrapping cast engages.
622        let values: &[u128] = &[
623            0,
624            1,
625            9_999,
626            10_000,
627            10_001,
628            19_999,
629            20_000,
630            123_456_789,
631            u64::MAX as u128 - 1,
632            u64::MAX as u128,
633        ];
634        let rates: &[i16] = &[
635            i16::MIN,
636            -10_000,
637            -9_999,
638            -20,
639            -1,
640            0,
641            1,
642            20,
643            9_999,
644            10_000,
645            i16::MAX,
646        ];
647        for &v in values {
648            for &b in rates {
649                assert_eq!(
650                    fee_from_bps(v, b),
651                    naive(v, b),
652                    "fee mismatch for value={v} bps={b}"
653                );
654            }
655        }
656    }
657
658    #[test]
659    fn fee_from_bps_slow_path_above_u64_matches_naive() {
660        // Defensive corner: cost = price × quantity can exceed u64 only
661        // through paths the notional bounds block. The outlined fallback
662        // must still agree with the naive expression. Tested directly —
663        // the `fee_from_bps` wrapper debug_asserts before reaching it.
664        let values: &[u128] = &[
665            u64::MAX as u128 + 1,
666            (u64::MAX as u128) * 2,
667            u64::MAX as u128 * u64::MAX as u128,
668        ];
669        for &v in values {
670            for &b in &[-10_000i16, -1, 1, 20, 10_000] {
671                assert_eq!(
672                    fee_from_bps_slow(v, b),
673                    naive(v, b),
674                    "fee mismatch for value={v} bps={b}"
675                );
676            }
677        }
678    }
679
680    // debug_assert-based: only fires in debug builds, so the test is
681    // meaningless (and would fail) under --release.
682    #[cfg(debug_assertions)]
683    #[test]
684    #[should_panic(expected = "notional bound upstream is broken")]
685    fn fee_from_bps_panics_in_debug_above_u64() {
686        // In debug builds a >u64 cost must fail loudly at the fee
687        // helper rather than silently computing from a wrapped value.
688        let _ = fee_from_bps(u64::MAX as u128 + 1, 1);
689    }
690
691    #[test]
692    fn fee_from_bps_truncates_toward_zero_for_rebates() {
693        // trunc semantics: -0.5 bp of 5_000 is 0, not -1 — sign must not
694        // leak into the rounding direction.
695        assert_eq!(fee_from_bps(5_000, -1), 0);
696        assert_eq!(fee_from_bps(5_000, 1), 0);
697        assert_eq!(fee_from_bps(19_999, -3), -5);
698        assert_eq!(fee_from_bps(19_999, 3), 5);
699    }
700}