Skip to main content

nautilus_execution/matching_core/
mod.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//! A common `OrderMatchingCore` for the `OrderMatchingEngine` and other components.
17
18use nautilus_model::{
19    enums::{OrderSideSpecified, OrderType},
20    identifiers::{ClientOrderId, InstrumentId},
21    orders::{Order, OrderError, PassiveOrderAny, StopOrderAny},
22    types::Price,
23};
24
25/// An action returned by [`OrderMatchingCore::iterate`] when an order matches.
26#[derive(Debug, Clone, Copy, PartialEq, Eq)]
27pub enum MatchAction {
28    FillLimit(ClientOrderId),
29    TriggerStop(ClientOrderId),
30}
31
32/// Lightweight order information for matching/trigger checking.
33#[derive(Clone, Debug, PartialEq, Eq)]
34pub struct OrderMatchInfo {
35    pub client_order_id: ClientOrderId,
36    pub order_side: OrderSideSpecified,
37    pub order_type: OrderType,
38    pub trigger_price: Option<Price>,
39    pub limit_price: Option<Price>,
40    pub is_activated: bool,
41}
42
43impl OrderMatchInfo {
44    /// Creates a new [`OrderMatchInfo`] instance.
45    #[must_use]
46    pub const fn new(
47        client_order_id: ClientOrderId,
48        order_side: OrderSideSpecified,
49        order_type: OrderType,
50        trigger_price: Option<Price>,
51        limit_price: Option<Price>,
52        is_activated: bool,
53    ) -> Self {
54        Self {
55            client_order_id,
56            order_side,
57            order_type,
58            trigger_price,
59            limit_price,
60            is_activated,
61        }
62    }
63
64    /// Returns true if this is a stop order type that needs trigger checking.
65    #[must_use]
66    pub const fn is_stop(&self) -> bool {
67        self.trigger_price.is_some()
68    }
69
70    /// Returns true if this is a limit order type that needs fill checking.
71    #[must_use]
72    pub const fn is_limit(&self) -> bool {
73        self.limit_price.is_some() && self.trigger_price.is_none()
74    }
75}
76
77impl From<&PassiveOrderAny> for OrderMatchInfo {
78    fn from(order: &PassiveOrderAny) -> Self {
79        match order {
80            PassiveOrderAny::Limit(limit) => Self {
81                client_order_id: limit.client_order_id(),
82                order_side: limit.order_side_specified(),
83                order_type: limit.order_type(),
84                trigger_price: None,
85                limit_price: Some(limit.limit_px()),
86                is_activated: true,
87            },
88            PassiveOrderAny::Stop(stop) => {
89                let limit_price = match stop {
90                    StopOrderAny::LimitIfTouched(o) => Some(o.price),
91                    StopOrderAny::StopLimit(o) => Some(o.price),
92                    StopOrderAny::TrailingStopLimit(o) => Some(o.price),
93                    StopOrderAny::MarketIfTouched(_)
94                    | StopOrderAny::StopMarket(_)
95                    | StopOrderAny::TrailingStopMarket(_) => None,
96                };
97                let is_activated = match stop {
98                    StopOrderAny::TrailingStopMarket(o) => o.is_activated,
99                    StopOrderAny::TrailingStopLimit(o) => o.is_activated,
100                    _ => true,
101                };
102                Self {
103                    client_order_id: stop.client_order_id(),
104                    order_side: stop.order_side_specified(),
105                    order_type: stop.order_type(),
106                    trigger_price: Some(stop.stop_px()),
107                    limit_price,
108                    is_activated,
109                }
110            }
111        }
112    }
113}
114
115/// A generic order matching core.
116#[derive(Clone, Debug)]
117pub struct OrderMatchingCore {
118    /// The instrument ID for the matching core.
119    pub instrument_id: InstrumentId,
120    /// The price increment for the matching core.
121    pub price_increment: Price,
122    /// The current bid price for the matching core.
123    pub bid: Option<Price>,
124    /// The current ask price for the matching core.
125    pub ask: Option<Price>,
126    /// The last price for the matching core.
127    pub last: Option<Price>,
128    pub is_bid_initialized: bool,
129    pub is_ask_initialized: bool,
130    pub is_last_initialized: bool,
131    fill_limit_inside_spread: bool,
132    orders_bid: Vec<OrderMatchInfo>,
133    orders_ask: Vec<OrderMatchInfo>,
134}
135
136impl OrderMatchingCore {
137    // Creates a new [`OrderMatchingCore`] instance.
138    #[must_use]
139    pub const fn new(instrument_id: InstrumentId, price_increment: Price) -> Self {
140        Self {
141            instrument_id,
142            price_increment,
143            bid: None,
144            ask: None,
145            last: None,
146            is_bid_initialized: false,
147            is_ask_initialized: false,
148            is_last_initialized: false,
149            fill_limit_inside_spread: false,
150            orders_bid: Vec::new(),
151            orders_ask: Vec::new(),
152        }
153    }
154
155    #[must_use]
156    pub const fn price_precision(&self) -> u8 {
157        self.price_increment.precision
158    }
159
160    #[must_use]
161    pub fn get_order(&self, client_order_id: ClientOrderId) -> Option<&OrderMatchInfo> {
162        self.orders_bid
163            .iter()
164            .find(|o| o.client_order_id == client_order_id)
165            .or_else(|| {
166                self.orders_ask
167                    .iter()
168                    .find(|o| o.client_order_id == client_order_id)
169            })
170    }
171
172    #[must_use]
173    pub const fn get_orders_bid(&self) -> &[OrderMatchInfo] {
174        self.orders_bid.as_slice()
175    }
176
177    #[must_use]
178    pub const fn get_orders_ask(&self) -> &[OrderMatchInfo] {
179        self.orders_ask.as_slice()
180    }
181
182    #[must_use]
183    pub fn get_orders(&self) -> Vec<OrderMatchInfo> {
184        let mut orders = self.orders_bid.clone();
185        orders.extend_from_slice(&self.orders_ask);
186        orders
187    }
188
189    #[must_use]
190    pub fn order_exists(&self, client_order_id: ClientOrderId) -> bool {
191        self.orders_bid
192            .iter()
193            .any(|o| o.client_order_id == client_order_id)
194            || self
195                .orders_ask
196                .iter()
197                .any(|o| o.client_order_id == client_order_id)
198    }
199
200    pub const fn set_last_raw(&mut self, last: Price) {
201        self.last = Some(last);
202        self.is_last_initialized = true;
203    }
204
205    pub const fn set_bid_raw(&mut self, bid: Price) {
206        self.bid = Some(bid);
207        self.is_bid_initialized = true;
208    }
209
210    pub const fn set_ask_raw(&mut self, ask: Price) {
211        self.ask = Some(ask);
212        self.is_ask_initialized = true;
213    }
214
215    pub fn reset(&mut self) {
216        self.bid = None;
217        self.ask = None;
218        self.last = None;
219        self.orders_bid.clear();
220        self.orders_ask.clear();
221    }
222
223    /// Adds an order to the matching core.
224    pub fn add_order(&mut self, order: OrderMatchInfo) {
225        match order.order_side {
226            OrderSideSpecified::Buy => self.orders_bid.push(order),
227            OrderSideSpecified::Sell => self.orders_ask.push(order),
228        }
229    }
230
231    /// Deletes an order from the matching core by client order ID.
232    ///
233    /// # Errors
234    ///
235    /// Returns an [`OrderError::NotFound`] if the order is not present.
236    pub fn delete_order(&mut self, client_order_id: ClientOrderId) -> Result<(), OrderError> {
237        if let Some(index) = self
238            .orders_bid
239            .iter()
240            .position(|o| o.client_order_id == client_order_id)
241        {
242            self.orders_bid.remove(index);
243            return Ok(());
244        }
245
246        if let Some(index) = self
247            .orders_ask
248            .iter()
249            .position(|o| o.client_order_id == client_order_id)
250        {
251            self.orders_ask.remove(index);
252            return Ok(());
253        }
254
255        Err(OrderError::NotFound(client_order_id))
256    }
257
258    pub fn iterate(&mut self) -> Vec<MatchAction> {
259        let mut actions = self.iterate_bids();
260        actions.extend(self.iterate_asks());
261        actions
262    }
263
264    pub fn iterate_bids(&mut self) -> Vec<MatchAction> {
265        let orders: Vec<_> = self.orders_bid.clone();
266        orders
267            .iter()
268            .filter_map(|order| self.match_order(order))
269            .collect()
270    }
271
272    pub fn iterate_asks(&mut self) -> Vec<MatchAction> {
273        let orders: Vec<_> = self.orders_ask.clone();
274        orders
275            .iter()
276            .filter_map(|order| self.match_order(order))
277            .collect()
278    }
279
280    pub fn match_order(&self, order: &OrderMatchInfo) -> Option<MatchAction> {
281        if order.is_stop() {
282            self.match_stop_order(order)
283        } else if order.is_limit() {
284            self.match_limit_order(order)
285        } else {
286            None
287        }
288    }
289
290    fn match_limit_order(&self, order: &OrderMatchInfo) -> Option<MatchAction> {
291        if let Some(limit_price) = order.limit_price
292            && self.is_limit_fillable(order.order_side, limit_price)
293        {
294            Some(MatchAction::FillLimit(order.client_order_id))
295        } else {
296            None
297        }
298    }
299
300    fn match_stop_order(&self, order: &OrderMatchInfo) -> Option<MatchAction> {
301        if !order.is_activated {
302            return None;
303        }
304
305        if let Some(trigger_price) = order.trigger_price
306            && self.is_stop_matched(order.order_side, trigger_price)
307        {
308            Some(MatchAction::TriggerStop(order.client_order_id))
309        } else {
310            None
311        }
312    }
313
314    #[must_use]
315    pub fn is_limit_matched(&self, side: OrderSideSpecified, price: Price) -> bool {
316        match side {
317            OrderSideSpecified::Buy => self.ask.is_some_and(|a| a <= price),
318            OrderSideSpecified::Sell => self.bid.is_some_and(|b| b >= price),
319        }
320    }
321
322    #[must_use]
323    pub fn is_stop_matched(&self, side: OrderSideSpecified, price: Price) -> bool {
324        match side {
325            OrderSideSpecified::Buy => self.ask.is_some_and(|a| a >= price),
326            OrderSideSpecified::Sell => self.bid.is_some_and(|b| b <= price),
327        }
328    }
329
330    #[must_use]
331    pub fn is_touch_triggered(&self, side: OrderSideSpecified, trigger_price: Price) -> bool {
332        match side {
333            OrderSideSpecified::Buy => self.ask.is_some_and(|a| a <= trigger_price),
334            OrderSideSpecified::Sell => self.bid.is_some_and(|b| b >= trigger_price),
335        }
336    }
337
338    pub fn set_fill_limit_inside_spread(&mut self, value: bool) {
339        self.fill_limit_inside_spread = value;
340    }
341
342    /// Returns whether a limit order is fillable at the given price.
343    ///
344    /// Checks `is_limit_matched` first (crosses the spread). When
345    /// `fill_limit_inside_spread` is set, also checks at-or-inside spread
346    /// (BUY >= bid, SELL <= ask), requiring both sides initialized.
347    #[must_use]
348    pub fn is_limit_fillable(&self, side: OrderSideSpecified, price: Price) -> bool {
349        if self.is_limit_matched(side, price) {
350            return true;
351        }
352
353        if !self.fill_limit_inside_spread {
354            return false;
355        }
356
357        // Require both quotes present since fill simulation needs best bid and ask
358        if let (Some(bid), Some(ask)) = (self.bid, self.ask) {
359            match side {
360                OrderSideSpecified::Buy => price >= bid,
361                OrderSideSpecified::Sell => price <= ask,
362            }
363        } else {
364            false
365        }
366    }
367}
368
369#[cfg(test)]
370mod tests {
371    use nautilus_model::{
372        enums::{OrderSide, OrderType},
373        orders::{Order, builder::OrderTestBuilder},
374        types::Quantity,
375    };
376    use rstest::rstest;
377
378    use super::*;
379
380    const fn create_matching_core(
381        instrument_id: InstrumentId,
382        price_increment: Price,
383    ) -> OrderMatchingCore {
384        OrderMatchingCore::new(instrument_id, price_increment)
385    }
386
387    #[rstest]
388    fn test_add_order_bid_side() {
389        let instrument_id = InstrumentId::from("AAPL.XNAS");
390        let mut matching_core = create_matching_core(instrument_id, Price::from("0.01"));
391
392        let order = OrderTestBuilder::new(OrderType::Limit)
393            .instrument_id(instrument_id)
394            .side(OrderSide::Buy)
395            .price(Price::from("100.00"))
396            .quantity(Quantity::from("100"))
397            .build();
398
399        let match_info = OrderMatchInfo::from(&PassiveOrderAny::try_from(order).unwrap());
400        matching_core.add_order(match_info.clone());
401
402        assert!(matching_core.get_orders_bid().contains(&match_info));
403        assert!(!matching_core.get_orders_ask().contains(&match_info));
404        assert_eq!(matching_core.get_orders_bid().len(), 1);
405        assert!(matching_core.get_orders_ask().is_empty());
406        assert!(matching_core.order_exists(match_info.client_order_id));
407    }
408
409    #[rstest]
410    fn test_add_order_ask_side() {
411        let instrument_id = InstrumentId::from("AAPL.XNAS");
412        let mut matching_core = create_matching_core(instrument_id, Price::from("0.01"));
413
414        let order = OrderTestBuilder::new(OrderType::Limit)
415            .instrument_id(instrument_id)
416            .side(OrderSide::Sell)
417            .price(Price::from("100.00"))
418            .quantity(Quantity::from("100"))
419            .build();
420
421        let match_info = OrderMatchInfo::from(&PassiveOrderAny::try_from(order).unwrap());
422        matching_core.add_order(match_info.clone());
423
424        assert!(matching_core.get_orders_ask().contains(&match_info));
425        assert!(!matching_core.get_orders_bid().contains(&match_info));
426        assert_eq!(matching_core.get_orders_ask().len(), 1);
427        assert!(matching_core.get_orders_bid().is_empty());
428        assert!(matching_core.order_exists(match_info.client_order_id));
429    }
430
431    #[rstest]
432    fn test_reset() {
433        let instrument_id = InstrumentId::from("AAPL.XNAS");
434        let mut matching_core = create_matching_core(instrument_id, Price::from("0.01"));
435
436        let order = OrderTestBuilder::new(OrderType::Limit)
437            .instrument_id(instrument_id)
438            .side(OrderSide::Sell)
439            .price(Price::from("100.00"))
440            .quantity(Quantity::from("100"))
441            .build();
442
443        let client_order_id = order.client_order_id();
444        let match_info = OrderMatchInfo::from(&PassiveOrderAny::try_from(order).unwrap());
445        matching_core.add_order(match_info);
446        matching_core.bid = Some(Price::from("100.00"));
447        matching_core.ask = Some(Price::from("100.00"));
448        matching_core.last = Some(Price::from("100.00"));
449
450        matching_core.reset();
451
452        assert!(matching_core.bid.is_none());
453        assert!(matching_core.ask.is_none());
454        assert!(matching_core.last.is_none());
455        assert!(matching_core.get_orders_bid().is_empty());
456        assert!(matching_core.get_orders_ask().is_empty());
457        assert!(!matching_core.order_exists(client_order_id));
458    }
459
460    #[rstest]
461    fn test_delete_order_when_not_exists() {
462        let instrument_id = InstrumentId::from("AAPL.XNAS");
463        let mut matching_core = create_matching_core(instrument_id, Price::from("0.01"));
464
465        let order = OrderTestBuilder::new(OrderType::Limit)
466            .instrument_id(instrument_id)
467            .side(OrderSide::Buy)
468            .price(Price::from("100.00"))
469            .quantity(Quantity::from("100"))
470            .build();
471
472        let result = matching_core.delete_order(order.client_order_id());
473        assert!(result.is_err());
474    }
475
476    #[rstest]
477    #[case(OrderSide::Buy)]
478    #[case(OrderSide::Sell)]
479    fn test_delete_order_when_exists(#[case] order_side: OrderSide) {
480        let instrument_id = InstrumentId::from("AAPL.XNAS");
481        let mut matching_core = create_matching_core(instrument_id, Price::from("0.01"));
482
483        let order = OrderTestBuilder::new(OrderType::Limit)
484            .instrument_id(instrument_id)
485            .side(order_side)
486            .price(Price::from("100.00"))
487            .quantity(Quantity::from("100"))
488            .build();
489
490        let client_order_id = order.client_order_id();
491        let match_info = OrderMatchInfo::from(&PassiveOrderAny::try_from(order).unwrap());
492        matching_core.add_order(match_info);
493        matching_core.delete_order(client_order_id).unwrap();
494
495        assert!(matching_core.get_orders_ask().is_empty());
496        assert!(matching_core.get_orders_bid().is_empty());
497    }
498
499    #[rstest]
500    #[case(None, None, Price::from("100.00"), OrderSide::Buy, false)]
501    #[case(None, None, Price::from("100.00"), OrderSide::Sell, false)]
502    #[case(
503        Some(Price::from("100.00")),
504        Some(Price::from("101.00")),
505        Price::from("100.00"),  // <-- Price below ask
506        OrderSide::Buy,
507        false
508    )]
509    #[case(
510        Some(Price::from("100.00")),
511        Some(Price::from("101.00")),
512        Price::from("101.00"),  // <-- Price at ask
513        OrderSide::Buy,
514        true
515    )]
516    #[case(
517        Some(Price::from("100.00")),
518        Some(Price::from("101.00")),
519        Price::from("102.00"),  // <-- Price above ask (marketable)
520        OrderSide::Buy,
521        true
522    )]
523    #[case(
524        Some(Price::from("100.00")),
525        Some(Price::from("101.00")),
526        Price::from("101.00"), // <-- Price above bid
527        OrderSide::Sell,
528        false
529    )]
530    #[case(
531        Some(Price::from("100.00")),
532        Some(Price::from("101.00")),
533        Price::from("100.00"),  // <-- Price at bid
534        OrderSide::Sell,
535        true
536    )]
537    #[case(
538        Some(Price::from("100.00")),
539        Some(Price::from("101.00")),
540        Price::from("99.00"),  // <-- Price below bid (marketable)
541        OrderSide::Sell,
542        true
543    )]
544    fn test_is_limit_matched(
545        #[case] bid: Option<Price>,
546        #[case] ask: Option<Price>,
547        #[case] price: Price,
548        #[case] order_side: OrderSide,
549        #[case] expected: bool,
550    ) {
551        let instrument_id = InstrumentId::from("AAPL.XNAS");
552        let mut matching_core = create_matching_core(instrument_id, Price::from("0.01"));
553        matching_core.bid = bid;
554        matching_core.ask = ask;
555
556        let order = OrderTestBuilder::new(OrderType::Limit)
557            .instrument_id(instrument_id)
558            .side(order_side)
559            .price(price)
560            .quantity(Quantity::from("100"))
561            .build();
562
563        let result =
564            matching_core.is_limit_matched(order.order_side_specified(), order.price().unwrap());
565        assert_eq!(result, expected);
566    }
567
568    #[rstest]
569    #[case(None, None, Price::from("100.00"), OrderSide::Buy, false)]
570    #[case(None, None, Price::from("100.00"), OrderSide::Sell, false)]
571    #[case(
572        Some(Price::from("100.00")),
573        Some(Price::from("101.00")),
574        Price::from("102.00"),  // <-- Trigger above ask
575        OrderSide::Buy,
576        false
577    )]
578    #[case(
579        Some(Price::from("100.00")),
580        Some(Price::from("101.00")),
581        Price::from("101.00"),  // <-- Trigger at ask
582        OrderSide::Buy,
583        true
584    )]
585    #[case(
586        Some(Price::from("100.00")),
587        Some(Price::from("101.00")),
588        Price::from("100.00"),  // <-- Trigger below ask
589        OrderSide::Buy,
590        true
591    )]
592    #[case(
593        Some(Price::from("100.00")),
594        Some(Price::from("101.00")),
595        Price::from("99.00"),  // Trigger below bid
596        OrderSide::Sell,
597        false
598    )]
599    #[case(
600        Some(Price::from("100.00")),
601        Some(Price::from("101.00")),
602        Price::from("100.00"),  // <-- Trigger at bid
603        OrderSide::Sell,
604        true
605    )]
606    #[case(
607        Some(Price::from("100.00")),
608        Some(Price::from("101.00")),
609        Price::from("101.00"),  // <-- Trigger above bid
610        OrderSide::Sell,
611        true
612    )]
613    fn test_is_stop_matched(
614        #[case] bid: Option<Price>,
615        #[case] ask: Option<Price>,
616        #[case] trigger_price: Price,
617        #[case] order_side: OrderSide,
618        #[case] expected: bool,
619    ) {
620        let instrument_id = InstrumentId::from("AAPL.XNAS");
621        let mut matching_core = create_matching_core(instrument_id, Price::from("0.01"));
622        matching_core.bid = bid;
623        matching_core.ask = ask;
624
625        let order = OrderTestBuilder::new(OrderType::StopMarket)
626            .instrument_id(instrument_id)
627            .side(order_side)
628            .trigger_price(trigger_price)
629            .quantity(Quantity::from("100"))
630            .build();
631
632        let result = matching_core
633            .is_stop_matched(order.order_side_specified(), order.trigger_price().unwrap());
634        assert_eq!(result, expected);
635    }
636
637    #[rstest]
638    fn test_iterate_returns_empty_when_no_orders() {
639        let instrument_id = InstrumentId::from("AAPL.XNAS");
640        let mut matching_core = create_matching_core(instrument_id, Price::from("0.01"));
641        matching_core.bid = Some(Price::from("100.00"));
642        matching_core.ask = Some(Price::from("101.00"));
643
644        let actions = matching_core.iterate();
645
646        assert!(actions.is_empty());
647    }
648
649    #[rstest]
650    fn test_iterate_returns_empty_when_no_market_data() {
651        let instrument_id = InstrumentId::from("AAPL.XNAS");
652        let mut matching_core = create_matching_core(instrument_id, Price::from("0.01"));
653
654        let order = OrderTestBuilder::new(OrderType::Limit)
655            .instrument_id(instrument_id)
656            .side(OrderSide::Buy)
657            .price(Price::from("100.00"))
658            .quantity(Quantity::from("100"))
659            .build();
660        let match_info = OrderMatchInfo::from(&PassiveOrderAny::try_from(order).unwrap());
661        matching_core.add_order(match_info);
662
663        let actions = matching_core.iterate();
664
665        assert!(actions.is_empty());
666    }
667
668    #[rstest]
669    fn test_iterate_returns_fill_limit_for_matched_buy() {
670        let instrument_id = InstrumentId::from("AAPL.XNAS");
671        let mut matching_core = create_matching_core(instrument_id, Price::from("0.01"));
672        matching_core.ask = Some(Price::from("100.00"));
673
674        let order = OrderTestBuilder::new(OrderType::Limit)
675            .instrument_id(instrument_id)
676            .side(OrderSide::Buy)
677            .price(Price::from("100.00"))
678            .quantity(Quantity::from("100"))
679            .build();
680        let client_order_id = order.client_order_id();
681        let match_info = OrderMatchInfo::from(&PassiveOrderAny::try_from(order).unwrap());
682        matching_core.add_order(match_info);
683
684        let actions = matching_core.iterate();
685
686        assert_eq!(actions, vec![MatchAction::FillLimit(client_order_id)]);
687    }
688
689    #[rstest]
690    fn test_iterate_returns_fill_limit_for_matched_sell() {
691        let instrument_id = InstrumentId::from("AAPL.XNAS");
692        let mut matching_core = create_matching_core(instrument_id, Price::from("0.01"));
693        matching_core.bid = Some(Price::from("100.00"));
694
695        let order = OrderTestBuilder::new(OrderType::Limit)
696            .instrument_id(instrument_id)
697            .side(OrderSide::Sell)
698            .price(Price::from("100.00"))
699            .quantity(Quantity::from("100"))
700            .build();
701        let client_order_id = order.client_order_id();
702        let match_info = OrderMatchInfo::from(&PassiveOrderAny::try_from(order).unwrap());
703        matching_core.add_order(match_info);
704
705        let actions = matching_core.iterate();
706
707        assert_eq!(actions, vec![MatchAction::FillLimit(client_order_id)]);
708    }
709
710    #[rstest]
711    fn test_iterate_returns_no_fill_for_unmatched_limit() {
712        let instrument_id = InstrumentId::from("AAPL.XNAS");
713        let mut matching_core = create_matching_core(instrument_id, Price::from("0.01"));
714        matching_core.ask = Some(Price::from("101.00"));
715
716        // Buy limit at 100 with ask at 101 — not matched
717        let order = OrderTestBuilder::new(OrderType::Limit)
718            .instrument_id(instrument_id)
719            .side(OrderSide::Buy)
720            .price(Price::from("100.00"))
721            .quantity(Quantity::from("100"))
722            .build();
723        let match_info = OrderMatchInfo::from(&PassiveOrderAny::try_from(order).unwrap());
724        matching_core.add_order(match_info);
725
726        let actions = matching_core.iterate();
727
728        assert!(actions.is_empty());
729    }
730
731    #[rstest]
732    fn test_iterate_returns_trigger_stop_for_matched_buy() {
733        let instrument_id = InstrumentId::from("AAPL.XNAS");
734        let mut matching_core = create_matching_core(instrument_id, Price::from("0.01"));
735        matching_core.ask = Some(Price::from("101.00"));
736
737        let order = OrderTestBuilder::new(OrderType::StopMarket)
738            .instrument_id(instrument_id)
739            .side(OrderSide::Buy)
740            .trigger_price(Price::from("101.00"))
741            .quantity(Quantity::from("100"))
742            .build();
743        let client_order_id = order.client_order_id();
744        let match_info = OrderMatchInfo::from(&PassiveOrderAny::try_from(order).unwrap());
745        matching_core.add_order(match_info);
746
747        let actions = matching_core.iterate();
748
749        assert_eq!(actions, vec![MatchAction::TriggerStop(client_order_id)]);
750    }
751
752    #[rstest]
753    fn test_iterate_returns_trigger_stop_for_matched_sell() {
754        let instrument_id = InstrumentId::from("AAPL.XNAS");
755        let mut matching_core = create_matching_core(instrument_id, Price::from("0.01"));
756        matching_core.bid = Some(Price::from("99.00"));
757
758        let order = OrderTestBuilder::new(OrderType::StopMarket)
759            .instrument_id(instrument_id)
760            .side(OrderSide::Sell)
761            .trigger_price(Price::from("99.00"))
762            .quantity(Quantity::from("100"))
763            .build();
764        let client_order_id = order.client_order_id();
765        let match_info = OrderMatchInfo::from(&PassiveOrderAny::try_from(order).unwrap());
766        matching_core.add_order(match_info);
767
768        let actions = matching_core.iterate();
769
770        assert_eq!(actions, vec![MatchAction::TriggerStop(client_order_id)]);
771    }
772
773    #[rstest]
774    fn test_iterate_skips_unactivated_stop_order() {
775        let instrument_id = InstrumentId::from("AAPL.XNAS");
776        let mut matching_core = create_matching_core(instrument_id, Price::from("0.01"));
777        matching_core.ask = Some(Price::from("110.00"));
778
779        // Manually create an unactivated stop (simulates trailing stop)
780        let match_info = OrderMatchInfo::new(
781            ClientOrderId::from("O-001"),
782            OrderSideSpecified::Buy,
783            OrderType::TrailingStopMarket,
784            Some(Price::from("105.00")),
785            None,
786            false, // not activated
787        );
788        matching_core.add_order(match_info);
789
790        let actions = matching_core.iterate();
791
792        assert!(actions.is_empty());
793    }
794
795    #[rstest]
796    fn test_iterate_triggers_activated_stop_order() {
797        let instrument_id = InstrumentId::from("AAPL.XNAS");
798        let mut matching_core = create_matching_core(instrument_id, Price::from("0.01"));
799        matching_core.ask = Some(Price::from("110.00"));
800
801        let client_order_id = ClientOrderId::from("O-001");
802        let match_info = OrderMatchInfo::new(
803            client_order_id,
804            OrderSideSpecified::Buy,
805            OrderType::TrailingStopMarket,
806            Some(Price::from("105.00")),
807            None,
808            true, // activated
809        );
810        matching_core.add_order(match_info);
811
812        let actions = matching_core.iterate();
813
814        assert_eq!(actions, vec![MatchAction::TriggerStop(client_order_id)]);
815    }
816
817    #[rstest]
818    fn test_iterate_returns_mixed_actions_for_limits_and_stops() {
819        let instrument_id = InstrumentId::from("AAPL.XNAS");
820        let mut matching_core = create_matching_core(instrument_id, Price::from("0.01"));
821        matching_core.bid = Some(Price::from("99.00"));
822        matching_core.ask = Some(Price::from("101.00"));
823
824        // Buy limit at 101 — matches (ask <= price)
825        let buy_limit = OrderTestBuilder::new(OrderType::Limit)
826            .instrument_id(instrument_id)
827            .side(OrderSide::Buy)
828            .price(Price::from("101.00"))
829            .quantity(Quantity::from("100"))
830            .build();
831        let buy_limit_id = buy_limit.client_order_id();
832        matching_core.add_order(OrderMatchInfo::from(
833            &PassiveOrderAny::try_from(buy_limit).unwrap(),
834        ));
835
836        // Sell stop at 99 — matches (bid <= trigger)
837        let sell_stop = OrderTestBuilder::new(OrderType::StopMarket)
838            .instrument_id(instrument_id)
839            .side(OrderSide::Sell)
840            .trigger_price(Price::from("99.00"))
841            .quantity(Quantity::from("50"))
842            .build();
843        let sell_stop_id = sell_stop.client_order_id();
844        matching_core.add_order(OrderMatchInfo::from(
845            &PassiveOrderAny::try_from(sell_stop).unwrap(),
846        ));
847
848        let actions = matching_core.iterate();
849
850        // Bids processed first, then asks
851        assert_eq!(actions.len(), 2);
852        assert_eq!(actions[0], MatchAction::FillLimit(buy_limit_id));
853        assert_eq!(actions[1], MatchAction::TriggerStop(sell_stop_id));
854    }
855
856    #[rstest]
857    fn test_is_limit_fillable_delegates_to_is_limit_matched_by_default() {
858        let instrument_id = InstrumentId::from("AAPL.XNAS");
859        let mut core = create_matching_core(instrument_id, Price::from("0.01"));
860        core.set_bid_raw(Price::from("100.00"));
861        core.set_ask_raw(Price::from("101.00"));
862
863        assert!(core.is_limit_fillable(OrderSideSpecified::Buy, Price::from("101.00")));
864        assert!(!core.is_limit_fillable(OrderSideSpecified::Buy, Price::from("100.00")));
865        assert!(core.is_limit_fillable(OrderSideSpecified::Sell, Price::from("100.00")));
866        assert!(!core.is_limit_fillable(OrderSideSpecified::Sell, Price::from("101.00")));
867    }
868
869    #[rstest]
870    fn test_is_limit_fillable_inside_spread_buy_at_bid() {
871        let instrument_id = InstrumentId::from("AAPL.XNAS");
872        let mut core = create_matching_core(instrument_id, Price::from("0.01"));
873        core.set_bid_raw(Price::from("100.00"));
874        core.set_ask_raw(Price::from("101.00"));
875        core.set_fill_limit_inside_spread(true);
876
877        assert!(core.is_limit_fillable(OrderSideSpecified::Buy, Price::from("100.00")));
878        assert!(core.is_limit_fillable(OrderSideSpecified::Buy, Price::from("100.50")));
879        assert!(!core.is_limit_fillable(OrderSideSpecified::Buy, Price::from("99.00")));
880    }
881
882    #[rstest]
883    fn test_is_limit_fillable_inside_spread_sell_at_ask() {
884        let instrument_id = InstrumentId::from("AAPL.XNAS");
885        let mut core = create_matching_core(instrument_id, Price::from("0.01"));
886        core.set_bid_raw(Price::from("100.00"));
887        core.set_ask_raw(Price::from("101.00"));
888        core.set_fill_limit_inside_spread(true);
889
890        assert!(core.is_limit_fillable(OrderSideSpecified::Sell, Price::from("101.00")));
891        assert!(core.is_limit_fillable(OrderSideSpecified::Sell, Price::from("100.50")));
892        assert!(!core.is_limit_fillable(OrderSideSpecified::Sell, Price::from("102.00")));
893    }
894
895    #[rstest]
896    fn test_is_limit_fillable_inside_spread_requires_both_quotes_present() {
897        let instrument_id = InstrumentId::from("AAPL.XNAS");
898        let mut core = create_matching_core(instrument_id, Price::from("0.01"));
899        core.set_fill_limit_inside_spread(true);
900
901        core.set_bid_raw(Price::from("100.00"));
902        assert!(!core.is_limit_fillable(OrderSideSpecified::Buy, Price::from("100.00")));
903
904        let mut core2 = create_matching_core(instrument_id, Price::from("0.01"));
905        core2.set_fill_limit_inside_spread(true);
906        core2.set_ask_raw(Price::from("101.00"));
907        assert!(!core2.is_limit_fillable(OrderSideSpecified::Sell, Price::from("101.00")));
908
909        // Ask cleared after both were set
910        let mut core3 = create_matching_core(instrument_id, Price::from("0.01"));
911        core3.set_fill_limit_inside_spread(true);
912        core3.set_bid_raw(Price::from("100.00"));
913        core3.set_ask_raw(Price::from("101.00"));
914        core3.ask = None;
915        assert!(!core3.is_limit_fillable(OrderSideSpecified::Buy, Price::from("100.00")));
916    }
917
918    #[rstest]
919    fn test_iterate_fills_limit_inside_spread_when_enabled() {
920        let instrument_id = InstrumentId::from("AAPL.XNAS");
921        let mut core = create_matching_core(instrument_id, Price::from("0.01"));
922        core.set_bid_raw(Price::from("100.00"));
923        core.set_ask_raw(Price::from("101.00"));
924        core.set_fill_limit_inside_spread(true);
925
926        let order = OrderTestBuilder::new(OrderType::Limit)
927            .instrument_id(instrument_id)
928            .side(OrderSide::Buy)
929            .price(Price::from("100.00"))
930            .quantity(Quantity::from("100"))
931            .build();
932        let client_order_id = order.client_order_id();
933        let match_info = OrderMatchInfo::from(&PassiveOrderAny::try_from(order).unwrap());
934        core.add_order(match_info);
935
936        let actions = core.iterate();
937        assert_eq!(actions, vec![MatchAction::FillLimit(client_order_id)]);
938    }
939
940    #[rstest]
941    #[case(None, None, Price::from("100.00"), OrderSide::Buy, false)]
942    #[case(None, None, Price::from("100.00"), OrderSide::Sell, false)]
943    #[case(
944        Some(Price::from("100.00")),
945        Some(Price::from("101.00")),
946        Price::from("102.00"),  // <-- Ask below trigger
947        OrderSide::Buy,
948        true
949    )]
950    #[case(
951        Some(Price::from("100.00")),
952        Some(Price::from("101.00")),
953        Price::from("101.00"),  // <-- Ask at trigger
954        OrderSide::Buy,
955        true
956    )]
957    #[case(
958        Some(Price::from("100.00")),
959        Some(Price::from("101.00")),
960        Price::from("100.00"),  // <-- Ask above trigger
961        OrderSide::Buy,
962        false
963    )]
964    #[case(
965        Some(Price::from("100.00")),
966        Some(Price::from("101.00")),
967        Price::from("99.00"),  // <-- Bid above trigger
968        OrderSide::Sell,
969        true
970    )]
971    #[case(
972        Some(Price::from("100.00")),
973        Some(Price::from("101.00")),
974        Price::from("100.00"),  // <-- Bid at trigger
975        OrderSide::Sell,
976        true
977    )]
978    #[case(
979        Some(Price::from("100.00")),
980        Some(Price::from("101.00")),
981        Price::from("101.00"),  // <-- Bid below trigger
982        OrderSide::Sell,
983        false
984    )]
985    fn test_is_touch_triggered(
986        #[case] bid: Option<Price>,
987        #[case] ask: Option<Price>,
988        #[case] trigger_price: Price,
989        #[case] order_side: OrderSide,
990        #[case] expected: bool,
991    ) {
992        let instrument_id = InstrumentId::from("AAPL.XNAS");
993        let mut matching_core = create_matching_core(instrument_id, Price::from("0.01"));
994        matching_core.bid = bid;
995        matching_core.ask = ask;
996
997        let result = matching_core.is_touch_triggered(order_side.as_specified(), trigger_price);
998        assert_eq!(result, expected);
999    }
1000}