orderbook_rs/orderbook/modifications.rs
1use crate::orderbook::book::OrderBook;
2use crate::orderbook::book_change_event::PriceLevelChangedEvent;
3use crate::orderbook::error::OrderBookError;
4use crate::orderbook::matching::MatchOutcome;
5use crate::orderbook::order_state::{CancelReason, OrderStatus};
6use crate::orderbook::reject_reason::RejectReason;
7use crate::orderbook::trade::TradeResult;
8use either::Either;
9use pricelevel::{Id, OrderType, OrderUpdate, PriceLevel, Quantity, Side, TakerKind};
10use std::sync::Arc;
11use tracing::trace;
12
13/// A trait to abstract quantity access and modification for different order types.
14pub trait OrderQuantity<T = ()> {
15 /// Returns the primary quantity used for display or simple matching.
16 /// For iceberg orders, this is the visible quantity.
17 fn quantity(&self) -> u64;
18
19 /// Returns the total quantity of the order (e.g., visible + hidden).
20 ///
21 /// Saturates on `visible + hidden` overflow for the two-tranche kinds.
22 /// Every order admitted through `add_order` / the submit APIs / the
23 /// validate-first modify path has already passed
24 /// [`Self::checked_total_quantity`] validation (#210), so the
25 /// saturating arm is unreachable for those book-resident orders; use
26 /// the checked variant at admission boundaries. Snapshot restore
27 /// trusts its (checksummed) source and does not re-validate totals —
28 /// consistent with its existing saturating risk rebuild.
29 fn total_quantity(&self) -> u64;
30
31 /// Returns the total quantity, or `None` when `visible + hidden`
32 /// overflows `u64` for a two-tranche order (Iceberg / Reserve). The
33 /// direct add path rejects such orders before the risk gate, and every
34 /// admission path rejects them before any match, listener, or map
35 /// mutation (#210).
36 #[must_use = "a None total means the order is unrepresentable and must be rejected"]
37 fn checked_total_quantity(&self) -> Option<u64>;
38
39 /// Sets the new quantity for an order, handling the logic for different types.
40 ///
41 /// This is the **user-facing quantity update** semantic: for iceberg
42 /// orders the value is applied to the visible tranche (matching
43 /// [`Self::quantity`], which returns the visible quantity), leaving
44 /// the hidden tranche unchanged. For adjusting an aggressive taker's
45 /// **total** remainder before resting, use
46 /// [`Self::set_total_remaining`] instead — applying a total to the
47 /// visible tranche manufactures liquidity (#210).
48 fn set_quantity(&mut self, new_total_quantity: u64);
49
50 /// Distributes a **total** remaining quantity across the order's
51 /// tranches before resting an aggressive taker's residual (#210).
52 ///
53 /// - One-tranche kinds: the quantity becomes `remaining_total`.
54 /// - Iceberg: the submitted visible quantity acts as the display
55 /// size — `visible = min(display, remaining_total)`,
56 /// `hidden = remaining_total − visible`. A fill smaller than the
57 /// visible tranche shrinks only the display; a fill past it
58 /// consumes hidden; conservation always holds:
59 /// `visible + hidden == remaining_total`.
60 /// - Reserve: reduction is drawn from the visible tranche first, then
61 /// hidden, with the existing replenish-on-empty behaviour (same
62 /// policy `set_quantity` already implemented for Reserve).
63 fn set_total_remaining(&mut self, remaining_total: u64);
64}
65
66impl<T> OrderQuantity<T> for OrderType<T> {
67 #[inline]
68 fn quantity(&self) -> u64 {
69 match self {
70 OrderType::Standard { quantity, .. } => quantity.as_u64(),
71 OrderType::IcebergOrder {
72 visible_quantity, ..
73 } => visible_quantity.as_u64(),
74 OrderType::PostOnly { quantity, .. } => quantity.as_u64(),
75 OrderType::TrailingStop { quantity, .. } => quantity.as_u64(),
76 OrderType::PeggedOrder { quantity, .. } => quantity.as_u64(),
77 OrderType::MarketToLimit { quantity, .. } => quantity.as_u64(),
78 OrderType::ReserveOrder {
79 visible_quantity, ..
80 } => visible_quantity.as_u64(),
81 }
82 }
83
84 #[inline]
85 fn total_quantity(&self) -> u64 {
86 match self {
87 OrderType::Standard { quantity, .. } => quantity.as_u64(),
88 OrderType::IcebergOrder {
89 visible_quantity,
90 hidden_quantity,
91 ..
92 } => visible_quantity
93 .as_u64()
94 .saturating_add(hidden_quantity.as_u64()),
95 OrderType::PostOnly { quantity, .. } => quantity.as_u64(),
96 OrderType::TrailingStop { quantity, .. } => quantity.as_u64(),
97 OrderType::PeggedOrder { quantity, .. } => quantity.as_u64(),
98 OrderType::MarketToLimit { quantity, .. } => quantity.as_u64(),
99 OrderType::ReserveOrder {
100 visible_quantity,
101 hidden_quantity,
102 ..
103 } => visible_quantity
104 .as_u64()
105 .saturating_add(hidden_quantity.as_u64()),
106 }
107 }
108
109 #[inline]
110 fn checked_total_quantity(&self) -> Option<u64> {
111 match self {
112 OrderType::IcebergOrder {
113 visible_quantity,
114 hidden_quantity,
115 ..
116 }
117 | OrderType::ReserveOrder {
118 visible_quantity,
119 hidden_quantity,
120 ..
121 } => visible_quantity
122 .as_u64()
123 .checked_add(hidden_quantity.as_u64()),
124 _ => Some(self.total_quantity()),
125 }
126 }
127
128 #[inline]
129 fn set_quantity(&mut self, new_total_quantity: u64) {
130 match self {
131 OrderType::Standard { quantity, .. }
132 | OrderType::PostOnly { quantity, .. }
133 | OrderType::TrailingStop { quantity, .. }
134 | OrderType::PeggedOrder { quantity, .. }
135 | OrderType::MarketToLimit { quantity, .. } => {
136 *quantity = Quantity::new(new_total_quantity)
137 }
138
139 OrderType::IcebergOrder {
140 visible_quantity, ..
141 } => {
142 // For iceberg orders, treat new_total_quantity as the new visible quantity
143 // This matches the expected behavior where quantity() returns visible_quantity
144 *visible_quantity = Quantity::new(new_total_quantity);
145 // Hidden quantity remains unchanged
146 }
147 OrderType::ReserveOrder { .. } => reduce_reserve_to_total(self, new_total_quantity),
148 }
149 }
150
151 #[inline]
152 fn set_total_remaining(&mut self, remaining_total: u64) {
153 match self {
154 OrderType::Standard { quantity, .. }
155 | OrderType::PostOnly { quantity, .. }
156 | OrderType::TrailingStop { quantity, .. }
157 | OrderType::PeggedOrder { quantity, .. }
158 | OrderType::MarketToLimit { quantity, .. } => {
159 *quantity = Quantity::new(remaining_total)
160 }
161
162 OrderType::IcebergOrder {
163 visible_quantity,
164 hidden_quantity,
165 ..
166 } => {
167 // The submitted visible quantity is the display size. The
168 // residual rests with at most one display tranche visible
169 // and the rest hidden — conservation by construction:
170 // visible + hidden == remaining_total.
171 let display = visible_quantity.as_u64();
172 let visible = display.min(remaining_total);
173 *visible_quantity = Quantity::new(visible);
174 *hidden_quantity = Quantity::new(remaining_total - visible);
175 }
176 OrderType::ReserveOrder { .. } => reduce_reserve_to_total(self, remaining_total),
177 }
178 }
179}
180
181/// Shared Reserve-order reduction: draw the reduction from the visible
182/// tranche first, then hidden, replenishing the visible tranche when it
183/// empties while hidden remains. Used by both the user-facing
184/// `set_quantity` and the residual `set_total_remaining` — Reserve
185/// already treats its input as a total.
186fn reduce_reserve_to_total<T>(order: &mut OrderType<T>, new_total_quantity: u64) {
187 if let OrderType::ReserveOrder {
188 visible_quantity,
189 hidden_quantity,
190 replenish_amount,
191 ..
192 } = order
193 {
194 let original_total = visible_quantity
195 .as_u64()
196 .saturating_add(hidden_quantity.as_u64());
197 let amount_to_reduce = original_total.saturating_sub(new_total_quantity);
198
199 let vis = visible_quantity.as_u64();
200 let filled_from_visible = amount_to_reduce.min(vis);
201 *visible_quantity = Quantity::new(vis.saturating_sub(filled_from_visible));
202
203 let remaining_to_reduce = amount_to_reduce - filled_from_visible;
204 *hidden_quantity =
205 Quantity::new(hidden_quantity.as_u64().saturating_sub(remaining_to_reduce));
206
207 if visible_quantity.as_u64() == 0 && hidden_quantity.as_u64() > 0 {
208 let refresh = replenish_amount
209 .map(|q| q.get())
210 .unwrap_or(0)
211 .min(hidden_quantity.as_u64());
212 *visible_quantity = Quantity::new(refresh);
213 *hidden_quantity = Quantity::new(hidden_quantity.as_u64().saturating_sub(refresh));
214 }
215 }
216}
217
218impl<T> OrderBook<T>
219where
220 T: Clone + Send + Sync + Default + 'static,
221{
222 /// Update an order's price and/or quantity
223 ///
224 /// # Queue priority
225 ///
226 /// The update variants follow conventional exchange price-time-priority
227 /// rules. This is a public contract — external conformance tooling
228 /// depends on it (see issue #203):
229 ///
230 /// - [`OrderUpdate::UpdateQuantity`] with a **decreased or unchanged**
231 /// total quantity (visible + hidden) updates the resting order in
232 /// place at its existing insertion sequence: the maker keeps its
233 /// queue position. Reducing size never forfeits time priority.
234 /// - [`OrderUpdate::UpdateQuantity`] with an **increased** total
235 /// quantity demotes the order to the back of its price level's
236 /// queue. Sizing up loses time priority. The demoted order keeps
237 /// its original admission timestamp — only its insertion sequence
238 /// is refreshed. The demotion survives a snapshot round-trip:
239 /// since pricelevel 0.9 level snapshots materialize orders in
240 /// queue-consumption order, so
241 /// [`restore_from_snapshot`](OrderBook::restore_from_snapshot)
242 /// rebuilds the exact queue (#205). Snapshots captured with
243 /// pricelevel < 0.9 restore a demoted order at its old
244 /// `(timestamp, seq)` position — re-snapshot to pin the corrected
245 /// order.
246 /// - [`OrderUpdate::UpdatePrice`], [`OrderUpdate::UpdatePriceAndQuantity`],
247 /// and [`OrderUpdate::Replace`] are implemented as cancel-then-add:
248 /// the order always re-enters at the back of its (possibly new)
249 /// price level and loses time priority — for `Replace` and
250 /// `UpdatePriceAndQuantity` even when the price is unchanged.
251 ///
252 /// # Errors
253 /// Returns [`OrderBookError::KillSwitchActive`] when the kill switch
254 /// is engaged and the update is anything other than
255 /// [`OrderUpdate::Cancel`]. Cancels are explicitly allowed so that
256 /// operators can drain resting orders while new flow is halted.
257 ///
258 /// [`OrderUpdate::UpdateQuantity`] is validate-first (#211): the
259 /// projected post-update order must pass the shared shape validator
260 /// (tick / lot / min-max / two-tranche representability) and the
261 /// modify-aware risk check, and any upstream
262 /// [`PriceLevelError`](pricelevel::PriceLevelError) from applying the
263 /// update is propagated as [`OrderBookError::PriceLevelError`] — a
264 /// rejected update leaves the maker unchanged, and `Ok(None)` means
265 /// only that the requested order is absent.
266 ///
267 /// Because the shared validator runs on the projected order, two
268 /// previously-accepted shapes are now rejected on `UpdateQuantity`
269 /// like they already were on the #98 modify paths: an
270 /// expired-but-unevicted GTD / DAY maker (`InvalidOperation`, expiry
271 /// is evaluated against the book clock) and a resting post-only maker
272 /// whose price meanwhile crosses the market (`PriceCrossing`).
273 pub fn update_order(
274 &self,
275 update: OrderUpdate,
276 ) -> Result<Option<Arc<OrderType<T>>>, OrderBookError> {
277 // #209: shared submit gate for the whole modify — its internal
278 // cancel-then-add sequences call the ungated inner variants.
279 let _gate = self.submit_gate_read();
280 // Gate non-cancel variants on the kill switch. Cancel passes
281 // through unchanged so operators can drain the book. The
282 // existing order stays live — only the modification is
283 // rejected — so we use `check_kill_switch` (no tracker
284 // recording) rather than `check_kill_switch_or_reject` (which
285 // would mark a live order as terminal-Rejected).
286 let is_modify = matches!(
287 &update,
288 OrderUpdate::UpdatePrice { .. }
289 | OrderUpdate::UpdateQuantity { .. }
290 | OrderUpdate::UpdatePriceAndQuantity { .. }
291 | OrderUpdate::Replace { .. }
292 );
293 if is_modify {
294 self.check_kill_switch()?;
295 }
296
297 self.cache.invalidate();
298 trace!("Order book {}: Updating order {:?}", self.symbol, update);
299 match update {
300 OrderUpdate::UpdatePrice {
301 order_id,
302 new_price,
303 } => {
304 // Get the order location without locking
305 let location = self.order_locations.get(&order_id).map(|val| *val);
306
307 if let Some((old_price, _)) = location {
308 // If price doesn't change, do nothing
309 if old_price == new_price.as_u128() {
310 return Err(OrderBookError::InvalidOperation {
311 message: "Cannot update price to the same value".to_string(),
312 });
313 }
314
315 // Get the original order without holding locks
316 let original_order = if let Some(order) = self.get_order(order_id) {
317 // Create a copy of the order
318 (*order).clone()
319 } else {
320 return Ok(None); // Order not found
321 };
322
323 // Create a new order with the updated price
324 let mut new_order = original_order.clone();
325
326 // Update the price based on order type
327 match &mut new_order {
328 OrderType::Standard { price, .. } => *price = new_price,
329 OrderType::IcebergOrder { price, .. } => *price = new_price,
330 OrderType::PostOnly { price, .. } => *price = new_price,
331 OrderType::TrailingStop { price, .. } => *price = new_price,
332 OrderType::PeggedOrder { price, .. } => *price = new_price,
333 OrderType::MarketToLimit { price, .. } => *price = new_price,
334 OrderType::ReserveOrder { price, .. } => *price = new_price,
335 }
336
337 // Validate-first atomic modify (#98): validate the new
338 // order's shape and run the modify-aware risk check
339 // *before* removing the original. On any rejection we
340 // return the typed error and the original order is
341 // never cancelled — no book mutation, no events, no
342 // trades. These checks are pure functions of the new
343 // order + the opposite book side, so evaluating them
344 // while the same-side original still rests yields the
345 // same verdict as after cancel.
346 self.validate_order_shape(&new_order)?;
347 self.check_risk_modify_admission(
348 order_id,
349 new_order.user_id(),
350 new_order.price().as_u128(),
351 new_order.total_quantity(),
352 )?;
353
354 // #168: reject a re-price that would self-cross the same
355 // user's opposite-side liquidity under CancelTaker/CancelBoth
356 // BEFORE cancelling the original, so the original survives.
357 self.check_modify_stp_self_cross(&new_order)?;
358
359 // Both checks passed: cancel the original and add the
360 // updated order. `add_order` re-runs its own checks;
361 // post-cancel the account count is restored so its risk
362 // check passes — consistent with the pre-guard.
363 // Ungated inner variants: `update_order` already holds
364 // the shared submit gate (#209); the public wrappers
365 // would re-acquire it (std RwLock is not reentrant).
366 // The re-add runs under the SHARED gate, so it must
367 // never be a fill-or-kill (whose all-or-nothing window
368 // requires the exclusive gate). Unreachable today — an
369 // FOK never rests, so it can never be modified — but
370 // enforced so a future TIF change cannot silently void
371 // the #209 guarantee.
372 debug_assert!(
373 !new_order.is_fill_or_kill(),
374 "a resting order can never carry FOK; the shared-gate re-add relies on it"
375 );
376 self.cancel_order_with_reason(order_id, CancelReason::UserRequested)?;
377 let result = self.add_order_inner(new_order, false)?.0;
378 Ok(Some(result))
379 } else {
380 Ok(None) // Order not found
381 }
382 }
383
384 OrderUpdate::UpdateQuantity {
385 order_id,
386 new_quantity,
387 } => {
388 // Get order location without locking
389 let location = self.order_locations.get(&order_id).map(|val| *val);
390
391 if let Some((price, side)) = location {
392 // Get the appropriate price levels map
393 let price_levels = match side {
394 Side::Buy => &self.bids,
395 Side::Sell => &self.asks,
396 };
397
398 // Attempt to update the order within the price level
399 let mut result = None;
400 let mut is_empty = false;
401
402 // Get the price level and update it
403 if let Some(entry) = price_levels.get(&price) {
404 let price_level = entry.value();
405
406 // Validate-first (#211, extending the #98 contract
407 // to quantity updates): project the exact order
408 // pricelevel will store (`with_reduced_quantity` —
409 // the same rewrite `UpdateQuantity` applies
410 // upstream) and run the shared shape validator
411 // plus the modify-aware risk check BEFORE mutating
412 // the level. A rejected update leaves the maker
413 // untouched. The source order is read off the
414 // level entry already in hand — no `Arc` churn, no
415 // second `order_locations` / level lookup.
416 let Some(current_unit) = price_level
417 .iter_orders()
418 .find(|resting| resting.id() == order_id)
419 else {
420 return Ok(None); // Order not found
421 };
422 let current = self.convert_from_unit_type(current_unit.as_ref());
423 let projected = current.with_reduced_quantity(new_quantity.as_u64());
424 self.validate_order_shape(&projected)?;
425 self.check_risk_modify_admission(
426 order_id,
427 projected.user_id(),
428 price,
429 projected.total_quantity(),
430 )?;
431
432 let update = OrderUpdate::UpdateQuantity {
433 order_id,
434 new_quantity,
435 };
436
437 // Propagate upstream validation / counter errors
438 // (#211): `Ok(None)` is reserved for a genuinely
439 // absent order, never an error swallowed silently.
440 match price_level.update_order(update) {
441 Ok(Some(order)) => {
442 // Keep the per-account risk counters in
443 // lockstep with the applied update.
444 self.risk_state.on_quantity_update(
445 order_id,
446 OrderQuantity::<()>::total_quantity(order.as_ref()),
447 );
448 // notify price level changes
449 if let Some(ref listener) = self.price_level_changed_listener {
450 let engine_seq = self.next_engine_seq();
451 listener(PriceLevelChangedEvent {
452 side,
453 price: price_level.price(),
454 quantity: price_level.visible_quantity(),
455 engine_seq,
456 })
457 }
458 result = Some(Arc::new(self.convert_from_unit_type(&order)));
459 }
460 Ok(None) => {}
461 Err(err) => {
462 return Err(OrderBookError::PriceLevelError(err));
463 }
464 }
465
466 is_empty = price_level.order_count() == 0;
467 }
468
469 // If the price level is now empty, remove it
470 if is_empty {
471 price_levels.remove(&price);
472 self.order_locations.remove(&order_id);
473 self.untrack_order_by_id(&order_id);
474 }
475
476 self.cache.invalidate();
477 if is_empty {
478 // Refresh depth gauges now that a level was
479 // removed during the modification path.
480 self.record_depth_metric();
481 }
482 Ok(result)
483 } else {
484 Ok(None) // Order not found
485 }
486 }
487
488 OrderUpdate::UpdatePriceAndQuantity {
489 order_id,
490 new_price,
491 new_quantity,
492 } => {
493 // Get order location without locking
494 let location = self.order_locations.get(&order_id).map(|val| *val);
495
496 if location.is_some() {
497 // Get the original order without holding locks
498 let original_order = if let Some(order) = self.get_order(order_id) {
499 // Create a copy of the order
500 (*order).clone()
501 } else {
502 return Ok(None); // Order not found
503 };
504
505 // Create a new order with the updated price and quantity
506 let mut new_order = original_order.clone();
507
508 // Update the price based on order type
509 match &mut new_order {
510 OrderType::Standard { price, .. } => *price = new_price,
511 OrderType::IcebergOrder { price, .. } => *price = new_price,
512 OrderType::PostOnly { price, .. } => *price = new_price,
513 OrderType::TrailingStop { price, .. } => *price = new_price,
514 OrderType::PeggedOrder { price, .. } => *price = new_price,
515 OrderType::MarketToLimit { price, .. } => *price = new_price,
516 OrderType::ReserveOrder { price, .. } => *price = new_price,
517 }
518
519 // Update the quantity using the trait method
520 new_order.set_quantity(new_quantity.as_u64());
521
522 // Validate-first atomic modify (#98): validate the new
523 // order's shape and run the modify-aware risk check
524 // *before* removing the original. On any rejection the
525 // original order is never cancelled.
526 self.validate_order_shape(&new_order)?;
527 self.check_risk_modify_admission(
528 order_id,
529 new_order.user_id(),
530 new_order.price().as_u128(),
531 new_order.total_quantity(),
532 )?;
533
534 // #168: reject a re-price that would self-cross the same
535 // user's opposite-side liquidity under CancelTaker/CancelBoth
536 // BEFORE cancelling the original, so the original survives.
537 self.check_modify_stp_self_cross(&new_order)?;
538
539 // Both checks passed: cancel the original and add the
540 // updated order.
541 // Ungated inner variants: `update_order` already holds
542 // the shared submit gate (#209); the public wrappers
543 // would re-acquire it (std RwLock is not reentrant).
544 // The re-add runs under the SHARED gate, so it must
545 // never be a fill-or-kill (whose all-or-nothing window
546 // requires the exclusive gate). Unreachable today — an
547 // FOK never rests, so it can never be modified — but
548 // enforced so a future TIF change cannot silently void
549 // the #209 guarantee.
550 debug_assert!(
551 !new_order.is_fill_or_kill(),
552 "a resting order can never carry FOK; the shared-gate re-add relies on it"
553 );
554 self.cancel_order_with_reason(order_id, CancelReason::UserRequested)?;
555 let result = self.add_order_inner(new_order, false)?.0;
556 Ok(Some(result))
557 } else {
558 Ok(None) // Order not found
559 }
560 }
561
562 OrderUpdate::Cancel { order_id } => {
563 // Get order location without locking
564 let location = self.order_locations.get(&order_id).map(|val| *val);
565
566 if let Some((price, side)) = location {
567 // Get the appropriate price levels map
568 let price_levels = match side {
569 Side::Buy => &self.bids,
570 Side::Sell => &self.asks,
571 };
572
573 // Attempt to cancel the order
574 let mut result = None;
575 let mut is_empty = false;
576
577 // Get the current order first
578 if let Some(current_order) = self.get_order(order_id) {
579 result = Some(current_order);
580
581 // Remove the order directly from the price level
582 if let Some(entry) = price_levels.get(&price) {
583 let price_level = entry.value();
584 let cancel_update = OrderUpdate::Cancel { order_id };
585 let result = price_level.update_order(cancel_update);
586 // notify price level changes
587 if let Some(ref listener) = self.price_level_changed_listener
588 && let Ok(updated_order) = result
589 && updated_order.is_some()
590 {
591 let engine_seq = self.next_engine_seq();
592 listener(PriceLevelChangedEvent {
593 side,
594 price: price_level.price(),
595 quantity: price_level.visible_quantity(),
596 engine_seq,
597 })
598 }
599 is_empty = price_level.order_count() == 0;
600 }
601
602 // Remove from order locations tracking
603 self.order_locations.remove(&order_id);
604 // Remove from user_orders index
605 self.untrack_order_by_id(&order_id);
606 }
607
608 // If price level is empty, remove it
609 if is_empty {
610 price_levels.remove(&price);
611 }
612
613 Ok(result)
614 } else {
615 Ok(None) // Order not found
616 }
617 }
618
619 OrderUpdate::Replace {
620 order_id,
621 price,
622 quantity,
623 side,
624 } => {
625 // Get the original order without holding locks
626 let original_opt = self.get_order(order_id);
627
628 if let Some(original) = original_opt {
629 // Create a new order by cloning and updating the original
630 let mut new_order = (*original).clone();
631
632 // Update the order fields based on order type
633 match &mut new_order {
634 OrderType::Standard {
635 id,
636 price: p,
637 quantity: q,
638 side: s,
639 ..
640 } => {
641 *id = order_id;
642 *p = price;
643 *q = quantity;
644 *s = side;
645 }
646 OrderType::IcebergOrder {
647 id,
648 price: p,
649 visible_quantity,
650 side: s,
651 ..
652 } => {
653 *id = order_id;
654 *p = price;
655 *visible_quantity = quantity;
656 *s = side;
657 }
658 OrderType::PostOnly {
659 id,
660 price: p,
661 quantity: q,
662 side: s,
663 ..
664 } => {
665 *id = order_id;
666 *p = price;
667 *q = quantity;
668 *s = side;
669 }
670 OrderType::TrailingStop {
671 id,
672 price: p,
673 quantity: q,
674 side: s,
675 ..
676 } => {
677 *id = order_id;
678 *p = price;
679 *q = quantity;
680 *s = side;
681 }
682 OrderType::PeggedOrder {
683 id,
684 price: p,
685 quantity: q,
686 side: s,
687 ..
688 } => {
689 *id = order_id;
690 *p = price;
691 *q = quantity;
692 *s = side;
693 }
694 OrderType::MarketToLimit {
695 id,
696 price: p,
697 quantity: q,
698 side: s,
699 ..
700 } => {
701 *id = order_id;
702 *p = price;
703 *q = quantity;
704 *s = side;
705 }
706 OrderType::ReserveOrder {
707 id,
708 price: p,
709 visible_quantity,
710 side: s,
711 ..
712 } => {
713 *id = order_id;
714 *p = price;
715 *visible_quantity = quantity;
716 *s = side;
717 }
718 }
719
720 // Validate-first atomic modify (#98): validate the new
721 // order's shape and run the modify-aware risk check
722 // *before* removing the original. On any rejection the
723 // original order is never cancelled — no book mutation,
724 // no events, no trades.
725 self.validate_order_shape(&new_order)?;
726 self.check_risk_modify_admission(
727 order_id,
728 new_order.user_id(),
729 new_order.price().as_u128(),
730 new_order.total_quantity(),
731 )?;
732
733 // #168: reject a re-price that would self-cross the same
734 // user's opposite-side liquidity under CancelTaker/CancelBoth
735 // BEFORE cancelling the original, so the original survives.
736 self.check_modify_stp_self_cross(&new_order)?;
737
738 // Both checks passed: cancel the original and add the
739 // new order.
740 // Ungated inner variants: `update_order` already holds
741 // the shared submit gate (#209); the public wrappers
742 // would re-acquire it (std RwLock is not reentrant).
743 // The re-add runs under the SHARED gate, so it must
744 // never be a fill-or-kill (whose all-or-nothing window
745 // requires the exclusive gate). Unreachable today — an
746 // FOK never rests, so it can never be modified — but
747 // enforced so a future TIF change cannot silently void
748 // the #209 guarantee.
749 debug_assert!(
750 !new_order.is_fill_or_kill(),
751 "a resting order can never carry FOK; the shared-gate re-add relies on it"
752 );
753 self.cancel_order_with_reason(order_id, CancelReason::UserRequested)?;
754 let result = self.add_order_inner(new_order, false)?.0;
755 Ok(Some(result))
756 } else {
757 Ok(None) // Original order not found
758 }
759 }
760 }
761 }
762
763 /// Cancel an order by ID.
764 ///
765 /// Tracks the cancellation as `CancelReason::UserRequested` in the
766 /// order state tracker (if configured).
767 pub fn cancel_order(&self, order_id: Id) -> Result<Option<Arc<OrderType<T>>>, OrderBookError> {
768 // #209: shared gate — a concurrent FOK's exclusive window must not
769 // interleave with this cancel.
770 let _gate = self.submit_gate_read();
771 self.cancel_order_with_reason(order_id, CancelReason::UserRequested)
772 }
773
774 /// Cancel an order by ID with an explicit cancellation reason.
775 ///
776 /// This is the internal implementation used by both `cancel_order`
777 /// and mass cancel operations to track the correct
778 /// [`CancelReason`] in the order state tracker.
779 pub(super) fn cancel_order_with_reason(
780 &self,
781 order_id: Id,
782 reason: CancelReason,
783 ) -> Result<Option<Arc<OrderType<T>>>, OrderBookError> {
784 self.cache.invalidate();
785 // First, we find the order's location (price and side) without locking
786 let location = self.order_locations.get(&order_id).map(|val| *val);
787
788 if let Some((price, side)) = location {
789 // Obtener el mapa de niveles de precio apropiado
790 let price_levels = match side {
791 Side::Buy => &self.bids,
792 Side::Sell => &self.asks,
793 };
794
795 // Create the update to cancel
796 let update = OrderUpdate::Cancel { order_id };
797
798 // Attempt to cancel the order from the price level
799 let mut result = None;
800 let mut empty_level = false;
801
802 if let Some(entry) = price_levels.get(&price) {
803 let price_level = entry.value();
804 // Try to cancel the order
805 if let Ok(cancelled) = price_level.update_order(update) {
806 result = cancelled;
807
808 // notify price level changes
809 if result.is_some()
810 && let Some(ref listener) = self.price_level_changed_listener
811 {
812 let engine_seq = self.next_engine_seq();
813 listener(PriceLevelChangedEvent {
814 side,
815 price: price_level.price(),
816 quantity: price_level.visible_quantity(),
817 engine_seq,
818 })
819 }
820
821 // Check if the level became empty
822 empty_level = price_level.order_count() == 0;
823 }
824 }
825
826 self.cache.invalidate();
827 // If we got a result and the order was canceled
828 if let Some(ref cancelled_order) = result {
829 // Track the cancellation in the order state tracker
830 let prev_filled = self
831 .order_state_tracker
832 .as_ref()
833 .and_then(|t| t.get(order_id))
834 .map(|s| s.filled_quantity())
835 .unwrap_or(0);
836 self.track_state(
837 order_id,
838 OrderStatus::Cancelled {
839 filled_quantity: prev_filled,
840 reason,
841 },
842 );
843
844 // Remove the order from the locations map
845 self.order_locations.remove(&order_id);
846
847 // Pre-trade risk hook: drop the per-account counter
848 // contribution before the order leaves the index. Does
849 // not depend on `cancelled_order` because the risk
850 // state already stores `account` and `remaining_qty`.
851 // No-op when no `RiskConfig` is installed.
852 self.risk_state.on_cancel(order_id);
853
854 // Remove the order from the user_orders index
855 self.untrack_user_order(cancelled_order.user_id(), &order_id);
856
857 // Unregister special orders from re-pricing tracking
858 #[cfg(feature = "special_orders")]
859 {
860 self.special_order_tracker
861 .unregister_pegged_order(&order_id);
862 self.special_order_tracker
863 .unregister_trailing_stop(&order_id);
864 }
865
866 // If the level became empty, remove it
867 if empty_level {
868 price_levels.remove(&price);
869 // Refresh the depth gauges now that a level was
870 // removed. No-op when the `metrics` feature is
871 // disabled.
872 self.record_depth_metric();
873 }
874 }
875
876 Ok(result.map(|order| Arc::new(self.convert_from_unit_type(&order))))
877 } else {
878 Ok(None)
879 }
880 }
881
882 /// Apply the side-effects of cancelling a single resting `order_id` that is
883 /// known to live on the already-held `price_level` (resting on `side`),
884 /// **without** removing the level from the bid/ask map.
885 ///
886 /// This mirrors the per-order effects of [`Self::cancel_order_with_reason`]
887 /// — level-change event, `Cancelled { reason }` state transition, per-account
888 /// risk release, `user_orders` / `order_locations` untrack, and special-order
889 /// deregistration — but it deliberately does **not** touch the bid/ask
890 /// `SkipMap`. The caller owns level removal (the matching loop drains
891 /// `empty_price_levels` after the walk), so this is safe to invoke mid-walk:
892 /// it never removes a level the iterator still references and never
893 /// re-resolves `order_locations`, so a sequence of cancels on the same held
894 /// level cannot skip a later order. Used by the STP `CancelMaker` /
895 /// `CancelBoth` arms (#95). No-op if `order_id` is not resting on the level.
896 pub(super) fn cancel_resting_maker_on_level(
897 &self,
898 price_level: &PriceLevel,
899 side: Side,
900 order_id: Id,
901 reason: CancelReason,
902 ) {
903 let Ok(Some(cancelled)) = price_level.update_order(OrderUpdate::Cancel { order_id }) else {
904 return;
905 };
906 self.cache.invalidate();
907
908 // 1. Notify the level change (same shape as cancel_order_with_reason).
909 if let Some(ref listener) = self.price_level_changed_listener {
910 let engine_seq = self.next_engine_seq();
911 listener(PriceLevelChangedEvent {
912 side,
913 price: price_level.price(),
914 quantity: price_level.visible_quantity(),
915 engine_seq,
916 });
917 }
918
919 // 2. Record the terminal cancellation, preserving any prior fill.
920 let prev_filled = self
921 .order_state_tracker
922 .as_ref()
923 .and_then(|t| t.get(order_id))
924 .map(|s| s.filled_quantity())
925 .unwrap_or(0);
926 self.track_state(
927 order_id,
928 OrderStatus::Cancelled {
929 filled_quantity: prev_filled,
930 reason,
931 },
932 );
933
934 // 3. Drop the per-account risk contribution, then untrack the order.
935 self.order_locations.remove(&order_id);
936 self.risk_state.on_cancel(order_id);
937 self.untrack_user_order(cancelled.user_id(), &order_id);
938
939 #[cfg(feature = "special_orders")]
940 {
941 self.special_order_tracker
942 .unregister_pegged_order(&order_id);
943 self.special_order_tracker
944 .unregister_trailing_stop(&order_id);
945 }
946 }
947
948 /// Validate the *shape* of an order against this book's admission
949 /// rules **without** mutating any book state.
950 ///
951 /// This is the single source of truth for the non-risk admission
952 /// checks that [`Self::add_order`] performs, in the same order and
953 /// returning the same typed [`OrderBookError`] variants. Unlike
954 /// `add_order` it is pure: it never calls
955 /// [`track_state`](Self::track_state), [`reject_with_risk`](Self::reject_with_risk),
956 /// emits metrics, or invalidates the cache. Every check here is a
957 /// function of the new order plus the *opposite* book side, so it
958 /// yields the same verdict whether evaluated before or after the
959 /// original (same-side) order has been cancelled — which is what
960 /// makes the validate-first atomic modify (#98) safe.
961 ///
962 /// Checks, in order:
963 /// 1. STP `MissingUserId` (when STP is enabled and `user_id` is zero).
964 /// 2. Tick size (`InvalidTickSize`).
965 /// 3. Lot size (`InvalidLotSize`, iceberg visible/hidden split).
966 /// 4. Min/max order size (`OrderSizeOutOfRange`).
967 /// 5. Expiry (`InvalidOperation` — already expired).
968 /// 6. Post-only would cross (`PriceCrossing`).
969 /// 7. FOK feasibility (`InsufficientLiquidity`).
970 ///
971 /// # Errors
972 /// Returns the first failing check's typed [`OrderBookError`].
973 pub(super) fn validate_order_shape(&self, order: &OrderType<T>) -> Result<(), OrderBookError> {
974 // Two-tranche total representability (#210): an Iceberg / Reserve
975 // whose visible + hidden overflows u64 cannot be tracked by any of
976 // the engine's quantity arithmetic — reject it before every other
977 // check so the saturating `total_quantity` below (and everywhere
978 // downstream) is provably unreachable for admitted orders.
979 if order.checked_total_quantity().is_none() {
980 return Err(OrderBookError::QuantityOverflow {
981 visible: order.visible_quantity().as_u64(),
982 hidden: order.hidden_quantity().as_u64(),
983 });
984 }
985
986 // STP user_id enforcement: when STP is enabled, all orders must carry
987 // a non-zero user_id so that self-trade checks can identify the owner.
988 if self.stp_mode != crate::orderbook::stp::STPMode::None
989 && order.user_id() == pricelevel::Hash32::zero()
990 {
991 return Err(OrderBookError::MissingUserId {
992 order_id: order.id(),
993 });
994 }
995
996 // Tick size validation: reject orders whose price is not a multiple of tick_size
997 if let Some(tick) = self.tick_size
998 && tick > 0
999 && !order.price().as_u128().is_multiple_of(tick)
1000 {
1001 return Err(OrderBookError::InvalidTickSize {
1002 price: order.price().as_u128(),
1003 tick_size: tick,
1004 });
1005 }
1006
1007 // Lot size validation: reject orders whose quantity is not a multiple of lot_size.
1008 // For iceberg orders, validate visible and hidden quantities individually.
1009 if let Some(lot) = self.lot_size
1010 && lot > 0
1011 {
1012 match order {
1013 OrderType::IcebergOrder {
1014 visible_quantity,
1015 hidden_quantity,
1016 ..
1017 } => {
1018 if visible_quantity.as_u64() % lot != 0 {
1019 return Err(OrderBookError::InvalidLotSize {
1020 quantity: visible_quantity.as_u64(),
1021 lot_size: lot,
1022 });
1023 }
1024 if hidden_quantity.as_u64() % lot != 0 {
1025 return Err(OrderBookError::InvalidLotSize {
1026 quantity: hidden_quantity.as_u64(),
1027 lot_size: lot,
1028 });
1029 }
1030 }
1031 _ => {
1032 if order.total_quantity() % lot != 0 {
1033 return Err(OrderBookError::InvalidLotSize {
1034 quantity: order.total_quantity(),
1035 lot_size: lot,
1036 });
1037 }
1038 }
1039 }
1040 }
1041
1042 // Min/max order size validation
1043 let qty = order.total_quantity();
1044 if let Some(min) = self.min_order_size
1045 && qty < min
1046 {
1047 return Err(OrderBookError::OrderSizeOutOfRange {
1048 quantity: qty,
1049 min: Some(min),
1050 max: self.max_order_size,
1051 });
1052 }
1053 if let Some(max) = self.max_order_size
1054 && qty > max
1055 {
1056 return Err(OrderBookError::OrderSizeOutOfRange {
1057 quantity: qty,
1058 min: self.min_order_size,
1059 max: Some(max),
1060 });
1061 }
1062
1063 if self.has_expired(order) {
1064 return Err(OrderBookError::InvalidOperation {
1065 message: "Order has already expired".to_string(),
1066 });
1067 }
1068
1069 if order.is_post_only() && self.will_cross_market(order.price().as_u128(), order.side()) {
1070 return Err(OrderBookError::PriceCrossing {
1071 price: order.price().as_u128(),
1072 side: order.side(),
1073 opposite_price: if order.side() == Side::Buy {
1074 self.best_ask().unwrap_or(0)
1075 } else {
1076 self.best_bid().unwrap_or(0)
1077 },
1078 });
1079 }
1080
1081 // For FOK orders, first check if the entire quantity can be matched
1082 // without altering the book. Use the faithful feasibility check (lot_size
1083 // + STP aware), not the raw-depth `peek_match`, so fill-or-kill stays
1084 // all-or-nothing and never emits a partial fill it then reports as killed (#96).
1085 if order.is_fill_or_kill() {
1086 let potential_match = self.fok_fillable_quantity(
1087 order.side(),
1088 order.total_quantity(),
1089 Some(order.price().as_u128()),
1090 order.user_id(),
1091 order.id(),
1092 );
1093 if potential_match < order.total_quantity() {
1094 return Err(OrderBookError::InsufficientLiquidity {
1095 side: order.side(),
1096 requested: order.total_quantity(),
1097 available: potential_match,
1098 });
1099 }
1100 }
1101
1102 Ok(())
1103 }
1104
1105 /// STP self-cross pre-check for the validate-first atomic modify (#168).
1106 ///
1107 /// Closes the one post-match modify-atomicity gap #98 left open. Under
1108 /// [`STPMode::CancelTaker`](crate::orderbook::stp::STPMode::CancelTaker) /
1109 /// [`CancelBoth`](crate::orderbook::stp::STPMode::CancelBoth), if a
1110 /// re-priced order would cross into the **same user's** resting liquidity on
1111 /// the opposite side, `add_order` matches post-cancel and cancels the taker
1112 /// (the re-added order) — *after* the original was already removed,
1113 /// destroying it. This dry-runs the crossable opposite side and, if the
1114 /// sweep would reach a same-user maker while the taker still has unfilled
1115 /// quantity (the exact condition under which the engine sets
1116 /// `stp_taker_cancelled`), returns [`OrderBookError::SelfTradePrevented`]
1117 /// **before** the original is cancelled, so it survives unchanged.
1118 ///
1119 /// No-op when STP is off, the taker is anonymous, or the mode is
1120 /// [`CancelMaker`](crate::orderbook::stp::STPMode::CancelMaker) (which
1121 /// cancels the maker and rests the taker — it never destroys the re-added
1122 /// order). Like the other validate-first checks (#98) it is a pure function
1123 /// of the new order plus the *opposite* book side, so evaluating it while
1124 /// the same-side original still rests yields the same verdict as after
1125 /// cancel.
1126 pub(super) fn check_modify_stp_self_cross(
1127 &self,
1128 new_order: &OrderType<T>,
1129 ) -> Result<(), OrderBookError> {
1130 use crate::orderbook::stp::STPMode;
1131
1132 let taker_user_id = new_order.user_id();
1133 // Only CancelTaker / CancelBoth cancel the taker; None / CancelMaker
1134 // rest it, so the re-added order is never destroyed.
1135 match self.stp_mode {
1136 STPMode::CancelTaker | STPMode::CancelBoth => {}
1137 _ => return Ok(()),
1138 }
1139 if taker_user_id == pricelevel::Hash32::zero() {
1140 return Ok(());
1141 }
1142
1143 let side = new_order.side();
1144 let new_price = new_order.price().as_u128();
1145 let opposite = match side {
1146 Side::Buy => &self.asks,
1147 Side::Sell => &self.bids,
1148 };
1149 // Walk the crossable opposite side in price-time priority — asks
1150 // ascending for a Buy, bids descending for a Sell — exactly the sweep's
1151 // visit order.
1152 let iter = match side {
1153 Side::Buy => Either::Left(opposite.iter()),
1154 Side::Sell => Either::Right(opposite.iter().rev()),
1155 };
1156
1157 let mut remaining = new_order.total_quantity();
1158 for entry in iter {
1159 if remaining == 0 {
1160 // The taker fully fills against non-self depth before reaching
1161 // any same-user maker → the engine never cancels it.
1162 return Ok(());
1163 }
1164 let price = *entry.key();
1165 let crosses = match side {
1166 Side::Buy => new_price >= price,
1167 Side::Sell => new_price <= price,
1168 };
1169 if !crosses {
1170 // Price-sorted levels: no further level can cross.
1171 break;
1172 }
1173 let level = entry.value();
1174 if level.iter_orders().any(|o| o.user_id() == taker_user_id) {
1175 // The sweep reaches a level holding a same-user maker while the
1176 // taker still has unfilled quantity: the engine would cancel the
1177 // taker here. Reject the modify before the original is cancelled.
1178 return Err(OrderBookError::SelfTradePrevented {
1179 mode: self.stp_mode,
1180 taker_order_id: new_order.id(),
1181 user_id: taker_user_id,
1182 });
1183 }
1184 // No same-user maker at this level: the taker consumes its full
1185 // matchable depth (the authoritative upstream dry run), then walks on.
1186 remaining =
1187 remaining.saturating_sub(level.matchable_quantity(remaining, new_order.id()));
1188 }
1189 Ok(())
1190 }
1191
1192 /// Record the terminal state transition (and metric) that the direct
1193 /// [`Self::add_order`] path historically emitted for each shape
1194 /// rejection returned by [`Self::validate_order_shape`].
1195 ///
1196 /// Keeping this mapping next to the validator preserves the exact
1197 /// pre-#98 reject side-effects of `add_order` while letting the
1198 /// validate-first modify path reuse the same pure validator without
1199 /// recording any state. Errors that previously had no side-effect
1200 /// (e.g. the already-expired `InvalidOperation`) are intentionally
1201 /// no-ops here.
1202 fn record_shape_rejection(&self, order: &OrderType<T>, err: &OrderBookError) {
1203 match err {
1204 OrderBookError::MissingUserId { .. } => {
1205 self.track_state(
1206 order.id(),
1207 OrderStatus::Rejected {
1208 reason: RejectReason::MissingUserId,
1209 },
1210 );
1211 }
1212 OrderBookError::QuantityOverflow { .. } => {
1213 self.track_state(
1214 order.id(),
1215 OrderStatus::Rejected {
1216 reason: RejectReason::InvalidQuantity,
1217 },
1218 );
1219 }
1220 OrderBookError::InvalidTickSize { .. } => {
1221 self.track_state(
1222 order.id(),
1223 OrderStatus::Rejected {
1224 reason: RejectReason::InvalidPrice,
1225 },
1226 );
1227 }
1228 OrderBookError::InvalidLotSize { .. } => {
1229 self.track_state(
1230 order.id(),
1231 OrderStatus::Rejected {
1232 reason: RejectReason::InvalidQuantity,
1233 },
1234 );
1235 }
1236 OrderBookError::OrderSizeOutOfRange { .. } => {
1237 self.track_state(
1238 order.id(),
1239 OrderStatus::Rejected {
1240 reason: RejectReason::OrderSizeOutOfRange,
1241 },
1242 );
1243 }
1244 OrderBookError::PriceCrossing { .. } => {
1245 self.track_state(
1246 order.id(),
1247 OrderStatus::Rejected {
1248 reason: RejectReason::PostOnlyWouldCross,
1249 },
1250 );
1251 }
1252 OrderBookError::InsufficientLiquidity { .. } => {
1253 self.track_state(
1254 order.id(),
1255 OrderStatus::Cancelled {
1256 filled_quantity: 0,
1257 reason: CancelReason::InsufficientLiquidity,
1258 },
1259 );
1260 crate::orderbook::metrics::record_reject(RejectReason::InsufficientLiquidity);
1261 }
1262 // The already-expired `InvalidOperation` path historically
1263 // recorded no terminal transition; preserve that.
1264 _ => {}
1265 }
1266 }
1267
1268 /// Add a new order to the book, automatically matching it if it's aggressive.
1269 ///
1270 /// This convenience method calls the same implementation as
1271 /// [`Self::add_order_with_result`] but discards the trade result. When no
1272 /// trade listener is installed, the `TradeResult` is never constructed, so
1273 /// this path stays free of the extra `MatchResult` clone.
1274 ///
1275 /// # Errors
1276 /// Returns [`OrderBookError::KillSwitchActive`] when the kill switch
1277 /// is engaged. The check runs before any cache invalidation, STP
1278 /// validation, tick/lot validation, or matching work.
1279 #[inline]
1280 pub fn add_order(&self, order: OrderType<T>) -> Result<Arc<OrderType<T>>, OrderBookError> {
1281 // #209: shared gate for ordinary submits, exclusive for FOK so its
1282 // feasibility + sweep window excludes every concurrent mutation.
1283 let _gate = self.acquire_submit_gate(order.is_fill_or_kill());
1284 self.add_order_inner(order, false).map(|(order, _)| order)
1285 }
1286
1287 /// Add a new order to the book, automatically matching it if it's
1288 /// aggressive, and additionally return the [`TradeResult`] produced by the
1289 /// match directly to the caller.
1290 ///
1291 /// The trade result is `None` when the order produced no fills (it rested
1292 /// on the book, or was admitted without matching). When a trade listener
1293 /// is installed, the listener is invoked with the exact same `TradeResult`
1294 /// that is returned here — same fills, same fees, same `engine_seq`.
1295 ///
1296 /// Per-call attribution: concurrent submits on the same book each receive
1297 /// exactly their own fills; the result is built from this call's private
1298 /// match outcome, never from shared capture state. The engine holds no
1299 /// cross-call trade accumulator — each returned `TradeResult` is
1300 /// constructed from the `MatchResult` produced by this invocation alone —
1301 /// so two threads submitting crossing orders concurrently cannot observe
1302 /// each other's fills in their own returned result.
1303 ///
1304 /// On error paths that follow real fills (an unfillable IOC remainder, or
1305 /// a self-trade-prevention cancellation after earlier non-self fills) the
1306 /// typed error is returned instead, so those fills reach the trade
1307 /// listener only.
1308 ///
1309 /// Every trade-producing call consumes one `engine_seq` tick, even when no
1310 /// trade listener is installed (plain [`Self::add_order`] only consumes one
1311 /// when a listener is present). `engine_seq` is per-instance and not
1312 /// replay-reproducible; consumers that need a stable ordering key should
1313 /// use the journal's `sequence_num` / `timestamp_ns` instead.
1314 ///
1315 /// # Errors
1316 /// Returns [`OrderBookError::KillSwitchActive`] when the kill switch
1317 /// is engaged. The check runs before any cache invalidation, STP
1318 /// validation, tick/lot validation, or matching work.
1319 pub fn add_order_with_result(
1320 &self,
1321 order: OrderType<T>,
1322 ) -> Result<(Arc<OrderType<T>>, Option<TradeResult>), OrderBookError> {
1323 // #209: same gating as `add_order`.
1324 let _gate = self.acquire_submit_gate(order.is_fill_or_kill());
1325 self.add_order_inner(order, true)
1326 }
1327
1328 /// Shared implementation behind [`Self::add_order`] and
1329 /// [`Self::add_order_with_result`]. `want_result` gates `TradeResult`
1330 /// construction so the plain `add_order` path only pays for it when an
1331 /// installed trade listener needs it anyway.
1332 fn add_order_inner(
1333 &self,
1334 mut order: OrderType<T>,
1335 want_result: bool,
1336 ) -> Result<(Arc<OrderType<T>>, Option<TradeResult>), OrderBookError> {
1337 self.check_kill_switch_or_reject(order.id())?;
1338 // Representability gate (#210): an unrepresentable two-tranche
1339 // total must be rejected before the risk gate below, which would
1340 // otherwise evaluate the account's notional against the SATURATED
1341 // `u64::MAX` total and reject with a misleading risk-family error.
1342 // `validate_order_shape` re-checks this for the shared modify path;
1343 // the duplicate check is a single jump-table match + checked_add.
1344 if order.checked_total_quantity().is_none() {
1345 let err = OrderBookError::QuantityOverflow {
1346 visible: order.visible_quantity().as_u64(),
1347 hidden: order.hidden_quantity().as_u64(),
1348 };
1349 self.record_shape_rejection(&order, &err);
1350 return Err(err);
1351 }
1352 // Pre-trade risk gate: per-account open-orders / notional /
1353 // price band. No-op when no `RiskConfig` is installed.
1354 // Documented order: kill_switch → risk → STP → fees → match.
1355 // On the cold reject path, record an `OrderStatus::Rejected`
1356 // transition with the closed `RejectReason` taxonomy before
1357 // propagating the typed error.
1358 if let Err(err) = self.check_risk_limit_admission(
1359 order.user_id(),
1360 order.price().as_u128(),
1361 order.total_quantity(),
1362 ) {
1363 self.reject_with_risk(order.id(), &err);
1364 return Err(err);
1365 }
1366
1367 // Reject a duplicate order id: an order with this id is already
1368 // resting on the book. Admitting it would overwrite the existing
1369 // order's entry in `order_locations` and orphan the live order (it
1370 // could no longer be cancelled or modified by id). This is an
1371 // `add_order`-specific structural check and deliberately does NOT
1372 // live in `validate_order_shape`: the validate-first atomic modify
1373 // (#98) runs that shared validator while the original, same-id
1374 // order is still resting, so a check there would false-reject every
1375 // modify. We also do NOT record an `OrderStatus::Rejected`
1376 // transition — the id belongs to a different, still-live order
1377 // whose tracked state must not be clobbered. The metric plus the
1378 // typed error (which the wire layer maps to
1379 // `RejectReason::DuplicateOrderId`) are sufficient.
1380 //
1381 // This is a sequential guard, not a concurrency guard: the check
1382 // and the eventual `order_locations.insert` straddle the match
1383 // walk, so two concurrent `add_order` calls with the same *fresh*
1384 // id can both pass here and both rest (last-writer-wins on insert).
1385 // Serializing order ids is the ingress / sequencing layer's job.
1386 if self.order_locations.contains_key(&order.id()) {
1387 crate::orderbook::metrics::record_reject(RejectReason::DuplicateOrderId);
1388 return Err(OrderBookError::DuplicateOrderId {
1389 order_id: order.id(),
1390 });
1391 }
1392
1393 trace!(
1394 "Order book {}: Adding order {} at price {}",
1395 self.symbol,
1396 order.id(),
1397 order.price()
1398 );
1399
1400 // Non-risk admission checks are owned by `validate_order_shape`
1401 // (the single source of truth shared with the validate-first
1402 // atomic modify path, #98). On the cold reject path we still
1403 // record the matching terminal state transition / metric here so
1404 // the direct (non-modify) `add_order` behavior is preserved
1405 // exactly.
1406 if let Err(err) = self.validate_order_shape(&order) {
1407 self.record_shape_rejection(&order, &err);
1408 return Err(err);
1409 }
1410
1411 // Residual-admission headroom pre-check (#211): a non-immediate
1412 // taker may rest its residual at a same-side level whose checked
1413 // aggregate counters cannot absorb it. pricelevel would reject
1414 // that admission — but only AFTER the sweep has emitted
1415 // irreversible trades. Reject up front instead. Gated on
1416 // `will_cross_market` (one best-price cache read): a non-crossing
1417 // add emits no trades, so its admission failure is already atomic
1418 // via the cleanup path below — only a crossing taker needs the
1419 // pre-trade guard, and it is about to pay for a full sweep anyway.
1420 // The check is conservative (it uses the full submitted total; the
1421 // actual residual is never larger) and best-effort under
1422 // concurrency — the authoritative, validated admission below still
1423 // guards the racy remainder, now with cleanup (#211).
1424 if !order.is_immediate() && self.will_cross_market(order.price().as_u128(), order.side()) {
1425 let same_side = match order.side() {
1426 Side::Buy => &self.bids,
1427 Side::Sell => &self.asks,
1428 };
1429 if let Some(entry) = same_side.get(&order.price().as_u128()) {
1430 // A counter-inconsistency error from the level's checked
1431 // aggregate is rejected with the same observable
1432 // lifecycle/metric surface as the overflow branch below —
1433 // both are pre-mutation, so the book is still pristine.
1434 let level_total = match entry.value().total_quantity() {
1435 Ok(total) => total,
1436 Err(err) => {
1437 self.track_state(
1438 order.id(),
1439 OrderStatus::Rejected {
1440 reason: RejectReason::InvalidQuantity,
1441 },
1442 );
1443 crate::orderbook::metrics::record_reject(RejectReason::InvalidQuantity);
1444 return Err(OrderBookError::PriceLevelError(err));
1445 }
1446 };
1447 if level_total.checked_add(order.total_quantity()).is_none() {
1448 let err = OrderBookError::InvalidOperation {
1449 message: format!(
1450 "resting order {} would overflow the aggregate capacity of level {}",
1451 order.id(),
1452 order.price()
1453 ),
1454 };
1455 self.track_state(
1456 order.id(),
1457 OrderStatus::Rejected {
1458 reason: RejectReason::InvalidQuantity,
1459 },
1460 );
1461 crate::orderbook::metrics::record_reject(RejectReason::InvalidQuantity);
1462 return Err(err);
1463 }
1464 }
1465 }
1466
1467 self.cache.invalidate();
1468 // Attempt to match the order immediately (with STP user_id propagation).
1469 // The outcome also carries whether STP cancelled the taker (#97) and
1470 // whether a per-level post-only guard refused to trade (#209).
1471 // Threading the taker's real kind gives post-only its structural
1472 // never-trades guarantee under every interleaving — the
1473 // `will_cross_market` precheck in `validate_order_shape` remains
1474 // only a fast-path reject.
1475 // Deliberately total over today's `TakerKind`: everything that is
1476 // not post-only — including MarketToLimit, which is MEANT to take
1477 // liquidity — sweeps as `Standard`. A future third `TakerKind`
1478 // variant must be routed here explicitly.
1479 let taker_kind = if order.is_post_only() {
1480 TakerKind::PostOnly
1481 } else {
1482 TakerKind::Standard
1483 };
1484 let MatchOutcome {
1485 result: match_result,
1486 taker_stp_cancelled,
1487 taker_post_only_rejected,
1488 } = self.match_order_with_user_outcome(
1489 order.id(),
1490 order.side(),
1491 order.total_quantity(), // Use total quantity for matching
1492 Some(order.price().as_u128()),
1493 order.user_id(),
1494 taker_kind,
1495 )?;
1496
1497 // #209: the sweep reached a crossable level with a post-only taker.
1498 // pricelevel structurally refused to trade (zero fills), so reject
1499 // exactly like the precheck would have — the race between precheck
1500 // and sweep can no longer make a post-only order take liquidity.
1501 if taker_post_only_rejected {
1502 self.track_state(
1503 order.id(),
1504 OrderStatus::Rejected {
1505 reason: RejectReason::PostOnlyWouldCross,
1506 },
1507 );
1508 crate::orderbook::metrics::record_reject(RejectReason::PostOnlyWouldCross);
1509 return Err(OrderBookError::PriceCrossing {
1510 price: order.price().as_u128(),
1511 side: order.side(),
1512 opposite_price: if order.side() == Side::Buy {
1513 self.best_ask().unwrap_or(0)
1514 } else {
1515 self.best_bid().unwrap_or(0)
1516 },
1517 });
1518 }
1519
1520 // Emit trades BEFORE any early return below: the STP taker-cancel and
1521 // unfillable-IOC paths return `Err` after real (non-self) fills already
1522 // executed, and those fills must still reach the metrics and the trade
1523 // listener. The `TradeResult` is only constructed when someone consumes
1524 // it — the installed listener and/or an `add_order_with_result` caller —
1525 // so the plain `add_order` hot path skips the `MatchResult` clone.
1526 let trades_emitted = match_result.trades().len() as u64;
1527 let trade_result = if trades_emitted > 0 {
1528 crate::orderbook::metrics::record_trades(trades_emitted);
1529 let listener = self.trade_listener.as_ref();
1530 if want_result || listener.is_some() {
1531 let mut trade_result = TradeResult::with_fees(
1532 self.symbol.clone(),
1533 match_result.clone(),
1534 self.fee_schedule,
1535 );
1536 trade_result.engine_seq = self.next_engine_seq();
1537 if let Some(listener) = listener {
1538 listener(&trade_result) // emit trade events to listener
1539 }
1540 Some(trade_result)
1541 } else {
1542 None
1543 }
1544 } else {
1545 None
1546 };
1547
1548 // True (non-self) executed quantity. `remaining_quantity` only decrements on
1549 // real trades, so STP-prevented self-fills never count toward it.
1550 let original_qty = order.total_quantity();
1551 let filled_qty = original_qty.saturating_sub(match_result.remaining_quantity().as_u64());
1552
1553 // If STP cancelled the taker, the residual must NOT rest — even though some
1554 // non-self fills already occurred at earlier levels. Record the terminal
1555 // SelfTradePrevention state with the true filled quantity and surface the STP
1556 // error (#97). The zero-fills case already returned this error from the match.
1557 if taker_stp_cancelled {
1558 self.track_state(
1559 order.id(),
1560 OrderStatus::Cancelled {
1561 filled_quantity: filled_qty,
1562 reason: CancelReason::SelfTradePrevention,
1563 },
1564 );
1565 crate::orderbook::metrics::record_reject(RejectReason::SelfTradePrevention);
1566 return Err(OrderBookError::SelfTradePrevented {
1567 mode: self.stp_mode,
1568 taker_order_id: order.id(),
1569 user_id: order.user_id(),
1570 });
1571 }
1572
1573 // If the order was not fully filled, add the remainder to the book
1574 if match_result.remaining_quantity().as_u64() > 0 {
1575 if order.is_immediate() {
1576 // IOC/FOK orders should not have a resting part.
1577 // If FOK, it should have been fully filled or cancelled before this point.
1578 // If IOC, this is the remaining part that couldn't be filled, so we just drop it.
1579 self.track_state(
1580 order.id(),
1581 OrderStatus::Cancelled {
1582 filled_quantity: filled_qty,
1583 reason: CancelReason::InsufficientLiquidity,
1584 },
1585 );
1586 crate::orderbook::metrics::record_reject(RejectReason::InsufficientLiquidity);
1587 return Err(OrderBookError::InsufficientLiquidity {
1588 side: order.side(),
1589 requested: order.quantity(), // Now uses the trait method
1590 available: order
1591 .quantity()
1592 .saturating_sub(match_result.remaining_quantity().as_u64()),
1593 });
1594 }
1595
1596 // Rest the taker's residual. `remaining_quantity` is the TOTAL
1597 // unmatched quantity, so distribute it across the tranches with
1598 // `set_total_remaining` (#210): for a partially-filled iceberg
1599 // the submitted visible quantity acts as the display size and
1600 // the rest stays hidden — assigning the total to the visible
1601 // tranche (the old `set_quantity` semantics) manufactured
1602 // liquidity by keeping the original hidden tranche on top.
1603 if match_result.remaining_quantity().as_u64() < order.total_quantity() {
1604 order.set_total_remaining(match_result.remaining_quantity().as_u64());
1605 }
1606
1607 let price = order.price().as_u128();
1608 let side = order.side();
1609
1610 let price_levels = match side {
1611 Side::Buy => &self.bids,
1612 Side::Sell => &self.asks,
1613 };
1614
1615 let price_level = price_levels.get_or_insert(price, Arc::new(PriceLevel::new(price)));
1616 let level = price_level.value();
1617
1618 // Convert to unit type for PriceLevel compatibility. Admission
1619 // into the level is validated upstream since pricelevel 0.9
1620 // (duplicate id, counter capacity). The pre-sweep headroom
1621 // check above makes a failure here concurrent-only; if it
1622 // still happens, remove the level when this call created it
1623 // empty — `best_bid` / `best_ask`, the cache, and the depth
1624 // gauges must never expose a phantom level — and surface the
1625 // error loudly: the sweep's trades are already irreversible
1626 // (#211).
1627 let unit_order = self.convert_to_unit_type(&order);
1628 let unit_order_arc = match price_level.value().add_order(unit_order) {
1629 Ok(admitted) => admitted,
1630 Err(err) => {
1631 if level.order_count() == 0 {
1632 price_levels.remove(&price);
1633 }
1634 self.cache.invalidate();
1635 self.record_depth_metric();
1636 tracing::error!(
1637 order_id = %order.id(),
1638 price,
1639 error = %err,
1640 "residual admission failed after irreversible trades; level cleaned up"
1641 );
1642 return Err(OrderBookError::PriceLevelError(err));
1643 }
1644 };
1645 // notify price level changes
1646 if let Some(ref listener) = self.price_level_changed_listener {
1647 let engine_seq = self.next_engine_seq();
1648 listener(PriceLevelChangedEvent {
1649 side,
1650 price: level.price(),
1651 quantity: level.visible_quantity(),
1652 engine_seq,
1653 })
1654 }
1655 self.order_locations
1656 .insert(unit_order_arc.id(), (price, side));
1657
1658 // Refresh the depth gauges. The level may be brand-new
1659 // (`get_or_insert` created it) or pre-existing — either
1660 // way the gauge reflects current state. No-op when the
1661 // `metrics` feature is disabled.
1662 self.record_depth_metric();
1663
1664 // Pre-trade risk hook: register the resting order with
1665 // the risk state so per-account counters are updated and
1666 // future checks see the new contribution. No-op when no
1667 // `RiskConfig` is installed.
1668 self.risk_state.on_admission(
1669 unit_order_arc.id(),
1670 order.user_id(),
1671 price,
1672 match_result.remaining_quantity().as_u64(),
1673 );
1674
1675 // Track the order in the user_orders index
1676 self.track_user_order(order.user_id(), unit_order_arc.id());
1677
1678 // Register special orders for re-pricing tracking
1679 #[cfg(feature = "special_orders")]
1680 match &order {
1681 OrderType::PeggedOrder { id, .. } => {
1682 self.special_order_tracker.register_pegged_order(*id);
1683 }
1684 OrderType::TrailingStop { id, .. } => {
1685 self.special_order_tracker.register_trailing_stop(*id);
1686 }
1687 _ => {}
1688 }
1689
1690 // Track state: Open (no fills) or PartiallyFilled (some fills, resting)
1691 if filled_qty > 0 {
1692 self.track_state(
1693 order.id(),
1694 OrderStatus::PartiallyFilled {
1695 original_quantity: original_qty,
1696 filled_quantity: filled_qty,
1697 },
1698 );
1699 } else {
1700 self.track_state(order.id(), OrderStatus::Open);
1701 }
1702
1703 // Convert back to generic type for return
1704 let generic_order = self.convert_from_unit_type(&unit_order_arc);
1705 Ok((Arc::new(generic_order), trade_result))
1706 } else {
1707 // The order was fully matched
1708 self.track_state(
1709 order.id(),
1710 OrderStatus::Filled {
1711 filled_quantity: original_qty,
1712 },
1713 );
1714 Ok((Arc::new(order), trade_result))
1715 }
1716 }
1717}