Skip to main content

nautilus_live/
testing.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//! Engine-wired support for live adapter integration tests.
17//!
18//! This module records whether adapter output follows the typed order-event path or the
19//! reconciliation-report path before routing each event through the live runner.
20
21#![warn(rustc::all)]
22#![warn(clippy::pedantic)]
23#![allow(
24    clippy::manual_let_else,
25    clippy::option_if_let_else,
26    clippy::or_fun_call,
27    clippy::redundant_else,
28    clippy::similar_names,
29    clippy::single_match_else,
30    reason = "workspace policy; generated by scripts/update-clippy-allowances.py"
31)]
32#![deny(unsafe_code)]
33#![deny(unsafe_op_in_unsafe_fn)]
34#![deny(nonstandard_style)]
35#![deny(missing_debug_implementations)]
36#![deny(rustdoc::broken_intra_doc_links)]
37#![allow(
38    clippy::missing_panics_doc,
39    reason = "test support reports invalid setup and failed assertions by panicking"
40)]
41
42use std::{cell::RefCell, fmt::Debug, rc::Rc, time::Duration};
43
44use nautilus_common::{
45    cache::Cache,
46    clients::ExecutionClient,
47    clock::{Clock, VirtualClock},
48    live::{
49        dst,
50        runner::{replace_data_event_sender, replace_exec_event_sender},
51    },
52    messages::{
53        ExecutionEvent,
54        execution::{
55            TradingCommand, cancel::CancelOrder, modify::ModifyOrder, submit::SubmitOrder,
56        },
57    },
58    msgbus::{self, MessageBus, MessagingSwitchboard},
59};
60use nautilus_core::{UUID4, UnixNanos};
61use nautilus_execution::engine::{ExecutionEngine, config::ExecutionEngineConfig};
62use nautilus_model::{
63    events::{OrderEventAny, OrderPendingCancel, OrderPendingUpdate},
64    identifiers::{AccountId, ClientId, InstrumentId, StrategyId, TraderId},
65    instruments::{Instrument, InstrumentAny},
66    orders::{Order, OrderAny},
67    reports::ExecutionMassStatus,
68    types::{Price, Quantity},
69};
70use nautilus_portfolio::Portfolio;
71use nautilus_risk::engine::{RiskEngine, config::RiskEngineConfig};
72use nautilus_testkit::testers::{ExecTester, ExecTesterConfig};
73use nautilus_trading::strategy::StrategyNative;
74
75use crate::runner::AsyncRunner;
76
77/// Engine-wired state for deterministic live execution seam tests.
78pub struct ExecutionHarness {
79    clock: Rc<RefCell<dyn Clock>>,
80    cache: Rc<RefCell<Cache>>,
81    risk_engine: Rc<RefCell<RiskEngine>>,
82    exec_engine: Rc<RefCell<ExecutionEngine>>,
83    exec_rx: tokio::sync::mpsc::UnboundedReceiver<ExecutionEvent>,
84    routed: Vec<RoutedKind>,
85    trader_id: TraderId,
86    client_id: ClientId,
87    account_id: AccountId,
88    instrument_id: InstrumentId,
89}
90
91impl Debug for ExecutionHarness {
92    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
93        f.debug_struct(stringify!(ExecutionHarness))
94            .field("trader_id", &self.trader_id)
95            .field("client_id", &self.client_id)
96            .field("account_id", &self.account_id)
97            .field("instrument_id", &self.instrument_id)
98            .field("routed", &self.routed)
99            .finish_non_exhaustive()
100    }
101}
102
103impl ExecutionHarness {
104    /// Creates a harness with real risk and execution engines and the supplied instrument.
105    ///
106    /// Replaces the current thread's message bus and live event senders.
107    #[must_use]
108    pub fn new(
109        trader_id: TraderId,
110        client_id: ClientId,
111        account_id: AccountId,
112        instrument: InstrumentAny,
113    ) -> Self {
114        let _bus = MessageBus::new(trader_id, UUID4::new(), None, None).register_message_bus();
115        let clock: Rc<RefCell<dyn Clock>> = Rc::new(RefCell::new(VirtualClock::new()));
116        let cache = Rc::new(RefCell::new(Cache::default()));
117        let instrument_id = instrument.id();
118        cache
119            .borrow_mut()
120            .add_instrument(instrument)
121            .expect("instrument should be added to the harness cache");
122
123        let portfolio = Portfolio::new(clock.clone(), cache.clone(), None);
124
125        let risk_engine = Rc::new(RefCell::new(RiskEngine::new(
126            RiskEngineConfig::default(),
127            portfolio,
128            clock.clone(),
129            cache.clone(),
130        )));
131        RiskEngine::register_msgbus_handlers(&risk_engine);
132
133        let exec_config = ExecutionEngineConfig::builder()
134            .manage_own_order_books(true)
135            .build()
136            .expect("execution engine config should be valid");
137
138        let exec_engine = Rc::new(RefCell::new(ExecutionEngine::new(
139            clock.clone(),
140            cache.clone(),
141            Some(exec_config),
142        )));
143        ExecutionEngine::register_msgbus_handlers(&exec_engine);
144
145        let (exec_tx, exec_rx) = tokio::sync::mpsc::unbounded_channel();
146        replace_exec_event_sender(exec_tx);
147        let (data_tx, _data_rx) = tokio::sync::mpsc::unbounded_channel();
148        replace_data_event_sender(data_tx);
149
150        Self {
151            clock,
152            cache,
153            risk_engine,
154            exec_engine,
155            exec_rx,
156            routed: Vec::new(),
157            trader_id,
158            client_id,
159            account_id,
160            instrument_id,
161        }
162    }
163
164    /// Returns the clock shared by the harness components.
165    #[must_use]
166    pub const fn clock(&self) -> &Rc<RefCell<dyn Clock>> {
167        &self.clock
168    }
169
170    /// Returns the cache shared by the harness components.
171    #[must_use]
172    pub const fn cache(&self) -> &Rc<RefCell<Cache>> {
173        &self.cache
174    }
175
176    /// Returns the risk engine used to route trading commands.
177    #[must_use]
178    pub const fn risk_engine(&self) -> &Rc<RefCell<RiskEngine>> {
179        &self.risk_engine
180    }
181
182    /// Returns the execution engine containing the adapter client under test.
183    #[must_use]
184    pub const fn exec_engine(&self) -> &Rc<RefCell<ExecutionEngine>> {
185        &self.exec_engine
186    }
187
188    /// Returns the trader ID used by the harness.
189    #[must_use]
190    pub const fn trader_id(&self) -> TraderId {
191        self.trader_id
192    }
193
194    /// Returns the execution client ID used by the harness.
195    #[must_use]
196    pub const fn client_id(&self) -> ClientId {
197        self.client_id
198    }
199
200    /// Returns the account ID used by the harness.
201    #[must_use]
202    pub const fn account_id(&self) -> AccountId {
203        self.account_id
204    }
205
206    /// Returns the instrument ID registered in the cache.
207    #[must_use]
208    pub const fn instrument_id(&self) -> InstrumentId {
209        self.instrument_id
210    }
211
212    /// Returns the routing kinds observed before live-runner dispatch.
213    #[must_use]
214    pub fn routed(&self) -> &[RoutedKind] {
215        &self.routed
216    }
217
218    /// Asserts that the execution client is registered and its engine is ready.
219    pub fn assert_engine_ready(&self) {
220        let engine = self.exec_engine.borrow();
221        assert!(engine.get_client(&self.client_id).is_some());
222        assert!(engine.check_integrity());
223        assert!(engine.check_connected());
224    }
225
226    /// Returns the total commands received by the risk engine.
227    #[must_use]
228    pub fn risk_command_count(&self) -> u64 {
229        self.risk_engine.borrow().command_count()
230    }
231
232    /// Registers an adapter execution client and its native venue route with the execution engine.
233    ///
234    /// # Errors
235    ///
236    /// Returns an error if the engine already contains the client ID or venue route.
237    pub fn register_client(&self, client: Box<dyn ExecutionClient>) -> anyhow::Result<()> {
238        let client_id = client.client_id();
239        let venue = client.venue();
240        let mut engine = self.exec_engine.borrow_mut();
241        engine.register_client(client)?;
242        if let Err(e) = engine.register_venue_routing(client_id, venue) {
243            engine.deregister_client(client_id)?;
244            return Err(e);
245        }
246
247        Ok(())
248    }
249
250    /// Caches an order and sends its submission command through the risk engine.
251    pub fn submit_via_risk(&self, order: &OrderAny) {
252        let cmd = SubmitOrder::from_order(
253            order,
254            self.trader_id,
255            Some(self.client_id),
256            None,
257            UUID4::new(),
258            UnixNanos::default(),
259        );
260        self.cache
261            .borrow_mut()
262            .add_order(order.clone(), None, Some(self.client_id), false)
263            .expect("order should be added to the harness cache");
264        msgbus::send_trading_command(
265            MessagingSwitchboard::risk_engine_execute(),
266            TradingCommand::SubmitOrder(cmd),
267        );
268    }
269
270    /// Marks an order pending update and sends its modification through the risk engine.
271    pub fn modify_via_risk(
272        &self,
273        order: &OrderAny,
274        price: Option<Price>,
275        quantity: Option<Quantity>,
276    ) {
277        self.mark_pending_update(order);
278        let venue_order_id = self
279            .cache
280            .borrow()
281            .order(&order.client_order_id())
282            .and_then(|cached| cached.venue_order_id());
283
284        let cmd = ModifyOrder::new(
285            self.trader_id,
286            Some(self.client_id),
287            order.strategy_id(),
288            order.instrument_id(),
289            order.client_order_id(),
290            venue_order_id,
291            quantity,
292            price,
293            None,
294            UUID4::new(),
295            UnixNanos::default(),
296            None,
297            None,
298        );
299        msgbus::send_trading_command(
300            MessagingSwitchboard::risk_engine_execute(),
301            TradingCommand::ModifyOrder(cmd),
302        );
303    }
304
305    /// Marks an order pending cancel and sends its cancellation through the execution engine.
306    pub fn cancel_via_execution(&self, order: &OrderAny) {
307        self.mark_pending_cancel(order);
308        let venue_order_id = self
309            .cache
310            .borrow()
311            .order(&order.client_order_id())
312            .and_then(|cached| cached.venue_order_id());
313
314        let cmd = CancelOrder::new(
315            self.trader_id,
316            Some(self.client_id),
317            order.strategy_id(),
318            order.instrument_id(),
319            order.client_order_id(),
320            venue_order_id,
321            UUID4::new(),
322            UnixNanos::default(),
323            None,
324            None,
325        );
326        msgbus::send_trading_command(
327            MessagingSwitchboard::exec_engine_queue_execute(),
328            TradingCommand::CancelOrder(cmd),
329        );
330    }
331
332    /// Routes emitted execution events until the cache predicate holds or the deadline expires.
333    pub async fn pump_until(
334        &mut self,
335        timeout: Duration,
336        predicate: impl Fn(&Cache) -> bool,
337    ) -> bool {
338        self.pump_until_condition(timeout, |harness| predicate(&harness.cache.borrow()))
339            .await
340    }
341
342    /// Routes emitted execution events until the expected routing kind is observed.
343    pub async fn pump_until_routed(&mut self, timeout: Duration, kind: RoutedKind) -> bool {
344        self.pump_until_condition(timeout, |harness| harness.routed.contains(&kind))
345            .await
346    }
347
348    /// Routes every execution event received during the supplied duration.
349    pub async fn pump_for(&mut self, duration: Duration) {
350        let deadline = dst::time::Instant::now() + duration;
351
352        while dst::time::Instant::now() < deadline {
353            let remaining = deadline.saturating_duration_since(dst::time::Instant::now());
354            match dst::time::timeout(remaining.min(EVENT_POLL_INTERVAL), self.exec_rx.recv()).await
355            {
356                Ok(Some(event)) => self.route_event(event),
357                Ok(None) => return,
358                Err(_) => dst::task::yield_now().await,
359            }
360        }
361    }
362
363    /// Registers an `ExecTester` against the harness clock and cache.
364    #[must_use]
365    pub fn register_exec_tester(&self, strategy_id: StrategyId, order_qty: Quantity) -> ExecTester {
366        let mut config =
367            ExecTesterConfig::new(strategy_id, self.instrument_id, self.client_id, order_qty);
368        config.subscribe_quotes = false;
369        config.subscribe_trades = false;
370        config.enable_limit_sells = false;
371        config.tob_offset_ticks = 1;
372        config.cancel_orders_on_stop = false;
373        config.close_positions_on_stop = false;
374
375        let mut tester = ExecTester::new(config);
376
377        let portfolio = Rc::new(RefCell::new(Portfolio::new(
378            self.clock.clone(),
379            self.cache.clone(),
380            None,
381        )));
382        StrategyNative::strategy_core_mut(&mut tester)
383            .register(
384                self.trader_id,
385                self.clock.clone(),
386                self.cache.clone(),
387                portfolio,
388            )
389            .expect("ExecTester should register against the harness");
390        tester
391    }
392
393    /// Applies a pending-cancel event to the cached order.
394    pub fn mark_pending_cancel(&self, order: &OrderAny) {
395        let cached = self.cached_order(order);
396        let ts_now = self.clock.borrow().timestamp_ns();
397        let event = OrderEventAny::PendingCancel(OrderPendingCancel::new(
398            cached.trader_id(),
399            cached.strategy_id(),
400            cached.instrument_id(),
401            cached.client_order_id(),
402            cached.account_id(),
403            UUID4::new(),
404            ts_now,
405            ts_now,
406            false,
407            cached.venue_order_id(),
408        ));
409        self.apply_pending_event(&event);
410    }
411
412    /// Generates and applies execution mass status from the registered adapter client.
413    #[allow(
414        clippy::await_holding_refcell_ref,
415        reason = "single-threaded test harness only runs mock venue tasks during the await"
416    )]
417    pub async fn reconcile_from_venue(&self) -> ExecutionMassStatus {
418        let mass_status = self
419            .exec_engine
420            .borrow_mut()
421            .generate_mass_status(&self.client_id, None)
422            .await
423            .expect("mass-status request should succeed")
424            .expect("mass-status request should return a report");
425        self.exec_engine
426            .borrow_mut()
427            .reconcile_execution_mass_status(&mass_status);
428        mass_status
429    }
430
431    fn mark_pending_update(&self, order: &OrderAny) {
432        let cached = self.cached_order(order);
433        let ts_now = self.clock.borrow().timestamp_ns();
434        let event = OrderEventAny::PendingUpdate(OrderPendingUpdate::new(
435            cached.trader_id(),
436            cached.strategy_id(),
437            cached.instrument_id(),
438            cached.client_order_id(),
439            cached.account_id(),
440            UUID4::new(),
441            ts_now,
442            ts_now,
443            false,
444            cached.venue_order_id(),
445        ));
446        self.apply_pending_event(&event);
447    }
448
449    fn cached_order(&self, order: &OrderAny) -> OrderAny {
450        self.cache
451            .borrow()
452            .order(&order.client_order_id())
453            .map(|cached| cached.clone())
454            .expect("order must be cached before a pending transition")
455    }
456
457    fn apply_pending_event(&self, event: &OrderEventAny) {
458        self.cache
459            .borrow_mut()
460            .update_order(event)
461            .expect("pending event should update the cached order");
462    }
463
464    async fn pump_until_condition(
465        &mut self,
466        timeout: Duration,
467        predicate: impl Fn(&Self) -> bool,
468    ) -> bool {
469        let start = dst::time::Instant::now();
470
471        loop {
472            if predicate(self) {
473                return true;
474            }
475
476            if start.elapsed() >= timeout {
477                return false;
478            }
479
480            match dst::time::timeout(EVENT_POLL_INTERVAL, self.exec_rx.recv()).await {
481                Ok(Some(event)) => self.route_event(event),
482                Ok(None) => return predicate(self),
483                Err(_) => dst::task::yield_now().await,
484            }
485        }
486    }
487
488    fn route_event(&mut self, event: ExecutionEvent) {
489        self.routed.push(RoutedKind::of(&event));
490        AsyncRunner::handle_exec_event(event);
491    }
492}
493
494const EVENT_POLL_INTERVAL: Duration = Duration::from_millis(50);
495
496/// Classifies which branch of the live execution routing fork handles an event.
497#[derive(Debug, Clone, Copy, PartialEq, Eq)]
498pub enum RoutedKind {
499    /// Typed order-event path.
500    Order,
501    /// Reconciliation-report path.
502    Report,
503    /// Account-state path.
504    Account,
505}
506
507impl RoutedKind {
508    fn of(event: &ExecutionEvent) -> Self {
509        match event {
510            ExecutionEvent::Report(_) => Self::Report,
511            ExecutionEvent::Account(_) => Self::Account,
512            _ => Self::Order,
513        }
514    }
515}
516
517/// Cross-layer invariants for live execution tests.
518pub mod invariants {
519    use ahash::AHashSet;
520    use nautilus_common::cache::Cache;
521    use nautilus_model::{
522        enums::OrderStatus,
523        identifiers::{ClientOrderId, InstrumentId},
524        orderbook::own::should_handle_own_book_order,
525        orders::Order,
526    };
527    use rust_decimal::Decimal;
528
529    use super::RoutedKind;
530
531    /// Asserts that a tracked lifecycle used typed order events and no reports.
532    pub fn assert_tracked_used_events(routed: &[RoutedKind]) {
533        assert!(
534            routed.contains(&RoutedKind::Order),
535            "tracked lifecycle routed no typed order event: {routed:?}",
536        );
537        let reports = routed
538            .iter()
539            .filter(|kind| **kind == RoutedKind::Report)
540            .count();
541        assert_eq!(
542            reports, 0,
543            "tracked happy path routed {reports} report(s), expected 0: {routed:?}",
544        );
545    }
546
547    /// Asserts the exact status of a cached order.
548    pub fn assert_order_status(cache: &Cache, id: &ClientOrderId, expected: OrderStatus) {
549        let status = cache.order(id).map(|order| order.status());
550        assert_eq!(
551            status,
552            Some(expected),
553            "order {id} status was {status:?}, expected {expected:?}",
554        );
555    }
556
557    /// Asserts that an instrument's own order book contains its eligible nonterminal cached orders.
558    ///
559    /// Every own-book entry must match its nonterminal cached order's projection field by field,
560    /// and every nonterminal cached order that `should_handle_own_book_order` accepts must be
561    /// present. Requires own-book management and a settled state: call it once queued commands
562    /// and events are processed, and not while an order awaits submission, such as a contingent
563    /// order held for its parent or an external-client order before its first event.
564    pub fn assert_own_book_consistent(cache: &Cache, instrument_id: &InstrumentId) {
565        let mut tracked_ids = AHashSet::new();
566
567        if let Some(book) = cache.own_order_book(instrument_id) {
568            let entries = book
569                .bids()
570                .chain(book.asks())
571                .flat_map(|level| level.orders.values());
572
573            for entry in entries {
574                let id = entry.client_order_id;
575
576                let Some(order) = cache.order(&id).filter(|order| !order.is_closed()) else {
577                    panic!("own order book retains closed or missing order {id}");
578                };
579
580                // `OwnBookOrder` equality compares only the client order ID
581                assert_eq!(
582                    format!("{entry:?}"),
583                    format!("{:?}", order.to_own_book_order()),
584                    "own order book entry for {id} diverges from the cached order",
585                );
586                tracked_ids.insert(id);
587            }
588        }
589
590        for order in cache.orders(None, Some(instrument_id), None, None, None) {
591            let id = order.client_order_id();
592            let eligible = !order.is_closed() && should_handle_own_book_order(&order);
593            assert!(
594                !eligible || tracked_ids.contains(&id),
595                "own order book is missing open order {id}",
596            );
597        }
598    }
599
600    /// Asserts the exact cumulative filled quantity of a cached order.
601    pub fn assert_filled_qty(cache: &Cache, id: &ClientOrderId, expected: Decimal) {
602        let filled = cache.order(id).map(|order| order.filled_qty().as_decimal());
603        assert_eq!(
604            filled,
605            Some(expected),
606            "order {id} filled_qty was {filled:?}, expected {expected}",
607        );
608    }
609
610    /// Asserts whether an order is present in the instrument's own order book.
611    pub fn assert_in_own_book(
612        cache: &Cache,
613        instrument_id: &InstrumentId,
614        id: &ClientOrderId,
615        expected: bool,
616    ) {
617        let present = cache
618            .own_order_book(instrument_id)
619            .is_some_and(|book| book.is_order_in_book(id));
620        assert_eq!(
621            present, expected,
622            "order {id} own-book membership was {present}, expected {expected}",
623        );
624    }
625}
626
627#[cfg(test)]
628mod tests {
629    use std::panic::{AssertUnwindSafe, catch_unwind};
630
631    use nautilus_common::cache::Cache;
632    use nautilus_execution::engine::stubs::StubExecutionClient;
633    use nautilus_model::{
634        enums::{OmsType, OrderSide, OrderStatus, OrderType},
635        identifiers::{AccountId, ClientId, ClientOrderId, TraderId, VenueOrderId},
636        instruments::{Instrument, stubs::audusd_sim},
637        orderbook::own::OwnOrderBook,
638        orders::{Order, OrderAny, OrderTestBuilder, stubs::TestOrderEventStubs},
639        types::{Price, Quantity},
640    };
641    use rstest::rstest;
642
643    use super::{ExecutionHarness, invariants};
644
645    #[derive(Clone, Copy, Debug)]
646    enum OwnBookFault {
647        Missing,
648        StaleSize,
649        Closed,
650        Unknown,
651    }
652
653    #[rstest]
654    #[case::occupied_route(false, "Venue SIM already routed to A, cannot re-route to B")]
655    #[case::duplicate_client(true, "Client already registered with ID A")]
656    fn test_registration_failure_preserves_client_and_route(
657        #[case] duplicate_id: bool,
658        #[case] expected: &str,
659    ) {
660        let instrument = audusd_sim();
661        let client_id = ClientId::from("A");
662        let account_id = AccountId::from("A-001");
663
664        let harness = ExecutionHarness::new(
665            TraderId::from("TRADER-001"),
666            client_id,
667            account_id,
668            instrument.clone().into(),
669        );
670        harness
671            .register_client(Box::new(StubExecutionClient::new(
672                client_id,
673                account_id,
674                instrument.id().venue,
675                OmsType::Netting,
676                None,
677            )))
678            .unwrap();
679
680        let replacement_id = if duplicate_id {
681            client_id
682        } else {
683            ClientId::from("B")
684        };
685
686        let error = harness
687            .register_client(Box::new(StubExecutionClient::new(
688                replacement_id,
689                AccountId::from("B-002"),
690                instrument.id().venue,
691                OmsType::Hedging,
692                None,
693            )))
694            .unwrap_err();
695        let order = OrderTestBuilder::new(OrderType::Market)
696            .instrument_id(instrument.id())
697            .quantity(Quantity::from(1))
698            .build();
699        let engine = harness.exec_engine().borrow();
700        let routed = engine.get_clients_for_orders(&[order]);
701
702        assert_eq!(error.to_string(), expected);
703        assert_eq!(engine.client_ids(), vec![client_id]);
704        assert_eq!(routed.len(), 1);
705        assert_eq!(routed[0].client_id(), client_id);
706        assert_eq!(routed[0].account_id(), account_id);
707        assert_eq!(routed[0].oms_type(), OmsType::Netting);
708    }
709
710    #[rstest]
711    #[case::consistent(None, None)]
712    #[case::missing(
713        Some(OwnBookFault::Missing),
714        Some("own order book is missing open order O-001")
715    )]
716    #[case::stale_size(
717        Some(OwnBookFault::StaleSize),
718        Some("own order book entry for O-001 diverges from the cached order")
719    )]
720    #[case::closed(
721        Some(OwnBookFault::Closed),
722        Some("own order book retains closed or missing order O-001")
723    )]
724    #[case::unknown(
725        Some(OwnBookFault::Unknown),
726        Some("own order book retains closed or missing order O-UNKNOWN")
727    )]
728    fn test_assert_own_book_consistent_detects_divergence(
729        #[case] fault: Option<OwnBookFault>,
730        #[case] expected: Option<&str>,
731    ) {
732        let (mut cache, order) = cache_with_accepted_order(false);
733        let instrument_id = order.instrument_id();
734        let entry = order.to_own_book_order();
735
736        match fault {
737            None => {}
738            Some(OwnBookFault::Missing) => {
739                let own_book = cache.own_order_book_mut(&instrument_id).unwrap();
740                own_book.delete(entry).unwrap();
741            }
742            Some(OwnBookFault::StaleSize) => {
743                let mut stale = entry;
744                stale.size = Quantity::from(50_000);
745                let own_book = cache.own_order_book_mut(&instrument_id).unwrap();
746                own_book.update(stale).unwrap();
747            }
748            Some(OwnBookFault::Closed) => {
749                let account_id = order.account_id().unwrap();
750                let canceled = TestOrderEventStubs::canceled(&order, account_id, None);
751                cache.update_order(&canceled).unwrap();
752                let own_book = cache.own_order_book_mut(&instrument_id).unwrap();
753                own_book.add(entry);
754            }
755            Some(OwnBookFault::Unknown) => {
756                let mut unknown = entry;
757                unknown.client_order_id = ClientOrderId::from("O-UNKNOWN");
758                let own_book = cache.own_order_book_mut(&instrument_id).unwrap();
759                own_book.add(unknown);
760            }
761        }
762
763        let result = catch_unwind(AssertUnwindSafe(|| {
764            invariants::assert_own_book_consistent(&cache, &instrument_id);
765        }));
766
767        let message = result.err().map(|payload| {
768            payload
769                .downcast_ref::<String>()
770                .cloned()
771                .unwrap_or_default()
772        });
773
774        match expected {
775            None => assert!(message.is_none(), "unexpected panic: {message:?}"),
776            Some(expected) => assert!(
777                message.as_deref().is_some_and(|m| m.contains(expected)),
778                "panic message {message:?} lacks {expected:?}",
779            ),
780        }
781    }
782
783    #[rstest]
784    fn test_assert_own_book_consistent_ignores_ineligible_open_order() {
785        let (cache, order) = cache_with_accepted_order(true);
786        let instrument_id = order.instrument_id();
787        let own_book = cache.own_order_book(&instrument_id).unwrap();
788        let in_own_book = own_book.is_order_in_book(&order.client_order_id());
789
790        invariants::assert_own_book_consistent(&cache, &instrument_id);
791
792        assert_eq!(order.status(), OrderStatus::Accepted);
793        assert!(!in_own_book);
794    }
795
796    fn cache_with_accepted_order(quote_quantity: bool) -> (Cache, OrderAny) {
797        let instrument = audusd_sim();
798        let instrument_id = instrument.id();
799        let account_id = AccountId::from("SIM-001");
800        let mut cache = Cache::default();
801        cache.add_instrument(instrument.into()).unwrap();
802        cache
803            .add_own_order_book(OwnOrderBook::new(instrument_id))
804            .unwrap();
805
806        let order = OrderTestBuilder::new(OrderType::Limit)
807            .instrument_id(instrument_id)
808            .client_order_id(ClientOrderId::from("O-001"))
809            .side(OrderSide::Buy)
810            .quantity(Quantity::from(100_000))
811            .price(Price::from("1.00000"))
812            .quote_quantity(quote_quantity)
813            .build();
814        cache.add_order(order.clone(), None, None, false).unwrap();
815        let order = cache
816            .update_order(&TestOrderEventStubs::submitted(&order, account_id))
817            .unwrap();
818        let order = cache
819            .update_order(&TestOrderEventStubs::accepted(
820                &order,
821                account_id,
822                VenueOrderId::from("V-001"),
823            ))
824            .unwrap();
825
826        (cache, order)
827    }
828}