Skip to main content

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}