Skip to main content

nautilus_execution/
matching_core.rs

1// -------------------------------------------------------------------------------------------------
2//  Copyright (C) 2015-2026 Nautech Systems Pty Ltd. All rights reserved.
3//  https://nautechsystems.io
4//
5//  Licensed under the GNU Lesser General Public License Version 3.0 (the "License");
6//  You may not use this file except in compliance with the License.
7//  You may obtain a copy of the License at https://www.gnu.org/licenses/lgpl-3.0.en.html
8//
9//  Unless required by applicable law or agreed to in writing, software
10//  distributed under the License is distributed on an "AS IS" BASIS,
11//  WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12//  See the License for the specific language governing permissions and
13//  limitations under the License.
14// -------------------------------------------------------------------------------------------------
15
16//! Order matching core shared by the `OrderMatchingEngine` and other components.
17//!
18//! # Book layout
19//!
20//! Each side has two separate books, mirroring real-venue architecture:
21//! - **Limit book**: `BTreeMap<Price, OrderBucket>` keyed by limit price.
22//!   Holds plain `LIMIT` orders.
23//! - **Stop book**: `BTreeMap<Price, OrderBucket>` keyed by trigger price.
24//!   Holds `STOP_*`, `*_IF_TOUCHED`, and `TRAILING_STOP_*` orders that need
25//!   trigger checking before matching.
26//!
27//! Plus a per-side pending `SmallVec` for orders without a key (e.g.
28//! `MARKET_TO_LIMIT` before conversion).
29//!
30//! # Ordering invariant
31//!
32//! Orders are matched in **price-time priority**, with limits processed
33//! before stops on each side:
34//! - **Bid limits**: best (highest) price first via `iter().rev()`.
35//! - **Ask limits**: best (lowest) price first via `iter()`.
36//! - **Bid stops**: closest trigger first via `iter()` (lowest trigger crosses
37//!   first as ask climbs through resting buy stops).
38//! - **Ask stops**: closest trigger first via `iter().rev()` (highest trigger
39//!   crosses first as bid drops through resting sell stops).
40//!
41//! Within a price level orders are stored in a `SmallVec` in insertion order,
42//! preserving time priority (FIFO at the same price). No active sorting
43//! happens; the `BTreeMap`'s tree shape gives price ordering for free.
44//!
45//! # Modify semantics
46//!
47//! The core does not expose an in-place modify API. Any change to a resting
48//! order must call [`OrderMatchingCore::delete_order`] followed by
49//! [`OrderMatchingCore::add_order`], which lands the order at the back of
50//! its (new or unchanged) price level. This matches real-venue behavior for
51//! price-changing modifies but loses queue position on quantity-only
52//! modifies. An in-place quantity-update API could be added later if the
53//! engine wants to preserve queue position on those.
54//!
55//! # Known limitation: limits-then-stops emission
56//!
57//! On each side, [`OrderMatchingCore::iterate_bids`] and
58//! [`OrderMatchingCore::iterate_asks`] emit all matchable limits before any
59//! triggered stops. In real venues stops trigger as the price crosses them
60//! and only then aggress against the limit book, so a snapshot iteration that
61//! sees both kinds matchable simultaneously cannot perfectly reconstruct the
62//! temporal order. The matching engine drives the snapshot, so a future
63//! engine change that feeds the previous bid/ask to the core could replay
64//! the price path and emit triggers/fills in cross-time order. Until then,
65//! callers that depend on price-path ordering (e.g. multi-level gap
66//! scenarios with both matchable limits and matchable stops on the same
67//! side) should treat that interleaving as undefined.
68//!
69//! # Duplicate inserts
70//!
71//! `add_order` does not deduplicate. Adding the same `client_order_id` twice
72//! without an intervening `delete_order` puts two `RestingOrder` entries in
73//! the vec and they will both match. Callers must ensure each
74//! `client_order_id` appears at most once across both sides.
75//!
76//! # Performance
77//!
78//! Per-level buckets are `SmallVec`s with [`INLINE_ORDERS_PER_LEVEL`] inline
79//! slots so the common case (1-3 orders per price) avoids heap allocation
80//! per bucket. Above that threshold the bucket spills to the heap. Adds and
81//! deletes are O(log L) for the `BTreeMap` lookup plus O(B) for the bucket
82//! scan/shift, where L is the number of distinct price levels per book and
83//! B is orders at that level: both small in practice.
84//!
85//! An `AHashMap` index from `ClientOrderId` to `(side, BookKind, Price)`
86//! makes [`OrderMatchingCore::get_order`], [`OrderMatchingCore::order_exists`],
87//! and the lookup portion of [`OrderMatchingCore::delete_order`] hash-fast;
88//! the follow-on bucket scan is O(B). The map is used purely for point
89//! queries (never iterated), so its randomized seed does not affect
90//! determinism.
91
92use std::collections::BTreeMap;
93
94use ahash::AHashMap;
95use nautilus_model::{
96    enums::{OrderSide, OrderType, TriggerType},
97    identifiers::{ClientOrderId, InstrumentId},
98    orders::{Order, OrderError, PassiveOrderAny, StopOrderAny},
99    types::Price,
100};
101use smallvec::SmallVec;
102
103/// Inline capacity for orders at a single price level. Sized to cover the
104/// typical 1-3 orders per level; above this the per-bucket `SmallVec` spills
105/// to the heap.
106pub const INLINE_ORDERS_PER_LEVEL: usize = 4;
107
108type OrderBucket = SmallVec<[RestingOrder; INLINE_ORDERS_PER_LEVEL]>;
109
110/// Identifies which per-side book a [`RestingOrder`] lives in.
111#[derive(Debug, Clone, Copy, PartialEq, Eq)]
112enum BookKind {
113    /// Plain `LIMIT` order in the limit book, keyed by limit price.
114    Limit,
115    /// Stop-style order in the stop book, keyed by trigger price. Includes
116    /// `STOP_*`, `*_IF_TOUCHED`, and `TRAILING_STOP_*` order types.
117    Stop,
118}
119
120/// An action returned by [`OrderMatchingCore::iterate`] when an order matches.
121#[derive(Debug, Clone, Copy, PartialEq, Eq)]
122pub enum MatchAction {
123    FillLimit(ClientOrderId),
124    TriggerStop(ClientOrderId),
125}
126
127/// Lightweight order information for matching/trigger checking.
128#[derive(Clone, Copy, Debug, PartialEq, Eq)]
129pub struct RestingOrder {
130    pub client_order_id: ClientOrderId,
131    pub order_side: OrderSide,
132    pub order_type: OrderType,
133    pub trigger_type: Option<TriggerType>,
134    pub trigger_price: Option<Price>,
135    pub limit_price: Option<Price>,
136    pub is_activated: bool,
137}
138
139impl RestingOrder {
140    /// Creates a new [`RestingOrder`] instance.
141    ///
142    /// `MARKET_TO_LIMIT` orders may legitimately be constructed with both
143    /// `trigger_price` and `limit_price` set to `None` until they convert to
144    /// a limit at execution time; [`OrderMatchingCore::match_order`] returns
145    /// `None` for such orders. This is a known coverage gap and not a bug
146    /// in the constructor.
147    #[must_use]
148    pub const fn new(
149        client_order_id: ClientOrderId,
150        order_side: OrderSide,
151        order_type: OrderType,
152        trigger_price: Option<Price>,
153        limit_price: Option<Price>,
154        is_activated: bool,
155    ) -> Self {
156        Self::new_with_trigger_type(
157            client_order_id,
158            order_side,
159            order_type,
160            match trigger_price {
161                Some(_) => Some(TriggerType::Default),
162                None => None,
163            },
164            trigger_price,
165            limit_price,
166            is_activated,
167        )
168    }
169
170    #[must_use]
171    pub(crate) const fn new_with_trigger_type(
172        client_order_id: ClientOrderId,
173        order_side: OrderSide,
174        order_type: OrderType,
175        trigger_type: Option<TriggerType>,
176        trigger_price: Option<Price>,
177        limit_price: Option<Price>,
178        is_activated: bool,
179    ) -> Self {
180        Self {
181            client_order_id,
182            order_side,
183            order_type,
184            trigger_type,
185            trigger_price,
186            limit_price,
187            is_activated,
188        }
189    }
190
191    /// Returns true if this is a stop order type that needs trigger checking.
192    #[must_use]
193    pub const fn is_stop(&self) -> bool {
194        self.trigger_price.is_some()
195    }
196
197    /// Returns true if this is a limit order type that needs fill checking.
198    #[must_use]
199    pub const fn is_limit(&self) -> bool {
200        self.limit_price.is_some() && self.trigger_price.is_none()
201    }
202}
203
204impl From<&PassiveOrderAny> for RestingOrder {
205    fn from(order: &PassiveOrderAny) -> Self {
206        match order {
207            PassiveOrderAny::Limit(limit) => Self {
208                client_order_id: limit.client_order_id(),
209                order_side: limit.order_side(),
210                order_type: limit.order_type(),
211                trigger_type: None,
212                trigger_price: None,
213                limit_price: Some(limit.limit_px()),
214                is_activated: true,
215            },
216            PassiveOrderAny::Stop(stop) => {
217                let limit_price = match stop {
218                    StopOrderAny::LimitIfTouched(o) => Some(o.price),
219                    StopOrderAny::StopLimit(o) => Some(o.price),
220                    StopOrderAny::TrailingStopLimit(o) => o.price,
221                    StopOrderAny::MarketIfTouched(_)
222                    | StopOrderAny::StopMarket(_)
223                    | StopOrderAny::TrailingStopMarket(_) => None,
224                };
225                let is_activated = match stop {
226                    StopOrderAny::TrailingStopMarket(o) => o.is_activated,
227                    StopOrderAny::TrailingStopLimit(o) => o.is_activated,
228                    _ => true,
229                };
230                Self {
231                    client_order_id: stop.client_order_id(),
232                    order_side: stop.order_side(),
233                    order_type: stop.order_type(),
234                    trigger_type: Some(stop.trigger_type().unwrap_or(TriggerType::Default)),
235                    trigger_price: stop.stop_px(),
236                    limit_price,
237                    is_activated,
238                }
239            }
240        }
241    }
242}
243
244/// A generic order matching core. See module docs for ordering, modify,
245/// duplicate, and performance contracts.
246#[derive(Clone, Debug)]
247pub struct OrderMatchingCore {
248    /// The instrument ID for the matching core.
249    pub instrument_id: InstrumentId,
250    /// The price increment for the matching core.
251    pub price_increment: Price,
252    /// The current bid price for the matching core.
253    pub bid: Option<Price>,
254    /// The current ask price for the matching core.
255    pub ask: Option<Price>,
256    /// The last price for the matching core.
257    pub last: Option<Price>,
258    fill_limit_inside_spread: bool,
259    bid_limits: BTreeMap<Price, OrderBucket>,
260    ask_limits: BTreeMap<Price, OrderBucket>,
261    bid_stops: BTreeMap<Price, OrderBucket>,
262    ask_stops: BTreeMap<Price, OrderBucket>,
263    pending_bid: SmallVec<[RestingOrder; 2]>,
264    pending_ask: SmallVec<[RestingOrder; 2]>,
265    order_index: AHashMap<ClientOrderId, (OrderSide, Option<(BookKind, Price)>)>,
266}
267
268impl OrderMatchingCore {
269    /// Creates a new [`OrderMatchingCore`] for the given instrument.
270    #[must_use]
271    pub fn new(instrument_id: InstrumentId, price_increment: Price) -> Self {
272        Self {
273            instrument_id,
274            price_increment,
275            bid: None,
276            ask: None,
277            last: None,
278            fill_limit_inside_spread: false,
279            bid_limits: BTreeMap::new(),
280            ask_limits: BTreeMap::new(),
281            bid_stops: BTreeMap::new(),
282            ask_stops: BTreeMap::new(),
283            pending_bid: SmallVec::new(),
284            pending_ask: SmallVec::new(),
285            order_index: AHashMap::new(),
286        }
287    }
288
289    /// Returns the price precision of the instrument's tick size.
290    #[must_use]
291    pub const fn price_precision(&self) -> u8 {
292        self.price_increment.precision
293    }
294
295    /// Returns the order with the given `client_order_id`, searching both sides.
296    #[must_use]
297    pub fn get_order(&self, client_order_id: ClientOrderId) -> Option<&RestingOrder> {
298        let (side, location) = self.order_index.get(&client_order_id).copied()?;
299        if let Some((kind, price)) = location {
300            self.book_for(side, kind)
301                .get(&price)?
302                .iter()
303                .find(|o| o.client_order_id == client_order_id)
304        } else {
305            self.pending_for(side)
306                .iter()
307                .find(|o| o.client_order_id == client_order_id)
308        }
309    }
310
311    /// Iterates the bid-side orders in price-time priority without
312    /// allocating: limits best (highest) first, then stops nearest-trigger
313    /// (lowest) first, then pending unkeyed orders. Borrowed view; for an
314    /// owned snapshot use [`Self::get_orders_bid`].
315    pub fn iter_bid_orders(&self) -> impl Iterator<Item = &RestingOrder> {
316        self.bid_limits
317            .values()
318            .rev()
319            .flat_map(|b| b.iter())
320            .chain(self.bid_stops.values().flat_map(|b| b.iter()))
321            .chain(self.pending_bid.iter())
322    }
323
324    /// Iterates the ask-side orders in price-time priority without
325    /// allocating: limits best (lowest) first, then stops nearest-trigger
326    /// (highest) first, then pending unkeyed orders. Borrowed view; for an
327    /// owned snapshot use [`Self::get_orders_ask`].
328    pub fn iter_ask_orders(&self) -> impl Iterator<Item = &RestingOrder> {
329        self.ask_limits
330            .values()
331            .flat_map(|b| b.iter())
332            .chain(self.ask_stops.values().rev().flat_map(|b| b.iter()))
333            .chain(self.pending_ask.iter())
334    }
335
336    /// Iterates all orders without allocating, bids (best first) then asks
337    /// (best first). Borrowed view; for an owned snapshot use
338    /// [`Self::get_orders`].
339    pub fn iter_orders(&self) -> impl Iterator<Item = &RestingOrder> {
340        self.iter_bid_orders().chain(self.iter_ask_orders())
341    }
342
343    /// Returns the bid-side orders in price-time priority: limits best
344    /// (highest) first, then stops nearest-trigger (lowest) first, then
345    /// pending unkeyed orders. Allocates an owned snapshot; for borrowed
346    /// iteration use [`Self::iter_bid_orders`].
347    #[must_use]
348    pub fn get_orders_bid(&self) -> Vec<RestingOrder> {
349        self.iter_bid_orders().copied().collect()
350    }
351
352    /// Returns the ask-side orders in price-time priority: limits best
353    /// (lowest) first, then stops nearest-trigger (highest) first, then
354    /// pending unkeyed orders. Allocates an owned snapshot; for borrowed
355    /// iteration use [`Self::iter_ask_orders`].
356    #[must_use]
357    pub fn get_orders_ask(&self) -> Vec<RestingOrder> {
358        self.iter_ask_orders().copied().collect()
359    }
360
361    /// Returns the per-side book for the given `(side, kind)`.
362    fn book_for(&self, side: OrderSide, kind: BookKind) -> &BTreeMap<Price, OrderBucket> {
363        match (side, kind) {
364            (OrderSide::Buy, BookKind::Limit) => &self.bid_limits,
365            (OrderSide::Buy, BookKind::Stop) => &self.bid_stops,
366            (OrderSide::Sell, BookKind::Limit) => &self.ask_limits,
367            (OrderSide::Sell, BookKind::Stop) => &self.ask_stops,
368        }
369    }
370
371    /// Returns the per-side pending bucket.
372    fn pending_for(&self, side: OrderSide) -> &[RestingOrder] {
373        match side {
374            OrderSide::Buy => &self.pending_bid,
375            OrderSide::Sell => &self.pending_ask,
376        }
377    }
378
379    /// Returns all orders, bids (best first) then asks (best first).
380    /// Allocates an owned snapshot; for borrowed iteration use
381    /// [`Self::iter_orders`].
382    #[must_use]
383    pub fn get_orders(&self) -> Vec<RestingOrder> {
384        self.iter_orders().copied().collect()
385    }
386
387    /// Returns whether an order with `client_order_id` is present on either side.
388    #[must_use]
389    pub fn order_exists(&self, client_order_id: ClientOrderId) -> bool {
390        self.order_index.contains_key(&client_order_id)
391    }
392
393    /// Sets the last traded price.
394    pub const fn set_last_raw(&mut self, last: Price) {
395        self.last = Some(last);
396    }
397
398    /// Sets the best bid price.
399    pub const fn set_bid_raw(&mut self, bid: Price) {
400        self.bid = Some(bid);
401    }
402
403    /// Sets the best ask price.
404    pub const fn set_ask_raw(&mut self, ask: Price) {
405        self.ask = Some(ask);
406    }
407
408    /// Updates the price increment (tick size) for the matching core.
409    pub const fn update_price_increment(&mut self, price_increment: Price) {
410        self.price_increment = price_increment;
411    }
412
413    /// Clears all orders and resets bid/ask/last to uninitialized.
414    pub fn reset(&mut self) {
415        self.bid = None;
416        self.ask = None;
417        self.last = None;
418        self.bid_limits.clear();
419        self.ask_limits.clear();
420        self.bid_stops.clear();
421        self.ask_stops.clear();
422        self.pending_bid.clear();
423        self.pending_ask.clear();
424        self.order_index.clear();
425    }
426
427    /// Returns the (book kind, key) for an order, or `None` if the order has
428    /// neither limit nor trigger price (e.g. `MARKET_TO_LIMIT` pre-conversion).
429    fn locate(order: &RestingOrder) -> Option<(BookKind, Price)> {
430        if order.is_stop() {
431            // is_stop() == trigger_price.is_some()
432            Some((BookKind::Stop, order.trigger_price.unwrap()))
433        } else {
434            order.limit_price.map(|p| (BookKind::Limit, p))
435        }
436    }
437
438    /// Adds an order to the matching core.
439    ///
440    /// # Invariant
441    ///
442    /// Each `client_order_id` must appear at most once across all books.
443    /// To re-add an order under the same ID (e.g. a price-changing modify),
444    /// call [`Self::delete_order`] first. Inserting duplicates puts two entries
445    /// in the bucket and the order will match twice.
446    ///
447    /// Routing:
448    /// - `is_stop()` orders go to the side's stop book, keyed by trigger price.
449    /// - Pure `LIMIT` orders go to the side's limit book, keyed by limit price.
450    /// - Orders with neither price (e.g. `MARKET_TO_LIMIT` before conversion)
451    ///   go to the per-side pending bucket. They remain visible to `get_order`
452    ///   / `order_exists` but `iterate_*` skips them.
453    ///
454    /// # Panics
455    ///
456    /// Panics in debug builds if the invariant is violated.
457    pub fn add_order(&mut self, order: RestingOrder) {
458        debug_assert!(
459            !self.order_exists(order.client_order_id),
460            "duplicate add_order for {}; caller must delete before re-adding",
461            order.client_order_id,
462        );
463
464        let side = order.order_side;
465        let client_order_id = order.client_order_id;
466        let location = Self::locate(&order);
467
468        if let Some((kind, price)) = location {
469            let book = match (side, kind) {
470                (OrderSide::Buy, BookKind::Limit) => &mut self.bid_limits,
471                (OrderSide::Buy, BookKind::Stop) => &mut self.bid_stops,
472                (OrderSide::Sell, BookKind::Limit) => &mut self.ask_limits,
473                (OrderSide::Sell, BookKind::Stop) => &mut self.ask_stops,
474            };
475            book.entry(price).or_default().push(order);
476        } else {
477            match side {
478                OrderSide::Buy => self.pending_bid.push(order),
479                OrderSide::Sell => self.pending_ask.push(order),
480            }
481        }
482        self.order_index.insert(client_order_id, (side, location));
483    }
484
485    /// Deletes an order from the matching core by client order ID.
486    ///
487    /// # Errors
488    ///
489    /// Returns an [`OrderError::NotFound`] if the order is not present.
490    ///
491    /// # Panics
492    ///
493    /// Panics if the index points at a bucket that is missing or no longer
494    /// contains the expected order, indicating internal index corruption.
495    pub fn delete_order(&mut self, client_order_id: ClientOrderId) -> Result<(), OrderError> {
496        let Some((side, location)) = self.order_index.remove(&client_order_id) else {
497            return Err(OrderError::NotFound(client_order_id));
498        };
499
500        if let Some((kind, price)) = location {
501            let book = match (side, kind) {
502                (OrderSide::Buy, BookKind::Limit) => &mut self.bid_limits,
503                (OrderSide::Buy, BookKind::Stop) => &mut self.bid_stops,
504                (OrderSide::Sell, BookKind::Limit) => &mut self.ask_limits,
505                (OrderSide::Sell, BookKind::Stop) => &mut self.ask_stops,
506            };
507            let bucket = book
508                .get_mut(&price)
509                .expect("order_index points to existing bucket");
510            let pos = bucket
511                .iter()
512                .position(|o| o.client_order_id == client_order_id)
513                .expect("order_index points to existing slot");
514            bucket.remove(pos);
515            if bucket.is_empty() {
516                book.remove(&price);
517            }
518        } else {
519            let pending = match side {
520                OrderSide::Buy => &mut self.pending_bid,
521                OrderSide::Sell => &mut self.pending_ask,
522            };
523            let pos = pending
524                .iter()
525                .position(|o| o.client_order_id == client_order_id)
526                .expect("order_index points to existing pending slot");
527            pending.remove(pos);
528        }
529        Ok(())
530    }
531
532    /// Matches all bid then ask orders against the current market and returns
533    /// the resulting actions in price-time priority.
534    pub fn iterate(&self) -> Vec<MatchAction> {
535        let mut actions = self.iterate_bids();
536        actions.extend(self.iterate_asks());
537        actions
538    }
539
540    /// Matches bid-side orders: limits best (highest) first, then stops
541    /// nearest-trigger (lowest) first. FIFO within each price level.
542    pub fn iterate_bids(&self) -> Vec<MatchAction> {
543        self.bid_limits
544            .iter()
545            .rev()
546            .flat_map(|(_, b)| b.iter())
547            .chain(self.bid_stops.values().flat_map(|b| b.iter()))
548            .filter_map(|order| self.match_order(order))
549            .collect()
550    }
551
552    /// Matches ask-side orders: limits best (lowest) first, then stops
553    /// nearest-trigger (highest) first. FIFO within each price level.
554    pub fn iterate_asks(&self) -> Vec<MatchAction> {
555        self.ask_limits
556            .values()
557            .flat_map(|b| b.iter())
558            .chain(self.ask_stops.iter().rev().flat_map(|(_, b)| b.iter()))
559            .filter_map(|order| self.match_order(order))
560            .collect()
561    }
562
563    /// Returns a [`MatchAction`] if the order matches the current market,
564    /// or `None` if it does not (or has neither trigger nor limit price).
565    pub fn match_order(&self, order: &RestingOrder) -> Option<MatchAction> {
566        if order.is_stop() {
567            self.match_stop_order(order)
568        } else if order.is_limit() {
569            self.match_limit_order(order)
570        } else {
571            None
572        }
573    }
574
575    fn match_limit_order(&self, order: &RestingOrder) -> Option<MatchAction> {
576        if let Some(limit_price) = order.limit_price
577            && self.is_limit_fillable(order.order_side, limit_price)
578        {
579            Some(MatchAction::FillLimit(order.client_order_id))
580        } else {
581            None
582        }
583    }
584
585    fn match_stop_order(&self, order: &RestingOrder) -> Option<MatchAction> {
586        if !order.is_activated {
587            return None;
588        }
589
590        let trigger_price = order.trigger_price?;
591        let is_triggered = match order.order_type {
592            OrderType::MarketIfTouched | OrderType::LimitIfTouched => self
593                .is_touch_triggered_with_trigger_type(
594                    order.order_side,
595                    trigger_price,
596                    order.trigger_type.unwrap_or(TriggerType::Default),
597                ),
598            _ => self.is_stop_matched_with_trigger_type(
599                order.order_side,
600                trigger_price,
601                order.trigger_type.unwrap_or(TriggerType::Default),
602            ),
603        };
604
605        if is_triggered {
606            Some(MatchAction::TriggerStop(order.client_order_id))
607        } else {
608            None
609        }
610    }
611
612    /// Returns whether a limit order at `price` would cross the opposite side
613    /// (BUY: `ask <= price`, SELL: `bid >= price`).
614    #[must_use]
615    pub fn is_limit_matched(&self, side: OrderSide, price: Price) -> bool {
616        match side {
617            OrderSide::Buy => self.ask.is_some_and(|a| a <= price),
618            OrderSide::Sell => self.bid.is_some_and(|b| b >= price),
619        }
620    }
621
622    /// Returns whether a stop trigger at `price` has been reached
623    /// (BUY: `ask >= price`, SELL: `bid <= price`).
624    #[must_use]
625    pub fn is_stop_matched(&self, side: OrderSide, price: Price) -> bool {
626        self.is_stop_matched_with_trigger_type(side, price, TriggerType::BidAsk)
627    }
628
629    #[must_use]
630    pub(crate) fn is_stop_matched_with_trigger_type(
631        &self,
632        side: OrderSide,
633        price: Price,
634        trigger_type: TriggerType,
635    ) -> bool {
636        self.market_price_for_trigger(side, trigger_type)
637            .is_some_and(|market_price| match side {
638                OrderSide::Buy => market_price >= price,
639                OrderSide::Sell => market_price <= price,
640            })
641    }
642
643    /// Returns whether a touch trigger at `trigger_price` has been reached
644    /// (BUY: `ask <= trigger_price`, SELL: `bid >= trigger_price`).
645    #[must_use]
646    pub fn is_touch_triggered(&self, side: OrderSide, trigger_price: Price) -> bool {
647        self.is_touch_triggered_with_trigger_type(side, trigger_price, TriggerType::BidAsk)
648    }
649
650    #[must_use]
651    pub(crate) fn is_touch_triggered_with_trigger_type(
652        &self,
653        side: OrderSide,
654        trigger_price: Price,
655        trigger_type: TriggerType,
656    ) -> bool {
657        self.market_price_for_trigger(side, trigger_type)
658            .is_some_and(|market_price| match side {
659                OrderSide::Buy => market_price <= trigger_price,
660                OrderSide::Sell => market_price >= trigger_price,
661            })
662    }
663
664    fn market_price_for_trigger(
665        &self,
666        side: OrderSide,
667        trigger_type: TriggerType,
668    ) -> Option<Price> {
669        let quote_price = match side {
670            OrderSide::Buy => self.ask,
671            OrderSide::Sell => self.bid,
672        };
673
674        match trigger_type {
675            TriggerType::LastPrice => self.last,
676            TriggerType::LastOrBidAsk => self.last.or(quote_price),
677            _ => quote_price,
678        }
679    }
680
681    /// Toggles whether limit orders fill at-or-inside the spread (vs only on cross).
682    pub fn set_fill_limit_inside_spread(&mut self, value: bool) {
683        self.fill_limit_inside_spread = value;
684    }
685
686    /// Returns whether a limit order is fillable at the given price.
687    ///
688    /// Checks `is_limit_matched` first (crosses the spread). When
689    /// `fill_limit_inside_spread` is set, also checks at-or-inside spread
690    /// (BUY >= bid, SELL <= ask), requiring both sides initialized.
691    #[must_use]
692    pub fn is_limit_fillable(&self, side: OrderSide, price: Price) -> bool {
693        if self.is_limit_matched(side, price) {
694            return true;
695        }
696
697        if !self.fill_limit_inside_spread {
698            return false;
699        }
700
701        // Require both quotes present since fill simulation needs best bid and ask
702        if let (Some(bid), Some(ask)) = (self.bid, self.ask) {
703            match side {
704                OrderSide::Buy => price >= bid,
705                OrderSide::Sell => price <= ask,
706            }
707        } else {
708            false
709        }
710    }
711}
712
713#[cfg(test)]
714mod tests {
715    use nautilus_model::{
716        enums::{OrderSide, OrderType, TrailingOffsetType, TriggerType},
717        events::{OrderEventAny, OrderInitialized, order::spec::OrderInitializedSpec},
718        orders::{Order, OrderAny, builder::OrderTestBuilder},
719        types::Quantity,
720    };
721    use rstest::rstest;
722    use rust_decimal::Decimal;
723
724    use super::*;
725
726    fn create_matching_core(
727        instrument_id: InstrumentId,
728        price_increment: Price,
729    ) -> OrderMatchingCore {
730        OrderMatchingCore::new(instrument_id, price_increment)
731    }
732
733    #[rstest]
734    fn test_add_order_bid_side() {
735        let instrument_id = InstrumentId::from("AAPL.XNAS");
736        let mut matching_core = create_matching_core(instrument_id, Price::from("0.01"));
737
738        let order = OrderTestBuilder::new(OrderType::Limit)
739            .instrument_id(instrument_id)
740            .side(OrderSide::Buy)
741            .price(Price::from("100.00"))
742            .quantity(Quantity::from("100"))
743            .build();
744
745        let match_info = RestingOrder::from(&PassiveOrderAny::try_from(order).unwrap());
746        matching_core.add_order(match_info);
747
748        assert!(matching_core.get_orders_bid().contains(&match_info));
749        assert!(!matching_core.get_orders_ask().contains(&match_info));
750        assert_eq!(matching_core.get_orders_bid().len(), 1);
751        assert!(matching_core.get_orders_ask().is_empty());
752        assert!(matching_core.order_exists(match_info.client_order_id));
753    }
754
755    #[rstest]
756    fn test_add_order_ask_side() {
757        let instrument_id = InstrumentId::from("AAPL.XNAS");
758        let mut matching_core = create_matching_core(instrument_id, Price::from("0.01"));
759
760        let order = OrderTestBuilder::new(OrderType::Limit)
761            .instrument_id(instrument_id)
762            .side(OrderSide::Sell)
763            .price(Price::from("100.00"))
764            .quantity(Quantity::from("100"))
765            .build();
766
767        let match_info = RestingOrder::from(&PassiveOrderAny::try_from(order).unwrap());
768        matching_core.add_order(match_info);
769
770        assert!(matching_core.get_orders_ask().contains(&match_info));
771        assert!(!matching_core.get_orders_bid().contains(&match_info));
772        assert_eq!(matching_core.get_orders_ask().len(), 1);
773        assert!(matching_core.get_orders_bid().is_empty());
774        assert!(matching_core.order_exists(match_info.client_order_id));
775    }
776
777    #[rstest]
778    fn test_reset() {
779        let instrument_id = InstrumentId::from("AAPL.XNAS");
780        let mut matching_core = create_matching_core(instrument_id, Price::from("0.01"));
781
782        let order = OrderTestBuilder::new(OrderType::Limit)
783            .instrument_id(instrument_id)
784            .side(OrderSide::Sell)
785            .price(Price::from("100.00"))
786            .quantity(Quantity::from("100"))
787            .build();
788
789        let client_order_id = order.client_order_id();
790        let match_info = RestingOrder::from(&PassiveOrderAny::try_from(order).unwrap());
791        matching_core.add_order(match_info);
792        matching_core.set_bid_raw(Price::from("100.00"));
793        matching_core.set_ask_raw(Price::from("100.00"));
794        matching_core.set_last_raw(Price::from("100.00"));
795
796        matching_core.reset();
797
798        assert!(matching_core.bid.is_none());
799        assert!(matching_core.ask.is_none());
800        assert!(matching_core.last.is_none());
801        assert!(matching_core.get_orders_bid().is_empty());
802        assert!(matching_core.get_orders_ask().is_empty());
803        assert!(!matching_core.order_exists(client_order_id));
804    }
805
806    #[rstest]
807    fn test_delete_order_when_not_exists() {
808        let instrument_id = InstrumentId::from("AAPL.XNAS");
809        let mut matching_core = create_matching_core(instrument_id, Price::from("0.01"));
810
811        let order = OrderTestBuilder::new(OrderType::Limit)
812            .instrument_id(instrument_id)
813            .side(OrderSide::Buy)
814            .price(Price::from("100.00"))
815            .quantity(Quantity::from("100"))
816            .build();
817
818        let result = matching_core.delete_order(order.client_order_id());
819        assert!(result.is_err());
820    }
821
822    #[rstest]
823    #[case(OrderSide::Buy)]
824    #[case(OrderSide::Sell)]
825    fn test_delete_order_when_exists(#[case] order_side: OrderSide) {
826        let instrument_id = InstrumentId::from("AAPL.XNAS");
827        let mut matching_core = create_matching_core(instrument_id, Price::from("0.01"));
828
829        let order = OrderTestBuilder::new(OrderType::Limit)
830            .instrument_id(instrument_id)
831            .side(order_side)
832            .price(Price::from("100.00"))
833            .quantity(Quantity::from("100"))
834            .build();
835
836        let client_order_id = order.client_order_id();
837        let match_info = RestingOrder::from(&PassiveOrderAny::try_from(order).unwrap());
838        matching_core.add_order(match_info);
839        matching_core.delete_order(client_order_id).unwrap();
840
841        assert!(matching_core.get_orders_ask().is_empty());
842        assert!(matching_core.get_orders_bid().is_empty());
843    }
844
845    #[rstest]
846    #[case(None, None, Price::from("100.00"), OrderSide::Buy, false)]
847    #[case(None, None, Price::from("100.00"), OrderSide::Sell, false)]
848    #[case(
849        Some(Price::from("100.00")),
850        Some(Price::from("101.00")),
851        Price::from("100.00"),  // <-- Price below ask
852        OrderSide::Buy,
853        false
854    )]
855    #[case(
856        Some(Price::from("100.00")),
857        Some(Price::from("101.00")),
858        Price::from("101.00"),  // <-- Price at ask
859        OrderSide::Buy,
860        true
861    )]
862    #[case(
863        Some(Price::from("100.00")),
864        Some(Price::from("101.00")),
865        Price::from("102.00"),  // <-- Price above ask (marketable)
866        OrderSide::Buy,
867        true
868    )]
869    #[case(
870        Some(Price::from("100.00")),
871        Some(Price::from("101.00")),
872        Price::from("101.00"), // <-- Price above bid
873        OrderSide::Sell,
874        false
875    )]
876    #[case(
877        Some(Price::from("100.00")),
878        Some(Price::from("101.00")),
879        Price::from("100.00"),  // <-- Price at bid
880        OrderSide::Sell,
881        true
882    )]
883    #[case(
884        Some(Price::from("100.00")),
885        Some(Price::from("101.00")),
886        Price::from("99.00"),  // <-- Price below bid (marketable)
887        OrderSide::Sell,
888        true
889    )]
890    fn test_is_limit_matched(
891        #[case] bid: Option<Price>,
892        #[case] ask: Option<Price>,
893        #[case] price: Price,
894        #[case] order_side: OrderSide,
895        #[case] expected: bool,
896    ) {
897        let instrument_id = InstrumentId::from("AAPL.XNAS");
898        let mut matching_core = create_matching_core(instrument_id, Price::from("0.01"));
899        matching_core.bid = bid;
900        matching_core.ask = ask;
901
902        let order = OrderTestBuilder::new(OrderType::Limit)
903            .instrument_id(instrument_id)
904            .side(order_side)
905            .price(price)
906            .quantity(Quantity::from("100"))
907            .build();
908
909        let result = matching_core.is_limit_matched(order.order_side(), order.price().unwrap());
910        assert_eq!(result, expected);
911    }
912
913    #[rstest]
914    #[case(None, None, Price::from("100.00"), OrderSide::Buy, false)]
915    #[case(None, None, Price::from("100.00"), OrderSide::Sell, false)]
916    #[case(
917        Some(Price::from("100.00")),
918        Some(Price::from("101.00")),
919        Price::from("102.00"),  // <-- Trigger above ask
920        OrderSide::Buy,
921        false
922    )]
923    #[case(
924        Some(Price::from("100.00")),
925        Some(Price::from("101.00")),
926        Price::from("101.00"),  // <-- Trigger at ask
927        OrderSide::Buy,
928        true
929    )]
930    #[case(
931        Some(Price::from("100.00")),
932        Some(Price::from("101.00")),
933        Price::from("100.00"),  // <-- Trigger below ask
934        OrderSide::Buy,
935        true
936    )]
937    #[case(
938        Some(Price::from("100.00")),
939        Some(Price::from("101.00")),
940        Price::from("99.00"),  // Trigger below bid
941        OrderSide::Sell,
942        false
943    )]
944    #[case(
945        Some(Price::from("100.00")),
946        Some(Price::from("101.00")),
947        Price::from("100.00"),  // <-- Trigger at bid
948        OrderSide::Sell,
949        true
950    )]
951    #[case(
952        Some(Price::from("100.00")),
953        Some(Price::from("101.00")),
954        Price::from("101.00"),  // <-- Trigger above bid
955        OrderSide::Sell,
956        true
957    )]
958    fn test_is_stop_matched(
959        #[case] bid: Option<Price>,
960        #[case] ask: Option<Price>,
961        #[case] trigger_price: Price,
962        #[case] order_side: OrderSide,
963        #[case] expected: bool,
964    ) {
965        let instrument_id = InstrumentId::from("AAPL.XNAS");
966        let mut matching_core = create_matching_core(instrument_id, Price::from("0.01"));
967        matching_core.bid = bid;
968        matching_core.ask = ask;
969
970        let order = OrderTestBuilder::new(OrderType::StopMarket)
971            .instrument_id(instrument_id)
972            .side(order_side)
973            .trigger_price(trigger_price)
974            .quantity(Quantity::from("100"))
975            .build();
976
977        let result =
978            matching_core.is_stop_matched(order.order_side(), order.trigger_price().unwrap());
979        assert_eq!(result, expected);
980    }
981
982    #[rstest]
983    #[case::last_price_below_trigger(Some(Price::from("99.00")), TriggerType::LastPrice, false)]
984    #[case::last_price_at_trigger(Some(Price::from("100.00")), TriggerType::LastPrice, true)]
985    #[case::last_price_unavailable(None, TriggerType::LastPrice, false)]
986    #[case::last_or_bid_ask_prefers_last(
987        Some(Price::from("99.00")),
988        TriggerType::LastOrBidAsk,
989        false
990    )]
991    #[case::last_or_bid_ask_falls_back_to_quote(None, TriggerType::LastOrBidAsk, true)]
992    #[case::bid_ask_uses_quote(Some(Price::from("99.00")), TriggerType::BidAsk, true)]
993    fn test_is_stop_matched_uses_trigger_type(
994        #[case] last: Option<Price>,
995        #[case] trigger_type: TriggerType,
996        #[case] expected: bool,
997    ) {
998        let instrument_id = InstrumentId::from("AAPL.XNAS");
999        let mut matching_core = create_matching_core(instrument_id, Price::from("0.01"));
1000        matching_core.ask = Some(Price::from("101.00"));
1001        matching_core.last = last;
1002
1003        let result = matching_core.is_stop_matched_with_trigger_type(
1004            OrderSide::Buy,
1005            Price::from("100.00"),
1006            trigger_type,
1007        );
1008
1009        assert_eq!(result, expected);
1010    }
1011
1012    #[rstest]
1013    fn test_iterate_returns_empty_when_no_orders() {
1014        let instrument_id = InstrumentId::from("AAPL.XNAS");
1015        let mut matching_core = create_matching_core(instrument_id, Price::from("0.01"));
1016        matching_core.set_bid_raw(Price::from("100.00"));
1017        matching_core.set_ask_raw(Price::from("101.00"));
1018
1019        let actions = matching_core.iterate();
1020
1021        assert!(actions.is_empty());
1022    }
1023
1024    #[rstest]
1025    fn test_iterate_returns_empty_when_no_market_data() {
1026        let instrument_id = InstrumentId::from("AAPL.XNAS");
1027        let mut matching_core = create_matching_core(instrument_id, Price::from("0.01"));
1028
1029        let order = OrderTestBuilder::new(OrderType::Limit)
1030            .instrument_id(instrument_id)
1031            .side(OrderSide::Buy)
1032            .price(Price::from("100.00"))
1033            .quantity(Quantity::from("100"))
1034            .build();
1035        let match_info = RestingOrder::from(&PassiveOrderAny::try_from(order).unwrap());
1036        matching_core.add_order(match_info);
1037
1038        let actions = matching_core.iterate();
1039
1040        assert!(actions.is_empty());
1041    }
1042
1043    #[rstest]
1044    fn test_iterate_returns_fill_limit_for_matched_buy() {
1045        let instrument_id = InstrumentId::from("AAPL.XNAS");
1046        let mut matching_core = create_matching_core(instrument_id, Price::from("0.01"));
1047        matching_core.set_ask_raw(Price::from("100.00"));
1048
1049        let order = OrderTestBuilder::new(OrderType::Limit)
1050            .instrument_id(instrument_id)
1051            .side(OrderSide::Buy)
1052            .price(Price::from("100.00"))
1053            .quantity(Quantity::from("100"))
1054            .build();
1055        let client_order_id = order.client_order_id();
1056        let match_info = RestingOrder::from(&PassiveOrderAny::try_from(order).unwrap());
1057        matching_core.add_order(match_info);
1058
1059        let actions = matching_core.iterate();
1060
1061        assert_eq!(actions, vec![MatchAction::FillLimit(client_order_id)]);
1062    }
1063
1064    #[rstest]
1065    fn test_iterate_returns_fill_limit_for_matched_sell() {
1066        let instrument_id = InstrumentId::from("AAPL.XNAS");
1067        let mut matching_core = create_matching_core(instrument_id, Price::from("0.01"));
1068        matching_core.set_bid_raw(Price::from("100.00"));
1069
1070        let order = OrderTestBuilder::new(OrderType::Limit)
1071            .instrument_id(instrument_id)
1072            .side(OrderSide::Sell)
1073            .price(Price::from("100.00"))
1074            .quantity(Quantity::from("100"))
1075            .build();
1076        let client_order_id = order.client_order_id();
1077        let match_info = RestingOrder::from(&PassiveOrderAny::try_from(order).unwrap());
1078        matching_core.add_order(match_info);
1079
1080        let actions = matching_core.iterate();
1081
1082        assert_eq!(actions, vec![MatchAction::FillLimit(client_order_id)]);
1083    }
1084
1085    #[rstest]
1086    fn test_iterate_returns_no_fill_for_unmatched_limit() {
1087        let instrument_id = InstrumentId::from("AAPL.XNAS");
1088        let mut matching_core = create_matching_core(instrument_id, Price::from("0.01"));
1089        matching_core.set_ask_raw(Price::from("101.00"));
1090
1091        // Buy limit at 100 with ask at 101 - not matched
1092        let order = OrderTestBuilder::new(OrderType::Limit)
1093            .instrument_id(instrument_id)
1094            .side(OrderSide::Buy)
1095            .price(Price::from("100.00"))
1096            .quantity(Quantity::from("100"))
1097            .build();
1098        let match_info = RestingOrder::from(&PassiveOrderAny::try_from(order).unwrap());
1099        matching_core.add_order(match_info);
1100
1101        let actions = matching_core.iterate();
1102
1103        assert!(actions.is_empty());
1104    }
1105
1106    #[rstest]
1107    fn test_iterate_returns_trigger_stop_for_matched_buy() {
1108        let instrument_id = InstrumentId::from("AAPL.XNAS");
1109        let mut matching_core = create_matching_core(instrument_id, Price::from("0.01"));
1110        matching_core.set_ask_raw(Price::from("101.00"));
1111
1112        let order = OrderTestBuilder::new(OrderType::StopMarket)
1113            .instrument_id(instrument_id)
1114            .side(OrderSide::Buy)
1115            .trigger_price(Price::from("101.00"))
1116            .trigger_type(TriggerType::Default)
1117            .quantity(Quantity::from("100"))
1118            .build();
1119        let client_order_id = order.client_order_id();
1120        let match_info = RestingOrder::from(&PassiveOrderAny::try_from(order).unwrap());
1121        matching_core.add_order(match_info);
1122
1123        let actions = matching_core.iterate();
1124
1125        assert_eq!(actions, vec![MatchAction::TriggerStop(client_order_id)]);
1126    }
1127
1128    #[rstest]
1129    fn test_iterate_returns_trigger_stop_for_matched_sell() {
1130        let instrument_id = InstrumentId::from("AAPL.XNAS");
1131        let mut matching_core = create_matching_core(instrument_id, Price::from("0.01"));
1132        matching_core.set_bid_raw(Price::from("99.00"));
1133
1134        let order = OrderTestBuilder::new(OrderType::StopMarket)
1135            .instrument_id(instrument_id)
1136            .side(OrderSide::Sell)
1137            .trigger_price(Price::from("99.00"))
1138            .quantity(Quantity::from("100"))
1139            .build();
1140        let client_order_id = order.client_order_id();
1141        let match_info = RestingOrder::from(&PassiveOrderAny::try_from(order).unwrap());
1142        matching_core.add_order(match_info);
1143
1144        let actions = matching_core.iterate();
1145
1146        assert_eq!(actions, vec![MatchAction::TriggerStop(client_order_id)]);
1147    }
1148
1149    #[rstest]
1150    fn test_iterate_skips_unactivated_stop_order() {
1151        let instrument_id = InstrumentId::from("AAPL.XNAS");
1152        let mut matching_core = create_matching_core(instrument_id, Price::from("0.01"));
1153        matching_core.set_ask_raw(Price::from("110.00"));
1154
1155        // Manually create an unactivated stop (simulates trailing stop)
1156        let match_info = RestingOrder::new(
1157            ClientOrderId::from("O-001"),
1158            OrderSide::Buy,
1159            OrderType::TrailingStopMarket,
1160            Some(Price::from("105.00")),
1161            None,
1162            false, // not activated
1163        );
1164        matching_core.add_order(match_info);
1165
1166        let actions = matching_core.iterate();
1167
1168        assert!(actions.is_empty());
1169    }
1170
1171    #[rstest]
1172    fn test_iterate_triggers_activated_stop_order() {
1173        let instrument_id = InstrumentId::from("AAPL.XNAS");
1174        let mut matching_core = create_matching_core(instrument_id, Price::from("0.01"));
1175        matching_core.set_ask_raw(Price::from("110.00"));
1176
1177        let client_order_id = ClientOrderId::from("O-001");
1178        let match_info = RestingOrder::new(
1179            client_order_id,
1180            OrderSide::Buy,
1181            OrderType::TrailingStopMarket,
1182            Some(Price::from("105.00")),
1183            None,
1184            true, // activated
1185        );
1186        matching_core.add_order(match_info);
1187
1188        let actions = matching_core.iterate();
1189
1190        assert_eq!(actions, vec![MatchAction::TriggerStop(client_order_id)]);
1191    }
1192
1193    #[rstest]
1194    fn test_iterate_returns_mixed_actions_for_limits_and_stops() {
1195        let instrument_id = InstrumentId::from("AAPL.XNAS");
1196        let mut matching_core = create_matching_core(instrument_id, Price::from("0.01"));
1197        matching_core.set_bid_raw(Price::from("99.00"));
1198        matching_core.set_ask_raw(Price::from("101.00"));
1199
1200        // Buy limit at 101 - matches (ask <= price)
1201        let buy_limit = OrderTestBuilder::new(OrderType::Limit)
1202            .instrument_id(instrument_id)
1203            .side(OrderSide::Buy)
1204            .price(Price::from("101.00"))
1205            .quantity(Quantity::from("100"))
1206            .client_order_id(ClientOrderId::from("O-BUY-LIMIT"))
1207            .build();
1208        let buy_limit_id = buy_limit.client_order_id();
1209        matching_core.add_order(RestingOrder::from(
1210            &PassiveOrderAny::try_from(buy_limit).unwrap(),
1211        ));
1212
1213        // Sell stop at 99 - matches (bid <= trigger)
1214        let sell_stop = OrderTestBuilder::new(OrderType::StopMarket)
1215            .instrument_id(instrument_id)
1216            .side(OrderSide::Sell)
1217            .trigger_price(Price::from("99.00"))
1218            .quantity(Quantity::from("50"))
1219            .client_order_id(ClientOrderId::from("O-SELL-STOP"))
1220            .build();
1221        let sell_stop_id = sell_stop.client_order_id();
1222        matching_core.add_order(RestingOrder::from(
1223            &PassiveOrderAny::try_from(sell_stop).unwrap(),
1224        ));
1225
1226        let actions = matching_core.iterate();
1227
1228        // Bids processed first, then asks
1229        assert_eq!(actions.len(), 2);
1230        assert_eq!(actions[0], MatchAction::FillLimit(buy_limit_id));
1231        assert_eq!(actions[1], MatchAction::TriggerStop(sell_stop_id));
1232    }
1233
1234    #[rstest]
1235    fn test_is_limit_fillable_delegates_to_is_limit_matched_by_default() {
1236        let instrument_id = InstrumentId::from("AAPL.XNAS");
1237        let mut core = create_matching_core(instrument_id, Price::from("0.01"));
1238        core.set_bid_raw(Price::from("100.00"));
1239        core.set_ask_raw(Price::from("101.00"));
1240
1241        assert!(core.is_limit_fillable(OrderSide::Buy, Price::from("101.00")));
1242        assert!(!core.is_limit_fillable(OrderSide::Buy, Price::from("100.00")));
1243        assert!(core.is_limit_fillable(OrderSide::Sell, Price::from("100.00")));
1244        assert!(!core.is_limit_fillable(OrderSide::Sell, Price::from("101.00")));
1245    }
1246
1247    #[rstest]
1248    fn test_is_limit_fillable_inside_spread_buy_at_bid() {
1249        let instrument_id = InstrumentId::from("AAPL.XNAS");
1250        let mut core = create_matching_core(instrument_id, Price::from("0.01"));
1251        core.set_bid_raw(Price::from("100.00"));
1252        core.set_ask_raw(Price::from("101.00"));
1253        core.set_fill_limit_inside_spread(true);
1254
1255        assert!(core.is_limit_fillable(OrderSide::Buy, Price::from("100.00")));
1256        assert!(core.is_limit_fillable(OrderSide::Buy, Price::from("100.50")));
1257        assert!(!core.is_limit_fillable(OrderSide::Buy, Price::from("99.00")));
1258    }
1259
1260    #[rstest]
1261    fn test_is_limit_fillable_inside_spread_sell_at_ask() {
1262        let instrument_id = InstrumentId::from("AAPL.XNAS");
1263        let mut core = create_matching_core(instrument_id, Price::from("0.01"));
1264        core.set_bid_raw(Price::from("100.00"));
1265        core.set_ask_raw(Price::from("101.00"));
1266        core.set_fill_limit_inside_spread(true);
1267
1268        assert!(core.is_limit_fillable(OrderSide::Sell, Price::from("101.00")));
1269        assert!(core.is_limit_fillable(OrderSide::Sell, Price::from("100.50")));
1270        assert!(!core.is_limit_fillable(OrderSide::Sell, Price::from("102.00")));
1271    }
1272
1273    #[rstest]
1274    fn test_is_limit_fillable_inside_spread_requires_both_quotes_present() {
1275        let instrument_id = InstrumentId::from("AAPL.XNAS");
1276        let mut core = create_matching_core(instrument_id, Price::from("0.01"));
1277        core.set_fill_limit_inside_spread(true);
1278
1279        core.set_bid_raw(Price::from("100.00"));
1280        assert!(!core.is_limit_fillable(OrderSide::Buy, Price::from("100.00")));
1281
1282        let mut core2 = create_matching_core(instrument_id, Price::from("0.01"));
1283        core2.set_fill_limit_inside_spread(true);
1284        core2.set_ask_raw(Price::from("101.00"));
1285        assert!(!core2.is_limit_fillable(OrderSide::Sell, Price::from("101.00")));
1286
1287        // Ask cleared after both were set
1288        let mut core3 = create_matching_core(instrument_id, Price::from("0.01"));
1289        core3.set_fill_limit_inside_spread(true);
1290        core3.set_bid_raw(Price::from("100.00"));
1291        core3.set_ask_raw(Price::from("101.00"));
1292        core3.ask = None;
1293        assert!(!core3.is_limit_fillable(OrderSide::Buy, Price::from("100.00")));
1294    }
1295
1296    #[rstest]
1297    fn test_iterate_fills_limit_inside_spread_when_enabled() {
1298        let instrument_id = InstrumentId::from("AAPL.XNAS");
1299        let mut core = create_matching_core(instrument_id, Price::from("0.01"));
1300        core.set_bid_raw(Price::from("100.00"));
1301        core.set_ask_raw(Price::from("101.00"));
1302        core.set_fill_limit_inside_spread(true);
1303
1304        let order = OrderTestBuilder::new(OrderType::Limit)
1305            .instrument_id(instrument_id)
1306            .side(OrderSide::Buy)
1307            .price(Price::from("100.00"))
1308            .quantity(Quantity::from("100"))
1309            .build();
1310        let client_order_id = order.client_order_id();
1311        let match_info = RestingOrder::from(&PassiveOrderAny::try_from(order).unwrap());
1312        core.add_order(match_info);
1313
1314        let actions = core.iterate();
1315        assert_eq!(actions, vec![MatchAction::FillLimit(client_order_id)]);
1316    }
1317
1318    #[rstest]
1319    #[case(None, None, Price::from("100.00"), OrderSide::Buy, false)]
1320    #[case(None, None, Price::from("100.00"), OrderSide::Sell, false)]
1321    #[case(
1322        Some(Price::from("100.00")),
1323        Some(Price::from("101.00")),
1324        Price::from("102.00"),  // <-- Ask below trigger
1325        OrderSide::Buy,
1326        true
1327    )]
1328    #[case(
1329        Some(Price::from("100.00")),
1330        Some(Price::from("101.00")),
1331        Price::from("101.00"),  // <-- Ask at trigger
1332        OrderSide::Buy,
1333        true
1334    )]
1335    #[case(
1336        Some(Price::from("100.00")),
1337        Some(Price::from("101.00")),
1338        Price::from("100.00"),  // <-- Ask above trigger
1339        OrderSide::Buy,
1340        false
1341    )]
1342    #[case(
1343        Some(Price::from("100.00")),
1344        Some(Price::from("101.00")),
1345        Price::from("99.00"),  // <-- Bid above trigger
1346        OrderSide::Sell,
1347        true
1348    )]
1349    #[case(
1350        Some(Price::from("100.00")),
1351        Some(Price::from("101.00")),
1352        Price::from("100.00"),  // <-- Bid at trigger
1353        OrderSide::Sell,
1354        true
1355    )]
1356    #[case(
1357        Some(Price::from("100.00")),
1358        Some(Price::from("101.00")),
1359        Price::from("101.00"),  // <-- Bid below trigger
1360        OrderSide::Sell,
1361        false
1362    )]
1363    fn test_is_touch_triggered(
1364        #[case] bid: Option<Price>,
1365        #[case] ask: Option<Price>,
1366        #[case] trigger_price: Price,
1367        #[case] order_side: OrderSide,
1368        #[case] expected: bool,
1369    ) {
1370        let instrument_id = InstrumentId::from("AAPL.XNAS");
1371        let mut matching_core = create_matching_core(instrument_id, Price::from("0.01"));
1372        matching_core.bid = bid;
1373        matching_core.ask = ask;
1374
1375        let result = matching_core.is_touch_triggered(order_side, trigger_price);
1376        assert_eq!(result, expected);
1377    }
1378
1379    #[rstest]
1380    fn test_update_price_increment_updates_increment_and_precision() {
1381        let instrument_id = InstrumentId::from("AAPL.XNAS");
1382        let mut matching_core = create_matching_core(instrument_id, Price::from("0.01"));
1383
1384        assert_eq!(matching_core.price_increment, Price::from("0.01"));
1385        assert_eq!(matching_core.price_precision(), 2);
1386
1387        matching_core.update_price_increment(Price::from("0.001"));
1388
1389        assert_eq!(matching_core.price_increment, Price::from("0.001"));
1390        assert_eq!(matching_core.price_precision(), 3);
1391    }
1392
1393    fn order_from_init(spec: OrderInitialized) -> OrderAny {
1394        OrderAny::from_events(vec![OrderEventAny::Initialized(spec)]).unwrap()
1395    }
1396
1397    #[rstest]
1398    fn test_get_order_finds_orders_on_either_side() {
1399        let instrument_id = InstrumentId::from("AAPL.XNAS");
1400        let mut core = create_matching_core(instrument_id, Price::from("0.01"));
1401
1402        let buy = order_from_init(
1403            OrderInitializedSpec::builder()
1404                .instrument_id(instrument_id)
1405                .client_order_id(ClientOrderId::from("O-BUY"))
1406                .order_side(OrderSide::Buy)
1407                .order_type(OrderType::Limit)
1408                .quantity(Quantity::from("10"))
1409                .price(Price::from("100.00"))
1410                .build(),
1411        );
1412        let buy_id = buy.client_order_id();
1413        core.add_order(RestingOrder::from(&PassiveOrderAny::try_from(buy).unwrap()));
1414
1415        let sell = order_from_init(
1416            OrderInitializedSpec::builder()
1417                .instrument_id(instrument_id)
1418                .client_order_id(ClientOrderId::from("O-SELL"))
1419                .order_side(OrderSide::Sell)
1420                .order_type(OrderType::Limit)
1421                .quantity(Quantity::from("10"))
1422                .price(Price::from("101.00"))
1423                .build(),
1424        );
1425        let sell_id = sell.client_order_id();
1426        core.add_order(RestingOrder::from(
1427            &PassiveOrderAny::try_from(sell).unwrap(),
1428        ));
1429
1430        assert_eq!(
1431            core.get_order(buy_id).map(|o| o.client_order_id),
1432            Some(buy_id)
1433        );
1434        assert_eq!(
1435            core.get_order(sell_id).map(|o| o.client_order_id),
1436            Some(sell_id)
1437        );
1438        assert!(core.get_order(ClientOrderId::from("O-MISSING")).is_none());
1439    }
1440
1441    #[rstest]
1442    fn test_match_order_returns_none_when_neither_price_set() {
1443        // MARKET_TO_LIMIT and any caller-built `RestingOrder::new` with both
1444        // prices `None` must no-op rather than dispatch to a match function.
1445        let instrument_id = InstrumentId::from("AAPL.XNAS");
1446        let mut core = create_matching_core(instrument_id, Price::from("0.01"));
1447        core.set_bid_raw(Price::from("100.00"));
1448        core.set_ask_raw(Price::from("101.00"));
1449
1450        let info = RestingOrder::new(
1451            ClientOrderId::from("O-NEITHER"),
1452            OrderSide::Buy,
1453            OrderType::MarketToLimit,
1454            None,
1455            None,
1456            true,
1457        );
1458        assert!(core.match_order(&info).is_none());
1459    }
1460
1461    #[rstest]
1462    fn test_from_passive_order_extracts_limit_price_for_stop_limit() {
1463        let order = order_from_init(
1464            OrderInitializedSpec::builder()
1465                .order_type(OrderType::StopLimit)
1466                .order_side(OrderSide::Buy)
1467                .quantity(Quantity::from("10"))
1468                .price(Price::from("101.00"))
1469                .trigger_price(Price::from("100.00"))
1470                .trigger_type(TriggerType::Default)
1471                .build(),
1472        );
1473
1474        let info = RestingOrder::from(&PassiveOrderAny::try_from(order).unwrap());
1475
1476        assert_eq!(info.trigger_price, Some(Price::from("100.00")));
1477        assert_eq!(info.limit_price, Some(Price::from("101.00")));
1478        assert!(info.is_activated);
1479    }
1480
1481    #[rstest]
1482    fn test_from_passive_order_extracts_limit_price_for_limit_if_touched() {
1483        let order = order_from_init(
1484            OrderInitializedSpec::builder()
1485                .order_type(OrderType::LimitIfTouched)
1486                .order_side(OrderSide::Sell)
1487                .quantity(Quantity::from("10"))
1488                .price(Price::from("99.00"))
1489                .trigger_price(Price::from("100.00"))
1490                .trigger_type(TriggerType::LastPrice)
1491                .build(),
1492        );
1493
1494        let info = RestingOrder::from(&PassiveOrderAny::try_from(order).unwrap());
1495
1496        assert_eq!(info.trigger_price, Some(Price::from("100.00")));
1497        assert_eq!(info.limit_price, Some(Price::from("99.00")));
1498        assert_eq!(info.trigger_type, Some(TriggerType::LastPrice));
1499        assert!(info.is_activated);
1500    }
1501
1502    #[rstest]
1503    fn test_from_passive_order_extracts_is_activated_for_trailing_stop_market() {
1504        let order = order_from_init(
1505            OrderInitializedSpec::builder()
1506                .order_type(OrderType::TrailingStopMarket)
1507                .order_side(OrderSide::Buy)
1508                .quantity(Quantity::from("10"))
1509                .trigger_price(Price::from("101.00"))
1510                .trigger_type(TriggerType::Default)
1511                .trailing_offset(Decimal::from(1))
1512                .trailing_offset_type(TrailingOffsetType::Price)
1513                .build(),
1514        );
1515
1516        let info = RestingOrder::from(&PassiveOrderAny::try_from(order).unwrap());
1517
1518        assert_eq!(info.trigger_price, Some(Price::from("101.00")));
1519        assert_eq!(info.limit_price, None);
1520        // TrailingStopMarket starts unactivated until the trigger has been seen.
1521        assert!(!info.is_activated);
1522    }
1523
1524    #[rstest]
1525    fn test_from_passive_order_extracts_limit_and_is_activated_for_trailing_stop_limit() {
1526        let order = order_from_init(
1527            OrderInitializedSpec::builder()
1528                .order_type(OrderType::TrailingStopLimit)
1529                .order_side(OrderSide::Sell)
1530                .quantity(Quantity::from("10"))
1531                .price(Price::from("99.00"))
1532                .trigger_price(Price::from("100.00"))
1533                .trigger_type(TriggerType::Default)
1534                .limit_offset(Decimal::from(1))
1535                .trailing_offset(Decimal::from(1))
1536                .trailing_offset_type(TrailingOffsetType::Price)
1537                .build(),
1538        );
1539
1540        let info = RestingOrder::from(&PassiveOrderAny::try_from(order).unwrap());
1541
1542        assert_eq!(info.trigger_price, Some(Price::from("100.00")));
1543        assert_eq!(info.limit_price, Some(Price::from("99.00")));
1544        assert!(!info.is_activated);
1545    }
1546
1547    // -- Book layout & iteration ordering ---------------------------------
1548
1549    fn limit_order(side: OrderSide, price: &str, id: &str) -> RestingOrder {
1550        let order = order_from_init(
1551            OrderInitializedSpec::builder()
1552                .client_order_id(ClientOrderId::from(id))
1553                .order_type(OrderType::Limit)
1554                .order_side(side)
1555                .quantity(Quantity::from("10"))
1556                .price(Price::from(price))
1557                .build(),
1558        );
1559        RestingOrder::from(&PassiveOrderAny::try_from(order).unwrap())
1560    }
1561
1562    fn stop_order(side: OrderSide, trigger: &str, id: &str) -> RestingOrder {
1563        let order = order_from_init(
1564            OrderInitializedSpec::builder()
1565                .client_order_id(ClientOrderId::from(id))
1566                .order_type(OrderType::StopMarket)
1567                .order_side(side)
1568                .quantity(Quantity::from("10"))
1569                .trigger_price(Price::from(trigger))
1570                .trigger_type(TriggerType::Default)
1571                .build(),
1572        );
1573        RestingOrder::from(&PassiveOrderAny::try_from(order).unwrap())
1574    }
1575
1576    fn stop_limit_order(side: OrderSide, trigger: &str, limit: &str, id: &str) -> RestingOrder {
1577        let order = order_from_init(
1578            OrderInitializedSpec::builder()
1579                .client_order_id(ClientOrderId::from(id))
1580                .order_type(OrderType::StopLimit)
1581                .order_side(side)
1582                .quantity(Quantity::from("10"))
1583                .price(Price::from(limit))
1584                .trigger_price(Price::from(trigger))
1585                .trigger_type(TriggerType::Default)
1586                .build(),
1587        );
1588        RestingOrder::from(&PassiveOrderAny::try_from(order).unwrap())
1589    }
1590
1591    #[rstest]
1592    fn test_iterate_bids_returns_limits_in_descending_price_order() {
1593        let mut core = create_matching_core(InstrumentId::from("AAPL.XNAS"), Price::from("0.01"));
1594        core.set_ask_raw(Price::from("99.00"));
1595
1596        // Add intentionally out-of-price-order to verify the BTreeMap re-sorts.
1597        core.add_order(limit_order(OrderSide::Buy, "100.00", "O-MID"));
1598        core.add_order(limit_order(OrderSide::Buy, "100.50", "O-HIGH"));
1599        core.add_order(limit_order(OrderSide::Buy, "99.50", "O-LOW"));
1600
1601        let actions = core.iterate_bids();
1602        assert_eq!(
1603            actions,
1604            vec![
1605                MatchAction::FillLimit(ClientOrderId::from("O-HIGH")),
1606                MatchAction::FillLimit(ClientOrderId::from("O-MID")),
1607                MatchAction::FillLimit(ClientOrderId::from("O-LOW")),
1608            ],
1609        );
1610    }
1611
1612    #[rstest]
1613    fn test_iterate_asks_returns_limits_in_ascending_price_order() {
1614        let mut core = create_matching_core(InstrumentId::from("AAPL.XNAS"), Price::from("0.01"));
1615        core.set_bid_raw(Price::from("101.00"));
1616
1617        core.add_order(limit_order(OrderSide::Sell, "100.50", "O-MID"));
1618        core.add_order(limit_order(OrderSide::Sell, "100.00", "O-LOW"));
1619        core.add_order(limit_order(OrderSide::Sell, "100.75", "O-HIGH"));
1620
1621        let actions = core.iterate_asks();
1622        assert_eq!(
1623            actions,
1624            vec![
1625                MatchAction::FillLimit(ClientOrderId::from("O-LOW")),
1626                MatchAction::FillLimit(ClientOrderId::from("O-MID")),
1627                MatchAction::FillLimit(ClientOrderId::from("O-HIGH")),
1628            ],
1629        );
1630    }
1631
1632    #[rstest]
1633    fn test_iterate_limits_preserves_fifo_within_same_price() {
1634        let mut core = create_matching_core(InstrumentId::from("AAPL.XNAS"), Price::from("0.01"));
1635        core.set_ask_raw(Price::from("99.00"));
1636
1637        for id in ["O-1", "O-2", "O-3", "O-4"] {
1638            core.add_order(limit_order(OrderSide::Buy, "100.00", id));
1639        }
1640
1641        let actions = core.iterate_bids();
1642        assert_eq!(
1643            actions,
1644            vec![
1645                MatchAction::FillLimit(ClientOrderId::from("O-1")),
1646                MatchAction::FillLimit(ClientOrderId::from("O-2")),
1647                MatchAction::FillLimit(ClientOrderId::from("O-3")),
1648                MatchAction::FillLimit(ClientOrderId::from("O-4")),
1649            ],
1650        );
1651    }
1652
1653    #[rstest]
1654    fn test_buy_stops_trigger_in_ascending_price_order_when_ask_crosses_multiple() {
1655        // Codex regression: ask climbs from 100 to 106. BUY stops at 101 and
1656        // 105 should both trigger, but the 101 stop must fire first because
1657        // the ask crossed it before reaching 105.
1658        let mut core = create_matching_core(InstrumentId::from("AAPL.XNAS"), Price::from("0.01"));
1659        core.set_ask_raw(Price::from("106.00"));
1660
1661        core.add_order(stop_order(OrderSide::Buy, "105.00", "O-FAR"));
1662        core.add_order(stop_order(OrderSide::Buy, "101.00", "O-NEAR"));
1663
1664        let actions = core.iterate_bids();
1665        assert_eq!(
1666            actions,
1667            vec![
1668                MatchAction::TriggerStop(ClientOrderId::from("O-NEAR")),
1669                MatchAction::TriggerStop(ClientOrderId::from("O-FAR")),
1670            ],
1671        );
1672    }
1673
1674    #[rstest]
1675    fn test_sell_stops_trigger_in_descending_price_order_when_bid_crosses_multiple() {
1676        // Symmetric to the BUY case: bid drops from 100 to 94. SELL stops at
1677        // 99 and 95 should both trigger, but 99 must fire first because the
1678        // bid crossed it before reaching 95.
1679        let mut core = create_matching_core(InstrumentId::from("AAPL.XNAS"), Price::from("0.01"));
1680        core.set_bid_raw(Price::from("94.00"));
1681
1682        core.add_order(stop_order(OrderSide::Sell, "95.00", "O-FAR"));
1683        core.add_order(stop_order(OrderSide::Sell, "99.00", "O-NEAR"));
1684
1685        let actions = core.iterate_asks();
1686        assert_eq!(
1687            actions,
1688            vec![
1689                MatchAction::TriggerStop(ClientOrderId::from("O-NEAR")),
1690                MatchAction::TriggerStop(ClientOrderId::from("O-FAR")),
1691            ],
1692        );
1693    }
1694
1695    #[rstest]
1696    fn test_iterate_stops_preserves_fifo_within_same_trigger() {
1697        let mut core = create_matching_core(InstrumentId::from("AAPL.XNAS"), Price::from("0.01"));
1698        core.set_ask_raw(Price::from("106.00"));
1699
1700        for id in ["O-S1", "O-S2", "O-S3"] {
1701            core.add_order(stop_order(OrderSide::Buy, "101.00", id));
1702        }
1703
1704        let actions = core.iterate_bids();
1705        assert_eq!(
1706            actions,
1707            vec![
1708                MatchAction::TriggerStop(ClientOrderId::from("O-S1")),
1709                MatchAction::TriggerStop(ClientOrderId::from("O-S2")),
1710                MatchAction::TriggerStop(ClientOrderId::from("O-S3")),
1711            ],
1712        );
1713    }
1714
1715    #[rstest]
1716    fn test_iterate_bids_processes_limits_before_stops() {
1717        // Both must match: ask=106 fills BUY limit at 110 (106 <= 110) AND
1718        // triggers BUY stop at 101 (106 >= 101). Limits emit before stops.
1719        let mut core = create_matching_core(InstrumentId::from("AAPL.XNAS"), Price::from("0.01"));
1720        core.set_ask_raw(Price::from("106.00"));
1721
1722        core.add_order(limit_order(OrderSide::Buy, "110.00", "O-LMT"));
1723        core.add_order(stop_order(OrderSide::Buy, "101.00", "O-STP"));
1724
1725        let actions = core.iterate_bids();
1726        assert_eq!(
1727            actions,
1728            vec![
1729                MatchAction::FillLimit(ClientOrderId::from("O-LMT")),
1730                MatchAction::TriggerStop(ClientOrderId::from("O-STP")),
1731            ],
1732        );
1733    }
1734
1735    #[rstest]
1736    fn test_iterate_asks_processes_limits_before_stops() {
1737        // Both must match: bid=94 fills SELL limit at 90 (94 >= 90) AND
1738        // triggers SELL stop at 99 (94 <= 99). Limits emit before stops.
1739        let mut core = create_matching_core(InstrumentId::from("AAPL.XNAS"), Price::from("0.01"));
1740        core.set_bid_raw(Price::from("94.00"));
1741
1742        core.add_order(limit_order(OrderSide::Sell, "90.00", "O-LMT"));
1743        core.add_order(stop_order(OrderSide::Sell, "99.00", "O-STP"));
1744
1745        let actions = core.iterate_asks();
1746        assert_eq!(
1747            actions,
1748            vec![
1749                MatchAction::FillLimit(ClientOrderId::from("O-LMT")),
1750                MatchAction::TriggerStop(ClientOrderId::from("O-STP")),
1751            ],
1752        );
1753    }
1754
1755    #[rstest]
1756    fn test_stop_limit_routed_to_stop_book_keyed_by_trigger() {
1757        // STOP_LIMIT has both prices set. is_stop() is true (because
1758        // trigger_price.is_some()), so it must live in the stop book and be
1759        // keyed by trigger_price for trigger-priority iteration.
1760        let mut core = create_matching_core(InstrumentId::from("AAPL.XNAS"), Price::from("0.01"));
1761        core.set_ask_raw(Price::from("106.00"));
1762
1763        // Two STOP_LIMIT BUYs at different triggers; the closer trigger
1764        // (101) must fire first regardless of limit prices.
1765        core.add_order(stop_limit_order(
1766            OrderSide::Buy,
1767            "105.00",
1768            "110.00",
1769            "O-FAR",
1770        ));
1771        core.add_order(stop_limit_order(
1772            OrderSide::Buy,
1773            "101.00",
1774            "110.00",
1775            "O-NEAR",
1776        ));
1777
1778        let actions = core.iterate_bids();
1779        assert_eq!(
1780            actions,
1781            vec![
1782                MatchAction::TriggerStop(ClientOrderId::from("O-NEAR")),
1783                MatchAction::TriggerStop(ClientOrderId::from("O-FAR")),
1784            ],
1785        );
1786    }
1787
1788    #[rstest]
1789    fn test_iterate_full_walk_combines_bids_then_asks_each_with_limits_then_stops() {
1790        // Both sides matchable simultaneously requires limits priced beyond
1791        // the touch and stops nearer to the touch.
1792        // Bid: ask=106 -> BUY limits at 110/107 fill (106 <= each), BUY stops
1793        // at 101/105 trigger (106 >= each).
1794        // Ask: bid=94 -> SELL limits at 90/93 fill (94 >= each), SELL stops
1795        // at 95/99 trigger (94 <= each).
1796        let mut core = create_matching_core(InstrumentId::from("AAPL.XNAS"), Price::from("0.01"));
1797        core.set_bid_raw(Price::from("94.00"));
1798        core.set_ask_raw(Price::from("106.00"));
1799
1800        core.add_order(limit_order(OrderSide::Buy, "110.00", "O-B-LMT-HIGH"));
1801        core.add_order(limit_order(OrderSide::Buy, "107.00", "O-B-LMT-LOW"));
1802        core.add_order(stop_order(OrderSide::Buy, "105.00", "O-B-STP-FAR"));
1803        core.add_order(stop_order(OrderSide::Buy, "101.00", "O-B-STP-NEAR"));
1804
1805        core.add_order(limit_order(OrderSide::Sell, "90.00", "O-A-LMT-LOW"));
1806        core.add_order(limit_order(OrderSide::Sell, "93.00", "O-A-LMT-HIGH"));
1807        core.add_order(stop_order(OrderSide::Sell, "95.00", "O-A-STP-FAR"));
1808        core.add_order(stop_order(OrderSide::Sell, "99.00", "O-A-STP-NEAR"));
1809
1810        let actions = core.iterate();
1811        assert_eq!(
1812            actions,
1813            vec![
1814                // bids: limits high-to-low, then stops near-to-far
1815                MatchAction::FillLimit(ClientOrderId::from("O-B-LMT-HIGH")),
1816                MatchAction::FillLimit(ClientOrderId::from("O-B-LMT-LOW")),
1817                MatchAction::TriggerStop(ClientOrderId::from("O-B-STP-NEAR")),
1818                MatchAction::TriggerStop(ClientOrderId::from("O-B-STP-FAR")),
1819                // asks: limits low-to-high, then stops near-to-far
1820                MatchAction::FillLimit(ClientOrderId::from("O-A-LMT-LOW")),
1821                MatchAction::FillLimit(ClientOrderId::from("O-A-LMT-HIGH")),
1822                MatchAction::TriggerStop(ClientOrderId::from("O-A-STP-NEAR")),
1823                MatchAction::TriggerStop(ClientOrderId::from("O-A-STP-FAR")),
1824            ],
1825        );
1826    }
1827
1828    #[rstest]
1829    fn test_pending_orders_skipped_in_iterate_but_visible_in_get_orders() {
1830        let instrument_id = InstrumentId::from("AAPL.XNAS");
1831        let mut core = create_matching_core(instrument_id, Price::from("0.01"));
1832        core.set_bid_raw(Price::from("99.00"));
1833        core.set_ask_raw(Price::from("100.00"));
1834
1835        // Real orders.
1836        core.add_order(limit_order(OrderSide::Buy, "100.00", "O-LMT"));
1837
1838        // A pending (no key) order.
1839        let pending = RestingOrder::new(
1840            ClientOrderId::from("O-PENDING"),
1841            OrderSide::Buy,
1842            OrderType::MarketToLimit,
1843            None,
1844            None,
1845            true,
1846        );
1847        core.add_order(pending);
1848
1849        // iterate sees only the limit; the pending order has no price to match.
1850        assert_eq!(
1851            core.iterate_bids(),
1852            vec![MatchAction::FillLimit(ClientOrderId::from("O-LMT"))],
1853        );
1854
1855        // get_orders sees both: bucketed first, pending appended.
1856        let bid_ids: Vec<_> = core
1857            .get_orders_bid()
1858            .iter()
1859            .map(|o| o.client_order_id)
1860            .collect();
1861        assert_eq!(
1862            bid_ids,
1863            vec![
1864                ClientOrderId::from("O-LMT"),
1865                ClientOrderId::from("O-PENDING"),
1866            ],
1867        );
1868    }
1869
1870    #[rstest]
1871    fn test_modify_then_readd_moves_order_to_back_of_new_level() {
1872        // A price-changing modify is delete + add; the re-added order must
1873        // land at the back of the new price level (queue-position loss),
1874        // matching real-venue behavior.
1875        let mut core = create_matching_core(InstrumentId::from("AAPL.XNAS"), Price::from("0.01"));
1876        core.set_ask_raw(Price::from("99.00"));
1877
1878        core.add_order(limit_order(OrderSide::Buy, "100.00", "O-A"));
1879        core.add_order(limit_order(OrderSide::Buy, "100.00", "O-B"));
1880        core.add_order(limit_order(OrderSide::Buy, "100.00", "O-C"));
1881
1882        // O-A modifies its price to 100.50 (better): moves to a new level.
1883        core.delete_order(ClientOrderId::from("O-A")).unwrap();
1884        core.add_order(limit_order(OrderSide::Buy, "100.50", "O-A"));
1885
1886        // O-B then modifies to 100.00 in place (price unchanged via re-add):
1887        // loses queue position to O-C at the same level.
1888        core.delete_order(ClientOrderId::from("O-B")).unwrap();
1889        core.add_order(limit_order(OrderSide::Buy, "100.00", "O-B"));
1890
1891        let actions = core.iterate_bids();
1892        assert_eq!(
1893            actions,
1894            vec![
1895                MatchAction::FillLimit(ClientOrderId::from("O-A")), // 100.50 best
1896                MatchAction::FillLimit(ClientOrderId::from("O-C")), // 100.00 oldest
1897                MatchAction::FillLimit(ClientOrderId::from("O-B")), // 100.00 newest
1898            ],
1899        );
1900    }
1901
1902    #[rstest]
1903    fn test_delete_unknown_order_returns_not_found() {
1904        let mut core = create_matching_core(InstrumentId::from("AAPL.XNAS"), Price::from("0.01"));
1905        let result = core.delete_order(ClientOrderId::from("O-MISSING"));
1906        assert!(matches!(result, Err(OrderError::NotFound(_))));
1907    }
1908}