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