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};
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 fn total_quantity(&self) -> u64;
21
22 /// Sets the new quantity for an order, handling the logic for different types.
23 /// For iceberg orders, it adjusts the visible and hidden parts correctly.
24 fn set_quantity(&mut self, new_total_quantity: u64);
25}
26
27impl<T> OrderQuantity<T> for OrderType<T> {
28 fn quantity(&self) -> u64 {
29 match self {
30 OrderType::Standard { quantity, .. } => quantity.as_u64(),
31 OrderType::IcebergOrder {
32 visible_quantity, ..
33 } => visible_quantity.as_u64(),
34 OrderType::PostOnly { quantity, .. } => quantity.as_u64(),
35 OrderType::TrailingStop { quantity, .. } => quantity.as_u64(),
36 OrderType::PeggedOrder { quantity, .. } => quantity.as_u64(),
37 OrderType::MarketToLimit { quantity, .. } => quantity.as_u64(),
38 OrderType::ReserveOrder {
39 visible_quantity, ..
40 } => visible_quantity.as_u64(),
41 }
42 }
43
44 fn total_quantity(&self) -> u64 {
45 match self {
46 OrderType::Standard { quantity, .. } => quantity.as_u64(),
47 OrderType::IcebergOrder {
48 visible_quantity,
49 hidden_quantity,
50 ..
51 } => visible_quantity
52 .as_u64()
53 .saturating_add(hidden_quantity.as_u64()),
54 OrderType::PostOnly { quantity, .. } => quantity.as_u64(),
55 OrderType::TrailingStop { quantity, .. } => quantity.as_u64(),
56 OrderType::PeggedOrder { quantity, .. } => quantity.as_u64(),
57 OrderType::MarketToLimit { quantity, .. } => quantity.as_u64(),
58 OrderType::ReserveOrder {
59 visible_quantity,
60 hidden_quantity,
61 ..
62 } => visible_quantity
63 .as_u64()
64 .saturating_add(hidden_quantity.as_u64()),
65 }
66 }
67
68 fn set_quantity(&mut self, new_total_quantity: u64) {
69 match self {
70 OrderType::Standard { quantity, .. }
71 | OrderType::PostOnly { quantity, .. }
72 | OrderType::TrailingStop { quantity, .. }
73 | OrderType::PeggedOrder { quantity, .. }
74 | OrderType::MarketToLimit { quantity, .. } => {
75 *quantity = Quantity::new(new_total_quantity)
76 }
77
78 OrderType::IcebergOrder {
79 visible_quantity, ..
80 } => {
81 // For iceberg orders, treat new_total_quantity as the new visible quantity
82 // This matches the expected behavior where quantity() returns visible_quantity
83 *visible_quantity = Quantity::new(new_total_quantity);
84 // Hidden quantity remains unchanged
85 }
86 OrderType::ReserveOrder {
87 visible_quantity,
88 hidden_quantity,
89 replenish_amount,
90 ..
91 } => {
92 let original_total = visible_quantity
93 .as_u64()
94 .saturating_add(hidden_quantity.as_u64());
95 let amount_to_reduce = original_total.saturating_sub(new_total_quantity);
96
97 let vis = visible_quantity.as_u64();
98 let filled_from_visible = amount_to_reduce.min(vis);
99 *visible_quantity = Quantity::new(vis.saturating_sub(filled_from_visible));
100
101 let remaining_to_reduce = amount_to_reduce - filled_from_visible;
102 *hidden_quantity =
103 Quantity::new(hidden_quantity.as_u64().saturating_sub(remaining_to_reduce));
104
105 if visible_quantity.as_u64() == 0 && hidden_quantity.as_u64() > 0 {
106 let refresh = replenish_amount
107 .map(|q| q.get())
108 .unwrap_or(0)
109 .min(hidden_quantity.as_u64());
110 *visible_quantity = Quantity::new(refresh);
111 *hidden_quantity =
112 Quantity::new(hidden_quantity.as_u64().saturating_sub(refresh));
113 }
114 }
115 }
116 }
117}
118
119impl<T> OrderBook<T>
120where
121 T: Clone + Send + Sync + Default + 'static,
122{
123 /// Update an order's price and/or quantity
124 ///
125 /// # Errors
126 /// Returns [`OrderBookError::KillSwitchActive`] when the kill switch
127 /// is engaged and the update is anything other than
128 /// [`OrderUpdate::Cancel`]. Cancels are explicitly allowed so that
129 /// operators can drain resting orders while new flow is halted.
130 pub fn update_order(
131 &self,
132 update: OrderUpdate,
133 ) -> Result<Option<Arc<OrderType<T>>>, OrderBookError> {
134 // Gate non-cancel variants on the kill switch. Cancel passes
135 // through unchanged so operators can drain the book. The
136 // existing order stays live — only the modification is
137 // rejected — so we use `check_kill_switch` (no tracker
138 // recording) rather than `check_kill_switch_or_reject` (which
139 // would mark a live order as terminal-Rejected).
140 let is_modify = matches!(
141 &update,
142 OrderUpdate::UpdatePrice { .. }
143 | OrderUpdate::UpdateQuantity { .. }
144 | OrderUpdate::UpdatePriceAndQuantity { .. }
145 | OrderUpdate::Replace { .. }
146 );
147 if is_modify {
148 self.check_kill_switch()?;
149 }
150
151 self.cache.invalidate();
152 trace!("Order book {}: Updating order {:?}", self.symbol, update);
153 match update {
154 OrderUpdate::UpdatePrice {
155 order_id,
156 new_price,
157 } => {
158 // Get the order location without locking
159 let location = self.order_locations.get(&order_id).map(|val| *val);
160
161 if let Some((old_price, _)) = location {
162 // If price doesn't change, do nothing
163 if old_price == new_price.as_u128() {
164 return Err(OrderBookError::InvalidOperation {
165 message: "Cannot update price to the same value".to_string(),
166 });
167 }
168
169 // Get the original order without holding locks
170 let original_order = if let Some(order) = self.get_order(order_id) {
171 // Create a copy of the order
172 (*order).clone()
173 } else {
174 return Ok(None); // Order not found
175 };
176
177 // Create a new order with the updated price
178 let mut new_order = original_order.clone();
179
180 // Update the price based on order type
181 match &mut new_order {
182 OrderType::Standard { price, .. } => *price = new_price,
183 OrderType::IcebergOrder { price, .. } => *price = new_price,
184 OrderType::PostOnly { price, .. } => *price = new_price,
185 OrderType::TrailingStop { price, .. } => *price = new_price,
186 OrderType::PeggedOrder { price, .. } => *price = new_price,
187 OrderType::MarketToLimit { price, .. } => *price = new_price,
188 OrderType::ReserveOrder { price, .. } => *price = new_price,
189 }
190
191 // Validate-first atomic modify (#98): validate the new
192 // order's shape and run the modify-aware risk check
193 // *before* removing the original. On any rejection we
194 // return the typed error and the original order is
195 // never cancelled — no book mutation, no events, no
196 // trades. These checks are pure functions of the new
197 // order + the opposite book side, so evaluating them
198 // while the same-side original still rests yields the
199 // same verdict as after cancel.
200 self.validate_order_shape(&new_order)?;
201 self.check_risk_modify_admission(
202 order_id,
203 new_order.user_id(),
204 new_order.price().as_u128(),
205 new_order.total_quantity(),
206 )?;
207
208 // #168: reject a re-price that would self-cross the same
209 // user's opposite-side liquidity under CancelTaker/CancelBoth
210 // BEFORE cancelling the original, so the original survives.
211 self.check_modify_stp_self_cross(&new_order)?;
212
213 // Both checks passed: cancel the original and add the
214 // updated order. `add_order` re-runs its own checks;
215 // post-cancel the account count is restored so its risk
216 // check passes — consistent with the pre-guard.
217 self.cancel_order(order_id)?;
218 let result = self.add_order(new_order)?;
219 Ok(Some(result))
220 } else {
221 Ok(None) // Order not found
222 }
223 }
224
225 OrderUpdate::UpdateQuantity {
226 order_id,
227 new_quantity,
228 } => {
229 // Get order location without locking
230 let location = self.order_locations.get(&order_id).map(|val| *val);
231
232 if let Some((price, side)) = location {
233 // Get the appropriate price levels map
234 let price_levels = match side {
235 Side::Buy => &self.bids,
236 Side::Sell => &self.asks,
237 };
238
239 // Attempt to update the order within the price level
240 let mut result = None;
241 let mut is_empty = false;
242
243 // Get the price level and update it
244 if let Some(entry) = price_levels.get(&price) {
245 let price_level = entry.value();
246 let update = OrderUpdate::UpdateQuantity {
247 order_id,
248 new_quantity,
249 };
250
251 if let Ok(updated_order) = price_level.update_order(update)
252 && let Some(order) = updated_order
253 {
254 // notify price level changes
255 if let Some(ref listener) = self.price_level_changed_listener {
256 let engine_seq = self.next_engine_seq();
257 listener(PriceLevelChangedEvent {
258 side,
259 price: price_level.price(),
260 quantity: price_level.visible_quantity(),
261 engine_seq,
262 })
263 }
264 result = Some(Arc::new(self.convert_from_unit_type(&order)));
265 }
266
267 is_empty = price_level.order_count() == 0;
268 }
269
270 // If the price level is now empty, remove it
271 if is_empty {
272 price_levels.remove(&price);
273 self.order_locations.remove(&order_id);
274 self.untrack_order_by_id(&order_id);
275 }
276
277 self.cache.invalidate();
278 if is_empty {
279 // Refresh depth gauges now that a level was
280 // removed during the modification path.
281 self.record_depth_metric();
282 }
283 Ok(result)
284 } else {
285 Ok(None) // Order not found
286 }
287 }
288
289 OrderUpdate::UpdatePriceAndQuantity {
290 order_id,
291 new_price,
292 new_quantity,
293 } => {
294 // Get order location without locking
295 let location = self.order_locations.get(&order_id).map(|val| *val);
296
297 if location.is_some() {
298 // Get the original order without holding locks
299 let original_order = if let Some(order) = self.get_order(order_id) {
300 // Create a copy of the order
301 (*order).clone()
302 } else {
303 return Ok(None); // Order not found
304 };
305
306 // Create a new order with the updated price and quantity
307 let mut new_order = original_order.clone();
308
309 // Update the price based on order type
310 match &mut new_order {
311 OrderType::Standard { price, .. } => *price = new_price,
312 OrderType::IcebergOrder { price, .. } => *price = new_price,
313 OrderType::PostOnly { price, .. } => *price = new_price,
314 OrderType::TrailingStop { price, .. } => *price = new_price,
315 OrderType::PeggedOrder { price, .. } => *price = new_price,
316 OrderType::MarketToLimit { price, .. } => *price = new_price,
317 OrderType::ReserveOrder { price, .. } => *price = new_price,
318 }
319
320 // Update the quantity using the trait method
321 new_order.set_quantity(new_quantity.as_u64());
322
323 // Validate-first atomic modify (#98): validate the new
324 // order's shape and run the modify-aware risk check
325 // *before* removing the original. On any rejection the
326 // original order is never cancelled.
327 self.validate_order_shape(&new_order)?;
328 self.check_risk_modify_admission(
329 order_id,
330 new_order.user_id(),
331 new_order.price().as_u128(),
332 new_order.total_quantity(),
333 )?;
334
335 // #168: reject a re-price that would self-cross the same
336 // user's opposite-side liquidity under CancelTaker/CancelBoth
337 // BEFORE cancelling the original, so the original survives.
338 self.check_modify_stp_self_cross(&new_order)?;
339
340 // Both checks passed: cancel the original and add the
341 // updated order.
342 self.cancel_order(order_id)?;
343 let result = self.add_order(new_order)?;
344 Ok(Some(result))
345 } else {
346 Ok(None) // Order not found
347 }
348 }
349
350 OrderUpdate::Cancel { order_id } => {
351 // Get order location without locking
352 let location = self.order_locations.get(&order_id).map(|val| *val);
353
354 if let Some((price, side)) = location {
355 // Get the appropriate price levels map
356 let price_levels = match side {
357 Side::Buy => &self.bids,
358 Side::Sell => &self.asks,
359 };
360
361 // Attempt to cancel the order
362 let mut result = None;
363 let mut is_empty = false;
364
365 // Get the current order first
366 if let Some(current_order) = self.get_order(order_id) {
367 result = Some(current_order);
368
369 // Remove the order directly from the price level
370 if let Some(entry) = price_levels.get(&price) {
371 let price_level = entry.value();
372 let cancel_update = OrderUpdate::Cancel { order_id };
373 let result = price_level.update_order(cancel_update);
374 // notify price level changes
375 if let Some(ref listener) = self.price_level_changed_listener
376 && let Ok(updated_order) = result
377 && updated_order.is_some()
378 {
379 let engine_seq = self.next_engine_seq();
380 listener(PriceLevelChangedEvent {
381 side,
382 price: price_level.price(),
383 quantity: price_level.visible_quantity(),
384 engine_seq,
385 })
386 }
387 is_empty = price_level.order_count() == 0;
388 }
389
390 // Remove from order locations tracking
391 self.order_locations.remove(&order_id);
392 // Remove from user_orders index
393 self.untrack_order_by_id(&order_id);
394 }
395
396 // If price level is empty, remove it
397 if is_empty {
398 price_levels.remove(&price);
399 }
400
401 Ok(result)
402 } else {
403 Ok(None) // Order not found
404 }
405 }
406
407 OrderUpdate::Replace {
408 order_id,
409 price,
410 quantity,
411 side,
412 } => {
413 // Get the original order without holding locks
414 let original_opt = self.get_order(order_id);
415
416 if let Some(original) = original_opt {
417 // Create a new order by cloning and updating the original
418 let mut new_order = (*original).clone();
419
420 // Update the order fields based on order type
421 match &mut new_order {
422 OrderType::Standard {
423 id,
424 price: p,
425 quantity: q,
426 side: s,
427 ..
428 } => {
429 *id = order_id;
430 *p = price;
431 *q = quantity;
432 *s = side;
433 }
434 OrderType::IcebergOrder {
435 id,
436 price: p,
437 visible_quantity,
438 side: s,
439 ..
440 } => {
441 *id = order_id;
442 *p = price;
443 *visible_quantity = quantity;
444 *s = side;
445 }
446 OrderType::PostOnly {
447 id,
448 price: p,
449 quantity: q,
450 side: s,
451 ..
452 } => {
453 *id = order_id;
454 *p = price;
455 *q = quantity;
456 *s = side;
457 }
458 OrderType::TrailingStop {
459 id,
460 price: p,
461 quantity: q,
462 side: s,
463 ..
464 } => {
465 *id = order_id;
466 *p = price;
467 *q = quantity;
468 *s = side;
469 }
470 OrderType::PeggedOrder {
471 id,
472 price: p,
473 quantity: q,
474 side: s,
475 ..
476 } => {
477 *id = order_id;
478 *p = price;
479 *q = quantity;
480 *s = side;
481 }
482 OrderType::MarketToLimit {
483 id,
484 price: p,
485 quantity: q,
486 side: s,
487 ..
488 } => {
489 *id = order_id;
490 *p = price;
491 *q = quantity;
492 *s = side;
493 }
494 OrderType::ReserveOrder {
495 id,
496 price: p,
497 visible_quantity,
498 side: s,
499 ..
500 } => {
501 *id = order_id;
502 *p = price;
503 *visible_quantity = quantity;
504 *s = side;
505 }
506 }
507
508 // Validate-first atomic modify (#98): validate the new
509 // order's shape and run the modify-aware risk check
510 // *before* removing the original. On any rejection the
511 // original order is never cancelled — no book mutation,
512 // no events, no trades.
513 self.validate_order_shape(&new_order)?;
514 self.check_risk_modify_admission(
515 order_id,
516 new_order.user_id(),
517 new_order.price().as_u128(),
518 new_order.total_quantity(),
519 )?;
520
521 // #168: reject a re-price that would self-cross the same
522 // user's opposite-side liquidity under CancelTaker/CancelBoth
523 // BEFORE cancelling the original, so the original survives.
524 self.check_modify_stp_self_cross(&new_order)?;
525
526 // Both checks passed: cancel the original and add the
527 // new order.
528 self.cancel_order(order_id)?;
529 let result = self.add_order(new_order)?;
530 Ok(Some(result))
531 } else {
532 Ok(None) // Original order not found
533 }
534 }
535 }
536 }
537
538 /// Cancel an order by ID.
539 ///
540 /// Tracks the cancellation as `CancelReason::UserRequested` in the
541 /// order state tracker (if configured).
542 pub fn cancel_order(&self, order_id: Id) -> Result<Option<Arc<OrderType<T>>>, OrderBookError> {
543 self.cancel_order_with_reason(order_id, CancelReason::UserRequested)
544 }
545
546 /// Cancel an order by ID with an explicit cancellation reason.
547 ///
548 /// This is the internal implementation used by both `cancel_order`
549 /// and mass cancel operations to track the correct
550 /// [`CancelReason`] in the order state tracker.
551 pub(super) fn cancel_order_with_reason(
552 &self,
553 order_id: Id,
554 reason: CancelReason,
555 ) -> Result<Option<Arc<OrderType<T>>>, OrderBookError> {
556 self.cache.invalidate();
557 // First, we find the order's location (price and side) without locking
558 let location = self.order_locations.get(&order_id).map(|val| *val);
559
560 if let Some((price, side)) = location {
561 // Obtener el mapa de niveles de precio apropiado
562 let price_levels = match side {
563 Side::Buy => &self.bids,
564 Side::Sell => &self.asks,
565 };
566
567 // Create the update to cancel
568 let update = OrderUpdate::Cancel { order_id };
569
570 // Attempt to cancel the order from the price level
571 let mut result = None;
572 let mut empty_level = false;
573
574 if let Some(entry) = price_levels.get(&price) {
575 let price_level = entry.value();
576 // Try to cancel the order
577 if let Ok(cancelled) = price_level.update_order(update) {
578 result = cancelled;
579
580 // notify price level changes
581 if result.is_some()
582 && let Some(ref listener) = self.price_level_changed_listener
583 {
584 let engine_seq = self.next_engine_seq();
585 listener(PriceLevelChangedEvent {
586 side,
587 price: price_level.price(),
588 quantity: price_level.visible_quantity(),
589 engine_seq,
590 })
591 }
592
593 // Check if the level became empty
594 empty_level = price_level.order_count() == 0;
595 }
596 }
597
598 self.cache.invalidate();
599 // If we got a result and the order was canceled
600 if let Some(ref cancelled_order) = result {
601 // Track the cancellation in the order state tracker
602 let prev_filled = self
603 .order_state_tracker
604 .as_ref()
605 .and_then(|t| t.get(order_id))
606 .map(|s| s.filled_quantity())
607 .unwrap_or(0);
608 self.track_state(
609 order_id,
610 OrderStatus::Cancelled {
611 filled_quantity: prev_filled,
612 reason,
613 },
614 );
615
616 // Remove the order from the locations map
617 self.order_locations.remove(&order_id);
618
619 // Pre-trade risk hook: drop the per-account counter
620 // contribution before the order leaves the index. Does
621 // not depend on `cancelled_order` because the risk
622 // state already stores `account` and `remaining_qty`.
623 // No-op when no `RiskConfig` is installed.
624 self.risk_state.on_cancel(order_id);
625
626 // Remove the order from the user_orders index
627 self.untrack_user_order(cancelled_order.user_id(), &order_id);
628
629 // Unregister special orders from re-pricing tracking
630 #[cfg(feature = "special_orders")]
631 {
632 self.special_order_tracker
633 .unregister_pegged_order(&order_id);
634 self.special_order_tracker
635 .unregister_trailing_stop(&order_id);
636 }
637
638 // If the level became empty, remove it
639 if empty_level {
640 price_levels.remove(&price);
641 // Refresh the depth gauges now that a level was
642 // removed. No-op when the `metrics` feature is
643 // disabled.
644 self.record_depth_metric();
645 }
646 }
647
648 Ok(result.map(|order| Arc::new(self.convert_from_unit_type(&order))))
649 } else {
650 Ok(None)
651 }
652 }
653
654 /// Apply the side-effects of cancelling a single resting `order_id` that is
655 /// known to live on the already-held `price_level` (resting on `side`),
656 /// **without** removing the level from the bid/ask map.
657 ///
658 /// This mirrors the per-order effects of [`Self::cancel_order_with_reason`]
659 /// — level-change event, `Cancelled { reason }` state transition, per-account
660 /// risk release, `user_orders` / `order_locations` untrack, and special-order
661 /// deregistration — but it deliberately does **not** touch the bid/ask
662 /// `SkipMap`. The caller owns level removal (the matching loop drains
663 /// `empty_price_levels` after the walk), so this is safe to invoke mid-walk:
664 /// it never removes a level the iterator still references and never
665 /// re-resolves `order_locations`, so a sequence of cancels on the same held
666 /// level cannot skip a later order. Used by the STP `CancelMaker` /
667 /// `CancelBoth` arms (#95). No-op if `order_id` is not resting on the level.
668 pub(super) fn cancel_resting_maker_on_level(
669 &self,
670 price_level: &PriceLevel,
671 side: Side,
672 order_id: Id,
673 reason: CancelReason,
674 ) {
675 let Ok(Some(cancelled)) = price_level.update_order(OrderUpdate::Cancel { order_id }) else {
676 return;
677 };
678 self.cache.invalidate();
679
680 // 1. Notify the level change (same shape as cancel_order_with_reason).
681 if let Some(ref listener) = self.price_level_changed_listener {
682 let engine_seq = self.next_engine_seq();
683 listener(PriceLevelChangedEvent {
684 side,
685 price: price_level.price(),
686 quantity: price_level.visible_quantity(),
687 engine_seq,
688 });
689 }
690
691 // 2. Record the terminal cancellation, preserving any prior fill.
692 let prev_filled = self
693 .order_state_tracker
694 .as_ref()
695 .and_then(|t| t.get(order_id))
696 .map(|s| s.filled_quantity())
697 .unwrap_or(0);
698 self.track_state(
699 order_id,
700 OrderStatus::Cancelled {
701 filled_quantity: prev_filled,
702 reason,
703 },
704 );
705
706 // 3. Drop the per-account risk contribution, then untrack the order.
707 self.order_locations.remove(&order_id);
708 self.risk_state.on_cancel(order_id);
709 self.untrack_user_order(cancelled.user_id(), &order_id);
710
711 #[cfg(feature = "special_orders")]
712 {
713 self.special_order_tracker
714 .unregister_pegged_order(&order_id);
715 self.special_order_tracker
716 .unregister_trailing_stop(&order_id);
717 }
718 }
719
720 /// Validate the *shape* of an order against this book's admission
721 /// rules **without** mutating any book state.
722 ///
723 /// This is the single source of truth for the non-risk admission
724 /// checks that [`Self::add_order`] performs, in the same order and
725 /// returning the same typed [`OrderBookError`] variants. Unlike
726 /// `add_order` it is pure: it never calls
727 /// [`track_state`](Self::track_state), [`reject_with_risk`](Self::reject_with_risk),
728 /// emits metrics, or invalidates the cache. Every check here is a
729 /// function of the new order plus the *opposite* book side, so it
730 /// yields the same verdict whether evaluated before or after the
731 /// original (same-side) order has been cancelled — which is what
732 /// makes the validate-first atomic modify (#98) safe.
733 ///
734 /// Checks, in order:
735 /// 1. STP `MissingUserId` (when STP is enabled and `user_id` is zero).
736 /// 2. Tick size (`InvalidTickSize`).
737 /// 3. Lot size (`InvalidLotSize`, iceberg visible/hidden split).
738 /// 4. Min/max order size (`OrderSizeOutOfRange`).
739 /// 5. Expiry (`InvalidOperation` — already expired).
740 /// 6. Post-only would cross (`PriceCrossing`).
741 /// 7. FOK feasibility (`InsufficientLiquidity`).
742 ///
743 /// # Errors
744 /// Returns the first failing check's typed [`OrderBookError`].
745 pub(super) fn validate_order_shape(&self, order: &OrderType<T>) -> Result<(), OrderBookError> {
746 // STP user_id enforcement: when STP is enabled, all orders must carry
747 // a non-zero user_id so that self-trade checks can identify the owner.
748 if self.stp_mode != crate::orderbook::stp::STPMode::None
749 && order.user_id() == pricelevel::Hash32::zero()
750 {
751 return Err(OrderBookError::MissingUserId {
752 order_id: order.id(),
753 });
754 }
755
756 // Tick size validation: reject orders whose price is not a multiple of tick_size
757 if let Some(tick) = self.tick_size
758 && tick > 0
759 && !order.price().as_u128().is_multiple_of(tick)
760 {
761 return Err(OrderBookError::InvalidTickSize {
762 price: order.price().as_u128(),
763 tick_size: tick,
764 });
765 }
766
767 // Lot size validation: reject orders whose quantity is not a multiple of lot_size.
768 // For iceberg orders, validate visible and hidden quantities individually.
769 if let Some(lot) = self.lot_size
770 && lot > 0
771 {
772 match order {
773 OrderType::IcebergOrder {
774 visible_quantity,
775 hidden_quantity,
776 ..
777 } => {
778 if visible_quantity.as_u64() % lot != 0 {
779 return Err(OrderBookError::InvalidLotSize {
780 quantity: visible_quantity.as_u64(),
781 lot_size: lot,
782 });
783 }
784 if hidden_quantity.as_u64() % lot != 0 {
785 return Err(OrderBookError::InvalidLotSize {
786 quantity: hidden_quantity.as_u64(),
787 lot_size: lot,
788 });
789 }
790 }
791 _ => {
792 if order.total_quantity() % lot != 0 {
793 return Err(OrderBookError::InvalidLotSize {
794 quantity: order.total_quantity(),
795 lot_size: lot,
796 });
797 }
798 }
799 }
800 }
801
802 // Min/max order size validation
803 let qty = order.total_quantity();
804 if let Some(min) = self.min_order_size
805 && qty < min
806 {
807 return Err(OrderBookError::OrderSizeOutOfRange {
808 quantity: qty,
809 min: Some(min),
810 max: self.max_order_size,
811 });
812 }
813 if let Some(max) = self.max_order_size
814 && qty > max
815 {
816 return Err(OrderBookError::OrderSizeOutOfRange {
817 quantity: qty,
818 min: self.min_order_size,
819 max: Some(max),
820 });
821 }
822
823 if self.has_expired(order) {
824 return Err(OrderBookError::InvalidOperation {
825 message: "Order has already expired".to_string(),
826 });
827 }
828
829 if order.is_post_only() && self.will_cross_market(order.price().as_u128(), order.side()) {
830 return Err(OrderBookError::PriceCrossing {
831 price: order.price().as_u128(),
832 side: order.side(),
833 opposite_price: if order.side() == Side::Buy {
834 self.best_ask().unwrap_or(0)
835 } else {
836 self.best_bid().unwrap_or(0)
837 },
838 });
839 }
840
841 // For FOK orders, first check if the entire quantity can be matched
842 // without altering the book. Use the faithful feasibility check (lot_size
843 // + STP aware), not the raw-depth `peek_match`, so fill-or-kill stays
844 // all-or-nothing and never emits a partial fill it then reports as killed (#96).
845 if order.is_fill_or_kill() {
846 let potential_match = self.fok_fillable_quantity(
847 order.side(),
848 order.total_quantity(),
849 Some(order.price().as_u128()),
850 order.user_id(),
851 );
852 if potential_match < order.total_quantity() {
853 return Err(OrderBookError::InsufficientLiquidity {
854 side: order.side(),
855 requested: order.total_quantity(),
856 available: potential_match,
857 });
858 }
859 }
860
861 Ok(())
862 }
863
864 /// STP self-cross pre-check for the validate-first atomic modify (#168).
865 ///
866 /// Closes the one post-match modify-atomicity gap #98 left open. Under
867 /// [`STPMode::CancelTaker`](crate::orderbook::stp::STPMode::CancelTaker) /
868 /// [`CancelBoth`](crate::orderbook::stp::STPMode::CancelBoth), if a
869 /// re-priced order would cross into the **same user's** resting liquidity on
870 /// the opposite side, `add_order` matches post-cancel and cancels the taker
871 /// (the re-added order) — *after* the original was already removed,
872 /// destroying it. This dry-runs the crossable opposite side and, if the
873 /// sweep would reach a same-user maker while the taker still has unfilled
874 /// quantity (the exact condition under which the engine sets
875 /// `stp_taker_cancelled`), returns [`OrderBookError::SelfTradePrevented`]
876 /// **before** the original is cancelled, so it survives unchanged.
877 ///
878 /// No-op when STP is off, the taker is anonymous, or the mode is
879 /// [`CancelMaker`](crate::orderbook::stp::STPMode::CancelMaker) (which
880 /// cancels the maker and rests the taker — it never destroys the re-added
881 /// order). Like the other validate-first checks (#98) it is a pure function
882 /// of the new order plus the *opposite* book side, so evaluating it while
883 /// the same-side original still rests yields the same verdict as after
884 /// cancel.
885 pub(super) fn check_modify_stp_self_cross(
886 &self,
887 new_order: &OrderType<T>,
888 ) -> Result<(), OrderBookError> {
889 use crate::orderbook::stp::STPMode;
890
891 let taker_user_id = new_order.user_id();
892 // Only CancelTaker / CancelBoth cancel the taker; None / CancelMaker
893 // rest it, so the re-added order is never destroyed.
894 match self.stp_mode {
895 STPMode::CancelTaker | STPMode::CancelBoth => {}
896 _ => return Ok(()),
897 }
898 if taker_user_id == pricelevel::Hash32::zero() {
899 return Ok(());
900 }
901
902 let side = new_order.side();
903 let new_price = new_order.price().as_u128();
904 let opposite = match side {
905 Side::Buy => &self.asks,
906 Side::Sell => &self.bids,
907 };
908 // Walk the crossable opposite side in price-time priority — asks
909 // ascending for a Buy, bids descending for a Sell — exactly the sweep's
910 // visit order.
911 let iter = match side {
912 Side::Buy => Either::Left(opposite.iter()),
913 Side::Sell => Either::Right(opposite.iter().rev()),
914 };
915
916 let mut remaining = new_order.total_quantity();
917 for entry in iter {
918 if remaining == 0 {
919 // The taker fully fills against non-self depth before reaching
920 // any same-user maker → the engine never cancels it.
921 return Ok(());
922 }
923 let price = *entry.key();
924 let crosses = match side {
925 Side::Buy => new_price >= price,
926 Side::Sell => new_price <= price,
927 };
928 if !crosses {
929 // Price-sorted levels: no further level can cross.
930 break;
931 }
932 let level = entry.value();
933 if level.iter_orders().any(|o| o.user_id() == taker_user_id) {
934 // The sweep reaches a level holding a same-user maker while the
935 // taker still has unfilled quantity: the engine would cancel the
936 // taker here. Reject the modify before the original is cancelled.
937 return Err(OrderBookError::SelfTradePrevented {
938 mode: self.stp_mode,
939 taker_order_id: new_order.id(),
940 user_id: taker_user_id,
941 });
942 }
943 // No same-user maker at this level: the taker consumes its full
944 // matchable depth (the authoritative upstream dry run), then walks on.
945 remaining = remaining.saturating_sub(level.matchable_quantity(remaining));
946 }
947 Ok(())
948 }
949
950 /// Record the terminal state transition (and metric) that the direct
951 /// [`Self::add_order`] path historically emitted for each shape
952 /// rejection returned by [`Self::validate_order_shape`].
953 ///
954 /// Keeping this mapping next to the validator preserves the exact
955 /// pre-#98 reject side-effects of `add_order` while letting the
956 /// validate-first modify path reuse the same pure validator without
957 /// recording any state. Errors that previously had no side-effect
958 /// (e.g. the already-expired `InvalidOperation`) are intentionally
959 /// no-ops here.
960 fn record_shape_rejection(&self, order: &OrderType<T>, err: &OrderBookError) {
961 match err {
962 OrderBookError::MissingUserId { .. } => {
963 self.track_state(
964 order.id(),
965 OrderStatus::Rejected {
966 reason: RejectReason::MissingUserId,
967 },
968 );
969 }
970 OrderBookError::InvalidTickSize { .. } => {
971 self.track_state(
972 order.id(),
973 OrderStatus::Rejected {
974 reason: RejectReason::InvalidPrice,
975 },
976 );
977 }
978 OrderBookError::InvalidLotSize { .. } => {
979 self.track_state(
980 order.id(),
981 OrderStatus::Rejected {
982 reason: RejectReason::InvalidQuantity,
983 },
984 );
985 }
986 OrderBookError::OrderSizeOutOfRange { .. } => {
987 self.track_state(
988 order.id(),
989 OrderStatus::Rejected {
990 reason: RejectReason::OrderSizeOutOfRange,
991 },
992 );
993 }
994 OrderBookError::PriceCrossing { .. } => {
995 self.track_state(
996 order.id(),
997 OrderStatus::Rejected {
998 reason: RejectReason::PostOnlyWouldCross,
999 },
1000 );
1001 }
1002 OrderBookError::InsufficientLiquidity { .. } => {
1003 self.track_state(
1004 order.id(),
1005 OrderStatus::Cancelled {
1006 filled_quantity: 0,
1007 reason: CancelReason::InsufficientLiquidity,
1008 },
1009 );
1010 crate::orderbook::metrics::record_reject(RejectReason::InsufficientLiquidity);
1011 }
1012 // The already-expired `InvalidOperation` path historically
1013 // recorded no terminal transition; preserve that.
1014 _ => {}
1015 }
1016 }
1017
1018 /// Add a new order to the book, automatically matching it if it's aggressive.
1019 ///
1020 /// This convenience method calls the same implementation as
1021 /// [`Self::add_order_with_result`] but discards the trade result. When no
1022 /// trade listener is installed, the `TradeResult` is never constructed, so
1023 /// this path stays free of the extra `MatchResult` clone.
1024 ///
1025 /// # Errors
1026 /// Returns [`OrderBookError::KillSwitchActive`] when the kill switch
1027 /// is engaged. The check runs before any cache invalidation, STP
1028 /// validation, tick/lot validation, or matching work.
1029 #[inline]
1030 pub fn add_order(&self, order: OrderType<T>) -> Result<Arc<OrderType<T>>, OrderBookError> {
1031 self.add_order_inner(order, false).map(|(order, _)| order)
1032 }
1033
1034 /// Add a new order to the book, automatically matching it if it's
1035 /// aggressive, and additionally return the [`TradeResult`] produced by the
1036 /// match directly to the caller.
1037 ///
1038 /// The trade result is `None` when the order produced no fills (it rested
1039 /// on the book, or was admitted without matching). When a trade listener
1040 /// is installed, the listener is invoked with the exact same `TradeResult`
1041 /// that is returned here — same fills, same fees, same `engine_seq`.
1042 ///
1043 /// On error paths that follow real fills (an unfillable IOC remainder, or
1044 /// a self-trade-prevention cancellation after earlier non-self fills) the
1045 /// typed error is returned instead, so those fills reach the trade
1046 /// listener only.
1047 ///
1048 /// Every trade-producing call consumes one `engine_seq` tick, even when no
1049 /// trade listener is installed (plain [`Self::add_order`] only consumes one
1050 /// when a listener is present). `engine_seq` is per-instance and not
1051 /// replay-reproducible; consumers that need a stable ordering key should
1052 /// use the journal's `sequence_num` / `timestamp_ns` instead.
1053 ///
1054 /// # Errors
1055 /// Returns [`OrderBookError::KillSwitchActive`] when the kill switch
1056 /// is engaged. The check runs before any cache invalidation, STP
1057 /// validation, tick/lot validation, or matching work.
1058 pub fn add_order_with_result(
1059 &self,
1060 order: OrderType<T>,
1061 ) -> Result<(Arc<OrderType<T>>, Option<TradeResult>), OrderBookError> {
1062 self.add_order_inner(order, true)
1063 }
1064
1065 /// Shared implementation behind [`Self::add_order`] and
1066 /// [`Self::add_order_with_result`]. `want_result` gates `TradeResult`
1067 /// construction so the plain `add_order` path only pays for it when an
1068 /// installed trade listener needs it anyway.
1069 fn add_order_inner(
1070 &self,
1071 mut order: OrderType<T>,
1072 want_result: bool,
1073 ) -> Result<(Arc<OrderType<T>>, Option<TradeResult>), OrderBookError> {
1074 self.check_kill_switch_or_reject(order.id())?;
1075 // Pre-trade risk gate: per-account open-orders / notional /
1076 // price band. No-op when no `RiskConfig` is installed.
1077 // Documented order: kill_switch → risk → STP → fees → match.
1078 // On the cold reject path, record an `OrderStatus::Rejected`
1079 // transition with the closed `RejectReason` taxonomy before
1080 // propagating the typed error.
1081 if let Err(err) = self.check_risk_limit_admission(
1082 order.user_id(),
1083 order.price().as_u128(),
1084 order.total_quantity(),
1085 ) {
1086 self.reject_with_risk(order.id(), &err);
1087 return Err(err);
1088 }
1089
1090 // Reject a duplicate order id: an order with this id is already
1091 // resting on the book. Admitting it would overwrite the existing
1092 // order's entry in `order_locations` and orphan the live order (it
1093 // could no longer be cancelled or modified by id). This is an
1094 // `add_order`-specific structural check and deliberately does NOT
1095 // live in `validate_order_shape`: the validate-first atomic modify
1096 // (#98) runs that shared validator while the original, same-id
1097 // order is still resting, so a check there would false-reject every
1098 // modify. We also do NOT record an `OrderStatus::Rejected`
1099 // transition — the id belongs to a different, still-live order
1100 // whose tracked state must not be clobbered. The metric plus the
1101 // typed error (which the wire layer maps to
1102 // `RejectReason::DuplicateOrderId`) are sufficient.
1103 //
1104 // This is a sequential guard, not a concurrency guard: the check
1105 // and the eventual `order_locations.insert` straddle the match
1106 // walk, so two concurrent `add_order` calls with the same *fresh*
1107 // id can both pass here and both rest (last-writer-wins on insert).
1108 // Serializing order ids is the ingress / sequencing layer's job.
1109 if self.order_locations.contains_key(&order.id()) {
1110 crate::orderbook::metrics::record_reject(RejectReason::DuplicateOrderId);
1111 return Err(OrderBookError::DuplicateOrderId {
1112 order_id: order.id(),
1113 });
1114 }
1115
1116 trace!(
1117 "Order book {}: Adding order {} at price {}",
1118 self.symbol,
1119 order.id(),
1120 order.price()
1121 );
1122
1123 // Non-risk admission checks are owned by `validate_order_shape`
1124 // (the single source of truth shared with the validate-first
1125 // atomic modify path, #98). On the cold reject path we still
1126 // record the matching terminal state transition / metric here so
1127 // the direct (non-modify) `add_order` behavior is preserved
1128 // exactly.
1129 if let Err(err) = self.validate_order_shape(&order) {
1130 self.record_shape_rejection(&order, &err);
1131 return Err(err);
1132 }
1133
1134 self.cache.invalidate();
1135 // Attempt to match the order immediately (with STP user_id propagation).
1136 // The outcome also carries whether STP cancelled the taker (#97).
1137 let MatchOutcome {
1138 result: match_result,
1139 taker_stp_cancelled,
1140 } = self.match_order_with_user_outcome(
1141 order.id(),
1142 order.side(),
1143 order.total_quantity(), // Use total quantity for matching
1144 Some(order.price().as_u128()),
1145 order.user_id(),
1146 )?;
1147
1148 // Emit trades BEFORE any early return below: the STP taker-cancel and
1149 // unfillable-IOC paths return `Err` after real (non-self) fills already
1150 // executed, and those fills must still reach the metrics and the trade
1151 // listener. The `TradeResult` is only constructed when someone consumes
1152 // it — the installed listener and/or an `add_order_with_result` caller —
1153 // so the plain `add_order` hot path skips the `MatchResult` clone.
1154 let trades_emitted = match_result.trades().len() as u64;
1155 let trade_result = if trades_emitted > 0 {
1156 crate::orderbook::metrics::record_trades(trades_emitted);
1157 let listener = self.trade_listener.as_ref();
1158 if want_result || listener.is_some() {
1159 let mut trade_result = TradeResult::with_fees(
1160 self.symbol.clone(),
1161 match_result.clone(),
1162 self.fee_schedule,
1163 );
1164 trade_result.engine_seq = self.next_engine_seq();
1165 if let Some(listener) = listener {
1166 listener(&trade_result) // emit trade events to listener
1167 }
1168 Some(trade_result)
1169 } else {
1170 None
1171 }
1172 } else {
1173 None
1174 };
1175
1176 // True (non-self) executed quantity. `remaining_quantity` only decrements on
1177 // real trades, so STP-prevented self-fills never count toward it.
1178 let original_qty = order.total_quantity();
1179 let filled_qty = original_qty.saturating_sub(match_result.remaining_quantity().as_u64());
1180
1181 // If STP cancelled the taker, the residual must NOT rest — even though some
1182 // non-self fills already occurred at earlier levels. Record the terminal
1183 // SelfTradePrevention state with the true filled quantity and surface the STP
1184 // error (#97). The zero-fills case already returned this error from the match.
1185 if taker_stp_cancelled {
1186 self.track_state(
1187 order.id(),
1188 OrderStatus::Cancelled {
1189 filled_quantity: filled_qty,
1190 reason: CancelReason::SelfTradePrevention,
1191 },
1192 );
1193 crate::orderbook::metrics::record_reject(RejectReason::SelfTradePrevention);
1194 return Err(OrderBookError::SelfTradePrevented {
1195 mode: self.stp_mode,
1196 taker_order_id: order.id(),
1197 user_id: order.user_id(),
1198 });
1199 }
1200
1201 // If the order was not fully filled, add the remainder to the book
1202 if match_result.remaining_quantity().as_u64() > 0 {
1203 if order.is_immediate() {
1204 // IOC/FOK orders should not have a resting part.
1205 // If FOK, it should have been fully filled or cancelled before this point.
1206 // If IOC, this is the remaining part that couldn't be filled, so we just drop it.
1207 self.track_state(
1208 order.id(),
1209 OrderStatus::Cancelled {
1210 filled_quantity: filled_qty,
1211 reason: CancelReason::InsufficientLiquidity,
1212 },
1213 );
1214 crate::orderbook::metrics::record_reject(RejectReason::InsufficientLiquidity);
1215 return Err(OrderBookError::InsufficientLiquidity {
1216 side: order.side(),
1217 requested: order.quantity(), // Now uses the trait method
1218 available: order
1219 .quantity()
1220 .saturating_sub(match_result.remaining_quantity().as_u64()),
1221 });
1222 }
1223
1224 // Update the order with the remaining quantity
1225 // For iceberg orders, only update if there was actual matching (remaining < total)
1226 if match_result.remaining_quantity().as_u64() < order.total_quantity() {
1227 order.set_quantity(match_result.remaining_quantity().as_u64()); // Now uses the trait method
1228 }
1229
1230 let price = order.price().as_u128();
1231 let side = order.side();
1232
1233 let price_levels = match side {
1234 Side::Buy => &self.bids,
1235 Side::Sell => &self.asks,
1236 };
1237
1238 let price_level = price_levels.get_or_insert(price, Arc::new(PriceLevel::new(price)));
1239 let level = price_level.value();
1240
1241 // Convert to unit type for PriceLevel compatibility
1242 let unit_order = self.convert_to_unit_type(&order);
1243 let unit_order_arc = price_level.value().add_order(unit_order);
1244 // notify price level changes
1245 if let Some(ref listener) = self.price_level_changed_listener {
1246 let engine_seq = self.next_engine_seq();
1247 listener(PriceLevelChangedEvent {
1248 side,
1249 price: level.price(),
1250 quantity: level.visible_quantity(),
1251 engine_seq,
1252 })
1253 }
1254 self.order_locations
1255 .insert(unit_order_arc.id(), (price, side));
1256
1257 // Refresh the depth gauges. The level may be brand-new
1258 // (`get_or_insert` created it) or pre-existing — either
1259 // way the gauge reflects current state. No-op when the
1260 // `metrics` feature is disabled.
1261 self.record_depth_metric();
1262
1263 // Pre-trade risk hook: register the resting order with
1264 // the risk state so per-account counters are updated and
1265 // future checks see the new contribution. No-op when no
1266 // `RiskConfig` is installed.
1267 self.risk_state.on_admission(
1268 unit_order_arc.id(),
1269 order.user_id(),
1270 price,
1271 match_result.remaining_quantity().as_u64(),
1272 );
1273
1274 // Track the order in the user_orders index
1275 self.track_user_order(order.user_id(), unit_order_arc.id());
1276
1277 // Register special orders for re-pricing tracking
1278 #[cfg(feature = "special_orders")]
1279 match &order {
1280 OrderType::PeggedOrder { id, .. } => {
1281 self.special_order_tracker.register_pegged_order(*id);
1282 }
1283 OrderType::TrailingStop { id, .. } => {
1284 self.special_order_tracker.register_trailing_stop(*id);
1285 }
1286 _ => {}
1287 }
1288
1289 // Track state: Open (no fills) or PartiallyFilled (some fills, resting)
1290 if filled_qty > 0 {
1291 self.track_state(
1292 order.id(),
1293 OrderStatus::PartiallyFilled {
1294 original_quantity: original_qty,
1295 filled_quantity: filled_qty,
1296 },
1297 );
1298 } else {
1299 self.track_state(order.id(), OrderStatus::Open);
1300 }
1301
1302 // Convert back to generic type for return
1303 let generic_order = self.convert_from_unit_type(&unit_order_arc);
1304 Ok((Arc::new(generic_order), trade_result))
1305 } else {
1306 // The order was fully matched
1307 self.track_state(
1308 order.id(),
1309 OrderStatus::Filled {
1310 filled_quantity: original_qty,
1311 },
1312 );
1313 Ok((Arc::new(order), trade_result))
1314 }
1315 }
1316}