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}