Skip to main content

nautilus_sandbox/
execution.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//! Sandbox execution client implementation.
17
18use std::{cell::RefCell, collections::BinaryHeap, fmt::Debug, rc::Rc};
19
20use ahash::{AHashMap, AHashSet};
21use async_trait::async_trait;
22use nautilus_common::{
23    cache::Cache,
24    clients::ExecutionClient,
25    clock::Clock,
26    factories::OrderEventFactory,
27    live::try_get_exec_event_sender,
28    messages::{
29        ExecutionEvent,
30        execution::{
31            BatchCancelOrders, BatchModifyOrders, CancelAllOrders, CancelOrder,
32            GenerateFillReports, GenerateOrderStatusReport, GenerateOrderStatusReports,
33            GeneratePositionStatusReports, ModifyOrder, QueryAccount, QueryOrder, SubmitOrder,
34            SubmitOrderList, TradingCommand,
35        },
36    },
37    msgbus::{
38        self, MStr, MessagingSwitchboard, Pattern, TypedHandler,
39        typed_handler::ShareableMessageHandler,
40    },
41    timer::{TimeEvent, TimeEventCallback},
42};
43use nautilus_core::{DurationNanos, Params, UUID4, UnixNanos, WeakCell};
44use nautilus_execution::{
45    client::core::ExecutionClientCore,
46    matching_engine::{OrderMatchingEngine, inflight::InflightOrders},
47    models::{fee::FeeModelHandle, fill::FillModelHandle, latency::LatencyModel},
48};
49use nautilus_model::{
50    accounts::AccountAny,
51    data::{Bar, InstrumentClose, InstrumentStatus, OrderBookDeltas, QuoteTick, TradeTick},
52    enums::OmsType,
53    events::{
54        OrderCancelRejected, OrderEventAny, OrderModifyRejected, OrderRejected, PositionEvent,
55    },
56    identifiers::{
57        AccountId, ClientId, ClientOrderId, InstrumentId, StrategyId, TraderId, Venue, VenueOrderId,
58    },
59    instruments::{Instrument, InstrumentAny},
60    orders::{Order, OrderAny},
61    reports::{ExecutionMassStatus, FillReport, OrderStatusReport, PositionStatusReport},
62    types::{AccountBalance, MarginBalance, Money},
63};
64use ustr::Ustr;
65
66use crate::config::SandboxExecutionClientConfig;
67
68// Bounds retained state for quote-only instruments that expire without event-driven cleanup
69const EXPIRED_ENGINE_SWEEP_INTERVAL: DurationNanos = DurationNanos::from_mins(1);
70
71/// A sandbox execution client for paper trading against live market data.
72///
73/// The `SandboxExecutionClient` simulates order execution using the `OrderMatchingEngine`
74/// to match orders against market data. This enables strategy testing in real-time
75/// without actual order execution on exchanges.
76pub struct SandboxExecutionClient {
77    core: RefCell<ExecutionClientCore>,
78    factory: OrderEventFactory,
79    config: SandboxExecutionClientConfig,
80    inner: Rc<RefCell<SandboxInner>>,
81    handlers: RefCell<Option<RegisteredHandlers>>,
82    clock: Rc<RefCell<dyn Clock>>,
83    cache: Rc<RefCell<Cache>>,
84}
85
86struct RegisteredHandlers {
87    deltas_pattern: MStr<Pattern>,
88    deltas_handler: TypedHandler<OrderBookDeltas>,
89    quote_pattern: MStr<Pattern>,
90    quote_handler: TypedHandler<QuoteTick>,
91    trade_pattern: MStr<Pattern>,
92    trade_handler: TypedHandler<TradeTick>,
93    bar_pattern: MStr<Pattern>,
94    bar_handler: TypedHandler<Bar>,
95    status_pattern: MStr<Pattern>,
96    status_handler: ShareableMessageHandler,
97    close_pattern: MStr<Pattern>,
98    close_handler: ShareableMessageHandler,
99    position_pattern: MStr<Pattern>,
100    position_handler: TypedHandler<PositionEvent>,
101}
102
103impl Debug for SandboxExecutionClient {
104    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
105        f.debug_struct(stringify!(SandboxExecutionClient))
106            .field("venue", &self.config.venue)
107            .field("account_id", &self.core.borrow().account_id)
108            .field("connected", &self.core.borrow().is_connected())
109            .field(
110                "matching_engines",
111                &self.inner.borrow().matching_engines.len(),
112            )
113            .finish()
114    }
115}
116
117impl SandboxExecutionClient {
118    /// Creates a new [`SandboxExecutionClient`] instance.
119    #[must_use]
120    pub fn new(
121        core: ExecutionClientCore,
122        config: SandboxExecutionClientConfig,
123        clock: Rc<RefCell<dyn Clock>>,
124        cache: Rc<RefCell<Cache>>,
125    ) -> Self {
126        let mut balances = AHashMap::new();
127        for money in &config.starting_balances {
128            balances.insert(money.currency.code.to_string(), *money);
129        }
130
131        let fill_model = config
132            .fill_model
133            .clone()
134            .map(FillModelHandle::from)
135            .unwrap_or_default();
136        let inner = Rc::new_cyclic(|weak: &std::rc::Weak<RefCell<SandboxInner>>| {
137            RefCell::new(SandboxInner {
138                clock: clock.clone(),
139                cache: cache.clone(),
140                config: config.clone(),
141                fill_model,
142                matching_engines: AHashMap::new(),
143                next_engine_raw_id: 0,
144                balances,
145                event_handler: None,
146                inbound_queue: BinaryHeap::new(),
147                inflight_orders: InflightOrders::default(),
148                inbound_seq: 0,
149                client_id: core.client_id,
150                account_id: core.account_id,
151                self_weak: WeakCell::from(weak.clone()),
152            })
153        });
154
155        let factory = OrderEventFactory::new(
156            core.trader_id,
157            core.account_id,
158            core.account_type,
159            core.base_currency,
160        );
161
162        Self {
163            core: RefCell::new(core),
164            factory,
165            config,
166            inner,
167            handlers: RefCell::new(None),
168            clock,
169            cache,
170        }
171    }
172
173    /// Returns a reference to the configuration.
174    #[must_use]
175    pub const fn config(&self) -> &SandboxExecutionClientConfig {
176        &self.config
177    }
178
179    /// Returns the number of active matching engines.
180    #[must_use]
181    pub fn matching_engine_count(&self) -> usize {
182        self.inner.borrow().matching_engines.len()
183    }
184
185    fn dispatch_order_event(&self, event: OrderEventAny) {
186        self.inner.borrow().dispatch_order_event(event);
187    }
188
189    fn register_message_handlers(&self) {
190        if self.handlers.borrow().is_some() {
191            log::warn!("Sandbox message handlers already registered");
192            return;
193        }
194
195        let inner_weak = WeakCell::from(Rc::downgrade(&self.inner));
196        let venue = self.config.venue;
197        let account_id = self.core.borrow().account_id;
198
199        let deltas_handler = {
200            let inner = inner_weak.clone();
201            TypedHandler::from(move |deltas: &OrderBookDeltas| {
202                if deltas.instrument_id.venue == venue
203                    && let Some(inner_rc) = inner.upgrade()
204                {
205                    SandboxInner::on_order_book_deltas(&inner_rc, deltas);
206                }
207            })
208        };
209
210        let quote_handler = {
211            let inner = inner_weak.clone();
212            TypedHandler::from(move |quote: &QuoteTick| {
213                if quote.instrument_id.venue == venue
214                    && let Some(inner_rc) = inner.upgrade()
215                {
216                    SandboxInner::on_quote_tick(&inner_rc, quote);
217                }
218            })
219        };
220
221        let trade_handler = {
222            let inner = inner_weak.clone();
223            TypedHandler::from(move |trade: &TradeTick| {
224                if trade.instrument_id.venue == venue
225                    && let Some(inner_rc) = inner.upgrade()
226                {
227                    SandboxInner::on_trade_tick(&inner_rc, trade);
228                }
229            })
230        };
231
232        // Bar topics include the bar type, so filter by venue in the handler
233        let bar_handler = {
234            let inner = inner_weak.clone();
235            TypedHandler::from(move |bar: &Bar| {
236                if bar.bar_type.instrument_id().venue == venue
237                    && let Some(inner_rc) = inner.upgrade()
238                {
239                    SandboxInner::on_bar(&inner_rc, bar);
240                }
241            })
242        };
243
244        let status_handler = {
245            let inner = inner_weak.clone();
246            ShareableMessageHandler::from_typed(move |status: &InstrumentStatus| {
247                if status.instrument_id.venue == venue
248                    && let Some(inner_rc) = inner.upgrade()
249                {
250                    SandboxInner::on_instrument_status(&inner_rc, status);
251                }
252            })
253        };
254
255        let close_handler = {
256            let inner = inner_weak.clone();
257            ShareableMessageHandler::from_typed(move |close: &InstrumentClose| {
258                if close.instrument_id.venue == venue
259                    && let Some(inner_rc) = inner.upgrade()
260                {
261                    SandboxInner::on_instrument_close(&inner_rc, close);
262                }
263            })
264        };
265
266        let position_handler = {
267            TypedHandler::from(move |event: &PositionEvent| {
268                let PositionEvent::PositionClosed(position_closed) = event else {
269                    return;
270                };
271
272                if position_closed.instrument_id.venue == venue
273                    && position_closed.account_id == account_id
274                    && let Some(inner_rc) = inner_weak.upgrade()
275                {
276                    // ExecutionEngine updates the cached position state before publishing
277                    // PositionClosed, so this retry observes the post-settlement cache view.
278                    if let Ok(mut inner) = inner_rc.try_borrow_mut() {
279                        inner.sync_expired_cleanup(position_closed.instrument_id);
280                    } else {
281                        log::debug!(
282                            "Skipping immediate expired cleanup retry for {} due to active sandbox borrow",
283                            position_closed.instrument_id,
284                        );
285                    }
286                }
287            })
288        };
289
290        let deltas_pattern: MStr<Pattern> = format!("data.book.deltas.{venue}.*").into();
291        let quote_pattern: MStr<Pattern> = format!("data.quotes.{venue}.*").into();
292        let trade_pattern: MStr<Pattern> = format!("data.trades.{venue}.*").into();
293        let bar_pattern: MStr<Pattern> = "data.bars.*".into();
294        let status_pattern: MStr<Pattern> = format!("data.status.{venue}.*").into();
295        let close_pattern: MStr<Pattern> = format!("data.close.{venue}.*").into();
296        let position_pattern: MStr<Pattern> = "events.position.*".into();
297
298        msgbus::subscribe_book_deltas(deltas_pattern, deltas_handler.clone(), Some(10));
299        msgbus::subscribe_quotes(quote_pattern, quote_handler.clone(), Some(10));
300        msgbus::subscribe_trades(trade_pattern, trade_handler.clone(), Some(10));
301        msgbus::subscribe_bars(bar_pattern, bar_handler.clone(), Some(10));
302        msgbus::subscribe_any(status_pattern, status_handler.clone(), Some(10));
303        msgbus::subscribe_instrument_close(close_pattern, close_handler.clone(), Some(10));
304        msgbus::subscribe_position_events(position_pattern, position_handler.clone(), Some(10));
305
306        *self.handlers.borrow_mut() = Some(RegisteredHandlers {
307            deltas_pattern,
308            deltas_handler,
309            quote_pattern,
310            quote_handler,
311            trade_pattern,
312            trade_handler,
313            bar_pattern,
314            bar_handler,
315            status_pattern,
316            status_handler,
317            close_pattern,
318            close_handler,
319            position_pattern,
320            position_handler,
321        });
322
323        log::debug!(
324            "Sandbox registered message handlers for venue={}",
325            self.config.venue
326        );
327    }
328
329    fn deregister_message_handlers(&self) {
330        if let Some(handlers) = self.handlers.borrow_mut().take() {
331            msgbus::unsubscribe_book_deltas(handlers.deltas_pattern, &handlers.deltas_handler);
332            msgbus::unsubscribe_quotes(handlers.quote_pattern, &handlers.quote_handler);
333            msgbus::unsubscribe_trades(handlers.trade_pattern, &handlers.trade_handler);
334            msgbus::unsubscribe_bars(handlers.bar_pattern, &handlers.bar_handler);
335            msgbus::unsubscribe_any(handlers.status_pattern, &handlers.status_handler);
336            msgbus::unsubscribe_instrument_close(handlers.close_pattern, &handlers.close_handler);
337            msgbus::unsubscribe_position_events(
338                handlers.position_pattern,
339                &handlers.position_handler,
340            );
341
342            log::debug!(
343                "Sandbox deregistered message handlers for venue={}",
344                self.config.venue
345            );
346        }
347    }
348
349    fn expiry_sweep_timer_name(&self) -> String {
350        format!("{}-sandbox-expiry-sweep", self.core.borrow().client_id)
351    }
352
353    fn register_expiry_sweep_timer(&self) {
354        let inner_weak = WeakCell::from(Rc::downgrade(&self.inner));
355        let callback: Rc<dyn Fn(TimeEvent)> = Rc::new(move |_event: TimeEvent| {
356            let Some(inner_rc) = inner_weak.upgrade() else {
357                return;
358            };
359
360            // The timer fires on the runner task, but a nested msgbus dispatch may already hold the
361            // borrow; skipping is safe because the next interval retries.
362            if let Ok(mut inner) = inner_rc.try_borrow_mut() {
363                inner.sweep_expired_engines();
364            } else {
365                log::debug!("Skipping sandbox expiry sweep due to active borrow");
366            }
367        });
368
369        let name = self.expiry_sweep_timer_name();
370
371        if let Err(e) = self.clock.borrow_mut().set_timer_ns(
372            &name,
373            EXPIRED_ENGINE_SWEEP_INTERVAL,
374            None,
375            None,
376            Some(TimeEventCallback::from(callback)),
377            None,
378            None,
379        ) {
380            log::error!("Failed to register sandbox expiry sweep timer: {e}");
381        }
382    }
383
384    fn cancel_expiry_sweep_timer(&self) {
385        self.clock
386            .borrow_mut()
387            .cancel_timer(&self.expiry_sweep_timer_name());
388    }
389
390    fn cancel_inbound_alert(&self) {
391        let client_id = self.core.borrow().client_id;
392        self.clock
393            .borrow_mut()
394            .cancel_timer(&inbound_alert_name(client_id));
395    }
396
397    fn get_current_account_balances(&self) -> Vec<AccountBalance> {
398        let account_id = self.core.borrow().account_id;
399        let cache = self.cache.borrow();
400
401        if let Some(account) = cache.account(&account_id) {
402            return account.balances().into_values().collect();
403        }
404
405        self.get_account_balances()
406    }
407
408    fn sync_cached_account_config(&self) -> anyhow::Result<()> {
409        let Some(mut account) = self.get_account() else {
410            return Ok(());
411        };
412
413        account.set_calculate_account_state(!self.config.frozen_account);
414
415        if let AccountAny::Margin(margin_account) = &mut account {
416            margin_account.set_default_leverage(self.config.default_leverage);
417            for (instrument_id, leverage) in &self.config.leverages {
418                margin_account.set_leverage(*instrument_id, *leverage);
419            }
420        }
421
422        self.cache.borrow_mut().update_account(&account)
423    }
424
425    /// Processes a quote tick through the matching engine.
426    ///
427    /// # Errors
428    ///
429    /// Returns an error if the instrument is not found in the cache.
430    pub fn process_quote_tick(&self, quote: &QuoteTick) -> anyhow::Result<()> {
431        SandboxInner::drain_inbound(&self.inner);
432
433        let instrument_id = quote.instrument_id;
434        let instrument = self.cache.borrow().try_instrument(&instrument_id)?.clone();
435
436        if !check_quote_or_drop("quote tick", quote, &instrument) {
437            return Ok(());
438        }
439
440        let mut inner = self.inner.borrow_mut();
441        inner.ensure_matching_engine(&instrument);
442        if let Some(engine) = inner.matching_engines.get_mut(&instrument_id) {
443            engine.process_quote_tick(quote);
444        }
445        Ok(())
446    }
447
448    /// Processes a trade tick through the matching engine.
449    ///
450    /// # Errors
451    ///
452    /// Returns an error if the instrument is not found in the cache.
453    pub fn process_trade_tick(&self, trade: &TradeTick) -> anyhow::Result<()> {
454        SandboxInner::drain_inbound(&self.inner);
455
456        if !self.config.trade_execution {
457            return Ok(());
458        }
459
460        let instrument_id = trade.instrument_id;
461        let instrument = self.cache.borrow().try_instrument(&instrument_id)?.clone();
462
463        if !check_trade_or_drop("trade tick", trade, &instrument) {
464            return Ok(());
465        }
466
467        let mut inner = self.inner.borrow_mut();
468        inner.ensure_matching_engine(&instrument);
469        if let Some(engine) = inner.matching_engines.get_mut(&instrument_id) {
470            engine.process_trade_tick(trade);
471        }
472        Ok(())
473    }
474
475    /// Processes a bar through the matching engine.
476    ///
477    /// # Errors
478    ///
479    /// Returns an error if the instrument is not found in the cache.
480    pub fn process_bar(&self, bar: &Bar) -> anyhow::Result<()> {
481        SandboxInner::drain_inbound(&self.inner);
482
483        if !self.config.bar_execution {
484            return Ok(());
485        }
486
487        let instrument_id = bar.bar_type.instrument_id();
488        let instrument = self.cache.borrow().try_instrument(&instrument_id)?.clone();
489
490        if !check_bar_or_drop("bar", bar, &instrument) {
491            return Ok(());
492        }
493
494        let mut inner = self.inner.borrow_mut();
495        inner.ensure_matching_engine(&instrument);
496        if let Some(engine) = inner.matching_engines.get_mut(&instrument_id) {
497            engine.process_bar(bar);
498        }
499        Ok(())
500    }
501
502    /// Processes order book deltas through the matching engine.
503    ///
504    /// # Errors
505    ///
506    /// Returns an error if the instrument is not found in the cache.
507    pub fn process_order_book_deltas(&self, deltas: &OrderBookDeltas) -> anyhow::Result<()> {
508        SandboxInner::drain_inbound(&self.inner);
509
510        let instrument_id = deltas.instrument_id;
511        let instrument = self.cache.borrow().try_instrument(&instrument_id)?.clone();
512
513        let mut inner = self.inner.borrow_mut();
514        inner.ensure_matching_engine(&instrument);
515        if let Some(engine) = inner.matching_engines.get_mut(&instrument_id) {
516            engine.process_order_book_deltas(deltas)?;
517        }
518        Ok(())
519    }
520
521    /// Resets the sandbox to its initial state.
522    pub fn reset(&self) {
523        let mut inner = self.inner.borrow_mut();
524        for engine in inner.matching_engines.values_mut() {
525            engine.reset();
526        }
527
528        inner.balances.clear();
529        for money in &self.config.starting_balances {
530            inner
531                .balances
532                .insert(money.currency.code.to_string(), *money);
533        }
534
535        inner.clear_inbound_queue();
536        self.cancel_inbound_alert();
537
538        log::info!(
539            "Sandbox execution client reset: venue={}",
540            self.config.venue
541        );
542    }
543
544    fn get_account_balances(&self) -> Vec<AccountBalance> {
545        self.inner
546            .borrow()
547            .balances
548            .values()
549            .map(|money| AccountBalance::new(*money, Money::zero(money.currency), *money))
550            .collect()
551    }
552
553    fn get_order(&self, client_order_id: &ClientOrderId) -> anyhow::Result<OrderAny> {
554        Ok(self.cache.borrow().try_order_owned(client_order_id)?)
555    }
556}
557
558#[async_trait(?Send)]
559impl ExecutionClient for SandboxExecutionClient {
560    fn is_connected(&self) -> bool {
561        self.core.borrow().is_connected()
562    }
563
564    fn client_id(&self) -> ClientId {
565        self.core.borrow().client_id
566    }
567
568    fn account_id(&self) -> AccountId {
569        self.core.borrow().account_id
570    }
571
572    fn venue(&self) -> Venue {
573        self.core.borrow().venue
574    }
575
576    fn oms_type(&self) -> OmsType {
577        self.config.oms_type
578    }
579
580    fn on_instrument(&mut self, instrument: InstrumentAny) {
581        let instrument_id = instrument.id();
582        let mut inner = self.inner.borrow_mut();
583        if let Some(engine) = inner.matching_engines.get_mut(&instrument_id)
584            && let Err(e) = engine.update_instrument(instrument)
585        {
586            log::error!("Failed to update instrument {instrument_id} in sandbox engine: {e}");
587        }
588    }
589
590    fn get_account(&self) -> Option<AccountAny> {
591        let account_id = self.core.borrow().account_id;
592        self.cache.borrow().account_owned(&account_id)
593    }
594
595    fn reset(&mut self) -> anyhow::Result<()> {
596        Self::reset(self);
597        Ok(())
598    }
599
600    fn generate_account_state(
601        &self,
602        balances: Vec<AccountBalance>,
603        margins: Vec<MarginBalance>,
604        reported: bool,
605        ts_event: UnixNanos,
606        info: Option<Params>,
607    ) -> anyhow::Result<()> {
608        let ts_init = self.clock.borrow().timestamp_ns();
609        let state = self
610            .factory
611            .generate_account_state(balances, margins, reported, ts_event, ts_init, info);
612        let endpoint = MessagingSwitchboard::portfolio_update_account();
613        msgbus::send_account_state(endpoint, &state);
614        self.sync_cached_account_config()?;
615        Ok(())
616    }
617
618    fn start(&mut self) -> anyhow::Result<()> {
619        if self.core.borrow().is_started() {
620            return Ok(());
621        }
622
623        if let Some(sender) = try_get_exec_event_sender() {
624            let handler: Rc<dyn Fn(OrderEventAny)> = Rc::new(move |event: OrderEventAny| {
625                if let Err(e) = sender.send(ExecutionEvent::Order(event)) {
626                    log::warn!("Failed to send order event: {e}");
627                }
628            });
629            let mut inner = self.inner.borrow_mut();
630            inner.event_handler = Some(handler.clone());
631            for engine in inner.matching_engines.values_mut() {
632                engine.set_event_handler(handler.clone());
633            }
634        }
635
636        self.register_message_handlers();
637        self.register_expiry_sweep_timer();
638
639        self.core.borrow().set_started();
640        let core = self.core.borrow();
641        log::info!(
642            "Sandbox execution client started: venue={}, account_id={}, oms_type={:?}, account_type={:?}",
643            self.config.venue,
644            core.account_id,
645            self.config.oms_type,
646            self.config.account_type,
647        );
648        Ok(())
649    }
650
651    fn stop(&mut self) -> anyhow::Result<()> {
652        if self.core.borrow().is_stopped() {
653            return Ok(());
654        }
655
656        self.deregister_message_handlers();
657        self.cancel_expiry_sweep_timer();
658        self.cancel_inbound_alert();
659
660        // Rejections take the execution channel like every other event, so the engine stopping
661        // this client processes them once its own borrow is released. Without a runner sender they
662        // would dispatch synchronously into an engine `ExecutionEngine::stop` still holds mutably,
663        // which is unsupported
664        {
665            let mut inner = self.inner.borrow_mut();
666            let discarded = inner.take_inbound_queue();
667            inner.reject_discarded(discarded);
668        }
669
670        self.core.borrow().set_stopped();
671        self.core.borrow().set_disconnected();
672        log::info!(
673            "Sandbox execution client stopped: venue={}",
674            self.config.venue
675        );
676        Ok(())
677    }
678
679    async fn connect(&mut self) -> anyhow::Result<()> {
680        if self.core.borrow().is_connected() {
681            return Ok(());
682        }
683
684        let balances = self.get_account_balances();
685        let ts_event = self.clock.borrow().timestamp_ns();
686        self.generate_account_state(balances, vec![], false, ts_event, None)?;
687
688        self.core.borrow().set_connected();
689        log::info!(
690            "Sandbox execution client connected: venue={}",
691            self.config.venue
692        );
693        Ok(())
694    }
695
696    async fn disconnect(&mut self) -> anyhow::Result<()> {
697        if self.core.borrow().is_disconnected() {
698            return Ok(());
699        }
700
701        self.core.borrow().set_disconnected();
702        log::info!(
703            "Sandbox execution client disconnected: venue={}",
704            self.config.venue
705        );
706        Ok(())
707    }
708
709    fn submit_order(&self, cmd: SubmitOrder) -> anyhow::Result<()> {
710        let order = self.get_order(&cmd.client_order_id)?;
711
712        if order.is_closed() {
713            log::warn!("Cannot submit closed order {}", order.client_order_id());
714            return Ok(());
715        }
716
717        let ts_init = self.clock.borrow().timestamp_ns();
718        let event = self.factory.generate_order_submitted(&order, ts_init);
719        self.dispatch_order_event(event);
720
721        let mut inner = self.inner.borrow_mut();
722        if inner.config.latency_model.is_none() {
723            inner.apply_submit_order(&cmd)?;
724        } else {
725            inner.defer_or_apply(TradingCommand::SubmitOrder(cmd));
726        }
727        Ok(())
728    }
729
730    fn submit_order_list(&self, cmd: SubmitOrderList) -> anyhow::Result<()> {
731        let ts_init = self.clock.borrow().timestamp_ns();
732
733        let orders: Vec<OrderAny> = self
734            .cache
735            .borrow()
736            .orders_for_ids(&cmd.order_list.client_order_ids, &cmd);
737
738        for order in &orders {
739            if order.is_closed() {
740                log::warn!("Cannot submit closed order {}", order.client_order_id());
741                continue;
742            }
743
744            let event = self.factory.generate_order_submitted(order, ts_init);
745            self.dispatch_order_event(event);
746        }
747
748        let mut inner = self.inner.borrow_mut();
749        if inner.config.latency_model.is_none() {
750            let _ = inner.apply_submit_order_list(&cmd);
751        } else {
752            inner.defer_or_apply(TradingCommand::SubmitOrderList(cmd));
753        }
754        Ok(())
755    }
756
757    fn modify_order(&self, cmd: ModifyOrder) -> anyhow::Result<()> {
758        let mut inner = self.inner.borrow_mut();
759        if inner.config.latency_model.is_none() {
760            inner.apply_modify_order(&cmd);
761        } else {
762            inner.defer_or_apply(TradingCommand::ModifyOrder(cmd));
763        }
764        Ok(())
765    }
766
767    fn batch_modify_orders(&self, cmd: BatchModifyOrders) -> anyhow::Result<()> {
768        let mut inner = self.inner.borrow_mut();
769        if inner.config.latency_model.is_none() {
770            inner.apply_batch_modify_orders(&cmd);
771        } else {
772            inner.defer_or_apply(TradingCommand::ModifyOrders(cmd));
773        }
774        Ok(())
775    }
776
777    fn cancel_order(&self, cmd: CancelOrder) -> anyhow::Result<()> {
778        let mut inner = self.inner.borrow_mut();
779        if inner.config.latency_model.is_none() {
780            inner.apply_cancel_order(&cmd);
781        } else {
782            inner.defer_or_apply(TradingCommand::CancelOrder(cmd));
783        }
784        Ok(())
785    }
786
787    fn cancel_all_orders(&self, cmd: CancelAllOrders) -> anyhow::Result<()> {
788        let mut inner = self.inner.borrow_mut();
789        if inner.config.latency_model.is_none() {
790            inner.apply_cancel_all_orders(&cmd);
791        } else {
792            inner.defer_or_apply(TradingCommand::CancelAllOrders(cmd));
793        }
794        Ok(())
795    }
796
797    fn batch_cancel_orders(&self, cmd: BatchCancelOrders) -> anyhow::Result<()> {
798        let mut inner = self.inner.borrow_mut();
799        if inner.config.latency_model.is_none() {
800            inner.apply_batch_cancel_orders(&cmd);
801        } else {
802            inner.defer_or_apply(TradingCommand::CancelOrders(cmd));
803        }
804        Ok(())
805    }
806
807    fn query_account(&self, _cmd: QueryAccount) -> anyhow::Result<()> {
808        let balances = self.get_current_account_balances();
809        let ts_event = self.clock.borrow().timestamp_ns();
810        self.generate_account_state(balances, vec![], false, ts_event, None)?;
811        Ok(())
812    }
813
814    fn query_order(&self, _cmd: QueryOrder) -> anyhow::Result<()> {
815        // Orders are tracked in the cache, no external query needed for sandbox
816        Ok(())
817    }
818
819    async fn generate_order_status_report(
820        &self,
821        _cmd: &GenerateOrderStatusReport,
822    ) -> anyhow::Result<Option<OrderStatusReport>> {
823        // Sandbox orders are tracked internally
824        Ok(None)
825    }
826
827    async fn generate_order_status_reports(
828        &self,
829        _cmd: &GenerateOrderStatusReports,
830    ) -> anyhow::Result<Vec<OrderStatusReport>> {
831        // Sandbox orders are tracked internally
832        Ok(Vec::new())
833    }
834
835    async fn generate_fill_reports(
836        &self,
837        _cmd: GenerateFillReports,
838    ) -> anyhow::Result<Vec<FillReport>> {
839        // Sandbox fills are tracked internally
840        Ok(Vec::new())
841    }
842
843    async fn generate_position_status_reports(
844        &self,
845        _cmd: &GeneratePositionStatusReports,
846    ) -> anyhow::Result<Vec<PositionStatusReport>> {
847        // Sandbox positions are tracked internally
848        Ok(Vec::new())
849    }
850
851    async fn generate_mass_status(
852        &self,
853        _lookback_mins: Option<u64>,
854    ) -> anyhow::Result<Option<ExecutionMassStatus>> {
855        let core = self.core.borrow();
856        let ts_init = self.clock.borrow().timestamp_ns();
857        Ok(Some(ExecutionMassStatus::new(
858            core.client_id,
859            core.account_id,
860            core.venue,
861            ts_init,
862            None,
863        )))
864    }
865}
866
867// Wrapped in `Rc<RefCell<>>` so message handlers can hold weak references
868struct SandboxInner {
869    clock: Rc<RefCell<dyn Clock>>,
870    cache: Rc<RefCell<Cache>>,
871    config: SandboxExecutionClientConfig,
872    fill_model: FillModelHandle,
873    matching_engines: AHashMap<InstrumentId, OrderMatchingEngine>,
874    next_engine_raw_id: u32,
875    balances: AHashMap<String, Money>,
876    /// Forwards order events to the runner's execution channel once `start` finds a sender; shared
877    /// with every matching engine this client owns.
878    event_handler: Option<Rc<dyn Fn(OrderEventAny)>>,
879    /// Inbound commands deferred by latency, ordered as a min-heap by due time.
880    inbound_queue: BinaryHeap<DelayedCommand>,
881    inflight_orders: InflightOrders,
882    /// Monotonic sequence providing FIFO tie-breaking for deferred commands sharing a due time, so
883    /// no queued command can be overtaken by one enqueued after it.
884    inbound_seq: u64,
885    client_id: ClientId,
886    account_id: AccountId,
887    self_weak: WeakCell<Self>,
888}
889
890/// A [`TradingCommand`] deferred by inbound latency, ordered by `due_ns` then `seq` so the
891/// `BinaryHeap` behaves as a min-heap for FIFO draining. Equality follows the same key.
892#[derive(Debug)]
893struct DelayedCommand {
894    due_ns: UnixNanos,
895    seq: u64,
896    command: TradingCommand,
897}
898
899impl Ord for DelayedCommand {
900    fn cmp(&self, other: &Self) -> std::cmp::Ordering {
901        other
902            .due_ns
903            .cmp(&self.due_ns)
904            .then_with(|| other.seq.cmp(&self.seq))
905    }
906}
907
908impl PartialOrd for DelayedCommand {
909    fn partial_cmp(&self, other: &Self) -> Option<std::cmp::Ordering> {
910        Some(self.cmp(other))
911    }
912}
913
914impl PartialEq for DelayedCommand {
915    fn eq(&self, other: &Self) -> bool {
916        self.cmp(other) == std::cmp::Ordering::Equal
917    }
918}
919
920impl Eq for DelayedCommand {}
921
922impl SandboxInner {
923    fn ensure_matching_engine(&mut self, instrument: &InstrumentAny) {
924        let instrument_id = instrument.id();
925
926        if !self.matching_engines.contains_key(&instrument_id) {
927            let engine_config = self.config.to_matching_engine_config();
928            let fill_model = self.fill_model.clone();
929            let fee_model = self
930                .config
931                .fee_model
932                .clone()
933                .map(FeeModelHandle::from)
934                .unwrap_or_default();
935            let raw_id = self.next_engine_raw_id;
936            self.next_engine_raw_id = self.next_engine_raw_id.wrapping_add(1);
937
938            let mut engine = OrderMatchingEngine::new(
939                instrument.clone(),
940                raw_id,
941                fill_model,
942                fee_model,
943                self.config.book_type,
944                self.config.oms_type,
945                self.config.account_type,
946                self.clock.clone(),
947                self.cache.clone(),
948                engine_config,
949            );
950
951            if let Some(handler) = &self.event_handler {
952                engine.set_event_handler(handler.clone());
953            }
954
955            engine.set_inflight_orders(self.inflight_orders.clone());
956            self.matching_engines.insert(instrument_id, engine);
957        }
958    }
959
960    fn process_quote_tick(&mut self, quote: &QuoteTick) {
961        let instrument_id = quote.instrument_id;
962
963        let instrument = self.cache.borrow().instrument(&instrument_id).cloned();
964        if let Some(instrument) = instrument {
965            if !check_quote_or_drop("quote tick", quote, &instrument) {
966                return;
967            }
968
969            self.ensure_matching_engine(&instrument);
970
971            if let Some(engine) = self.matching_engines.get_mut(&instrument_id) {
972                engine.process_quote_tick(quote);
973            }
974        }
975    }
976
977    fn process_trade_tick(&mut self, trade: &TradeTick) {
978        if !self.config.trade_execution {
979            return;
980        }
981
982        let instrument_id = trade.instrument_id;
983
984        let instrument = self.cache.borrow().instrument(&instrument_id).cloned();
985        if let Some(instrument) = instrument {
986            if !check_trade_or_drop("trade tick", trade, &instrument) {
987                return;
988            }
989
990            self.ensure_matching_engine(&instrument);
991
992            if let Some(engine) = self.matching_engines.get_mut(&instrument_id) {
993                engine.process_trade_tick(trade);
994            }
995        }
996    }
997
998    fn process_bar(&mut self, bar: &Bar) {
999        if !self.config.bar_execution {
1000            return;
1001        }
1002
1003        let instrument_id = bar.bar_type.instrument_id();
1004
1005        let instrument = self.cache.borrow().instrument(&instrument_id).cloned();
1006        if let Some(instrument) = instrument {
1007            if !check_bar_or_drop("bar", bar, &instrument) {
1008                return;
1009            }
1010
1011            self.ensure_matching_engine(&instrument);
1012
1013            if let Some(engine) = self.matching_engines.get_mut(&instrument_id) {
1014                engine.process_bar(bar);
1015            }
1016        }
1017    }
1018
1019    fn process_order_book_deltas(&mut self, deltas: &OrderBookDeltas) {
1020        let instrument_id = deltas.instrument_id;
1021
1022        let instrument = self.cache.borrow().instrument(&instrument_id).cloned();
1023        if let Some(instrument) = instrument {
1024            self.ensure_matching_engine(&instrument);
1025
1026            if let Some(engine) = self.matching_engines.get_mut(&instrument_id)
1027                && let Err(e) = engine.process_order_book_deltas(deltas)
1028            {
1029                log::error!("Error processing order book deltas: {e}");
1030            }
1031        }
1032    }
1033
1034    fn process_instrument_status(&mut self, status: &InstrumentStatus) {
1035        let instrument_id = status.instrument_id;
1036
1037        if let Some(engine) = self.matching_engines.get_mut(&instrument_id) {
1038            engine.process_status(status.action);
1039            return;
1040        }
1041
1042        let instrument = self.cache.borrow().instrument(&instrument_id).cloned();
1043        if let Some(instrument) = instrument {
1044            self.ensure_matching_engine(&instrument);
1045
1046            if let Some(engine) = self.matching_engines.get_mut(&instrument_id) {
1047                engine.process_status(status.action);
1048            }
1049        } else {
1050            log::warn!(
1051                "Ignoring instrument status for {instrument_id}: instrument missing from cache",
1052            );
1053        }
1054    }
1055
1056    fn process_instrument_close(&mut self, close: &InstrumentClose) {
1057        let instrument_id = close.instrument_id;
1058
1059        // A delayed close belongs to an existing exposure lifecycle. Unlike an
1060        // instrument status update, it must not recreate execution state from
1061        // cache after rotation/unsubscribe; pending-settlement ownership stays
1062        // with the already-initialized matching engine.
1063        if let Some(engine) = self.matching_engines.get_mut(&instrument_id) {
1064            engine.process_instrument_close(*close);
1065            self.sync_expired_cleanup(instrument_id);
1066        } else {
1067            log::warn!(
1068                "Ignoring instrument close for {instrument_id}: no existing matching engine",
1069            );
1070        }
1071    }
1072
1073    fn is_expired_now(&self, instrument_id: InstrumentId) -> bool {
1074        let Some(engine) = self.matching_engines.get(&instrument_id) else {
1075            return false;
1076        };
1077
1078        let now_ns = self.clock.borrow().timestamp_ns();
1079        engine
1080            .instrument
1081            .expiration_ns()
1082            .is_some_and(|ns| now_ns >= ns)
1083    }
1084
1085    fn has_open_orders(&self, instrument_id: InstrumentId) -> bool {
1086        self.cache.borrow().has_orders_open(
1087            Some(&self.config.venue),
1088            Some(&instrument_id),
1089            None,
1090            None,
1091            None,
1092        )
1093    }
1094
1095    fn sync_expired_cleanup(&mut self, instrument_id: InstrumentId) {
1096        if !self.is_expired_now(instrument_id) {
1097            return;
1098        }
1099
1100        let has_open_positions = self.cache.borrow().has_positions_open(
1101            Some(&self.config.venue),
1102            Some(&instrument_id),
1103            None,
1104            None,
1105            None,
1106        );
1107
1108        if has_open_positions {
1109            return;
1110        }
1111
1112        self.matching_engines.remove(&instrument_id);
1113        self.cache
1114            .borrow_mut()
1115            .purge_instrument_skip_order_guard(instrument_id);
1116    }
1117
1118    fn sync_expired_cleanup_many(&mut self, instrument_ids: &[InstrumentId]) {
1119        for &instrument_id in instrument_ids {
1120            self.sync_expired_cleanup(instrument_id);
1121        }
1122    }
1123
1124    // Quote-only instruments may never receive event-driven cleanup. Retain expired engines with
1125    // open positions because this path cannot settle them. Open orders must also remain because
1126    // `Cache::purge_instrument_skip_order_guard` requires callers to terminalize order state first
1127    fn sweep_expired_engines(&mut self) {
1128        let expired_ids: Vec<InstrumentId> = self
1129            .matching_engines
1130            .keys()
1131            .copied()
1132            .filter(|instrument_id| {
1133                self.is_expired_now(*instrument_id) && !self.has_open_orders(*instrument_id)
1134            })
1135            .collect();
1136
1137        self.sync_expired_cleanup_many(&expired_ids);
1138    }
1139
1140    /// Routes a deferred [`TradingCommand`] to its venue-side apply helper.
1141    fn apply_trading_command(&mut self, cmd: &TradingCommand) -> anyhow::Result<()> {
1142        self.inflight_orders.remove(cmd);
1143
1144        // Only a deferred command can overtake the submit that would have created the engine, so
1145        // build it here and let the venue raise the rejection.
1146        if matches!(
1147            cmd,
1148            TradingCommand::ModifyOrder(_)
1149                | TradingCommand::ModifyOrders(_)
1150                | TradingCommand::CancelOrder(_)
1151                | TradingCommand::CancelOrders(_)
1152        ) && !self.ensure_engine_for(cmd.instrument_id())
1153        {
1154            self.reject_command(cmd, "No matching engine for instrument");
1155            return Ok(());
1156        }
1157
1158        match cmd {
1159            TradingCommand::SubmitOrder(cmd) => self.apply_submit_order(cmd)?,
1160            TradingCommand::SubmitOrderList(cmd) => {
1161                // Only a deferred leg has an `OrderSubmitted` out that nothing else would resolve
1162                for order in self.apply_submit_order_list(cmd) {
1163                    self.reject_submit_leg(cmd, &order, "No instrument for order");
1164                }
1165            }
1166            TradingCommand::ModifyOrder(cmd) => self.apply_modify_order(cmd),
1167            TradingCommand::ModifyOrders(cmd) => self.apply_batch_modify_orders(cmd),
1168            TradingCommand::CancelOrder(cmd) => self.apply_cancel_order(cmd),
1169            TradingCommand::CancelOrders(cmd) => self.apply_batch_cancel_orders(cmd),
1170            TradingCommand::CancelAllOrders(cmd) => self.apply_cancel_all_orders(cmd),
1171            TradingCommand::QueryOrder(_) | TradingCommand::QueryAccount(_) => {}
1172        }
1173        Ok(())
1174    }
1175
1176    fn reject_command(&self, command: &TradingCommand, reason: &str) {
1177        self.reject_command_deduped(command, reason, &mut AHashSet::new());
1178    }
1179
1180    /// Dispatches the rejection for `command`, skipping any order that has already received a
1181    /// modify or cancel rejection recorded in `pending_rejected`.
1182    fn reject_command_deduped(
1183        &self,
1184        command: &TradingCommand,
1185        reason: &str,
1186        pending_rejected: &mut AHashSet<ClientOrderId>,
1187    ) {
1188        let ts_now = self.clock.borrow().timestamp_ns();
1189        let account_id = self.account_id;
1190        let reason = Ustr::from(reason);
1191
1192        let reject_submit = |trader_id, strategy_id, instrument_id, client_order_id| {
1193            self.dispatch_order_event(OrderEventAny::Rejected(OrderRejected::new(
1194                trader_id,
1195                strategy_id,
1196                instrument_id,
1197                client_order_id,
1198                account_id,
1199                reason,
1200                UUID4::new(),
1201                ts_now,
1202                ts_now,
1203                false,
1204                false,
1205            )));
1206        };
1207
1208        match command {
1209            TradingCommand::SubmitOrder(cmd) => reject_submit(
1210                cmd.trader_id,
1211                cmd.strategy_id,
1212                cmd.instrument_id,
1213                cmd.client_order_id,
1214            ),
1215            TradingCommand::SubmitOrderList(cmd) => {
1216                // Keyed per leg: the list's `instrument_id` is only representative
1217                let in_flight: Vec<(ClientOrderId, InstrumentId)> = self
1218                    .cache
1219                    .borrow()
1220                    .orders_for_ids(&cmd.order_list.client_order_ids, cmd)
1221                    .iter()
1222                    .filter(|order| !order.is_closed())
1223                    .map(|order| (order.client_order_id(), order.instrument_id()))
1224                    .collect();
1225
1226                for (client_order_id, instrument_id) in in_flight {
1227                    reject_submit(
1228                        cmd.trader_id,
1229                        cmd.strategy_id,
1230                        instrument_id,
1231                        client_order_id,
1232                    );
1233                }
1234            }
1235            TradingCommand::ModifyOrder(cmd) => {
1236                if self.needs_rejection(cmd.client_order_id, pending_rejected) {
1237                    self.reject_modify(cmd, reason, ts_now);
1238                }
1239            }
1240            TradingCommand::ModifyOrders(cmd) => {
1241                for modify in &cmd.modifies {
1242                    if self.needs_rejection(modify.client_order_id, pending_rejected) {
1243                        self.reject_modify(modify, reason, ts_now);
1244                    }
1245                }
1246            }
1247            TradingCommand::CancelOrder(cmd) => {
1248                if self.needs_rejection(cmd.client_order_id, pending_rejected) {
1249                    self.reject_cancel(
1250                        cmd.trader_id,
1251                        cmd.strategy_id,
1252                        cmd.instrument_id,
1253                        cmd.client_order_id,
1254                        cmd.venue_order_id,
1255                        reason,
1256                        ts_now,
1257                    );
1258                }
1259            }
1260            TradingCommand::CancelOrders(cmd) => {
1261                for cancel in &cmd.cancels {
1262                    if self.needs_rejection(cancel.client_order_id, pending_rejected) {
1263                        self.reject_cancel(
1264                            cancel.trader_id,
1265                            cancel.strategy_id,
1266                            cancel.instrument_id,
1267                            cancel.client_order_id,
1268                            cancel.venue_order_id,
1269                            reason,
1270                            ts_now,
1271                        );
1272                    }
1273                }
1274            }
1275            // `CancelAllOrders` names no orders and the strategy marks none `PENDING_CANCEL` for
1276            // it, so there is no pending state to release and the FSM would refuse a rejection.
1277            TradingCommand::CancelAllOrders(_) => {}
1278            TradingCommand::QueryOrder(_) | TradingCommand::QueryAccount(_) => {}
1279        }
1280    }
1281
1282    /// Returns whether a modify or cancel that will not reach the venue still has a rejection to
1283    /// raise for `client_order_id`, recording it in `pending_rejected`.
1284    ///
1285    /// An order closed while the command was in flight has none: the event that closed it already
1286    /// resolved the `PENDING_UPDATE` or `PENDING_CANCEL` a rejection would release, and the FSM
1287    /// has no transition from a closed status to a rejection.
1288    fn needs_rejection(
1289        &self,
1290        client_order_id: ClientOrderId,
1291        pending_rejected: &mut AHashSet<ClientOrderId>,
1292    ) -> bool {
1293        let is_closed = self
1294            .cache
1295            .borrow()
1296            .order(&client_order_id)
1297            .is_some_and(|order| order.is_closed());
1298
1299        !is_closed && pending_rejected.insert(client_order_id)
1300    }
1301
1302    fn reject_modify(&self, cmd: &ModifyOrder, reason: Ustr, ts_now: UnixNanos) {
1303        self.dispatch_order_event(OrderEventAny::ModifyRejected(OrderModifyRejected::new(
1304            cmd.trader_id,
1305            cmd.strategy_id,
1306            cmd.instrument_id,
1307            cmd.client_order_id,
1308            reason,
1309            UUID4::new(),
1310            ts_now,
1311            ts_now,
1312            false,
1313            cmd.venue_order_id,
1314            Some(self.account_id),
1315        )));
1316    }
1317
1318    #[expect(clippy::too_many_arguments, reason = "mirrors the event's own fields")]
1319    fn reject_cancel(
1320        &self,
1321        trader_id: TraderId,
1322        strategy_id: StrategyId,
1323        instrument_id: InstrumentId,
1324        client_order_id: ClientOrderId,
1325        venue_order_id: Option<VenueOrderId>,
1326        reason: Ustr,
1327        ts_now: UnixNanos,
1328    ) {
1329        self.dispatch_order_event(OrderEventAny::CancelRejected(OrderCancelRejected::new(
1330            trader_id,
1331            strategy_id,
1332            instrument_id,
1333            client_order_id,
1334            reason,
1335            UUID4::new(),
1336            ts_now,
1337            ts_now,
1338            false,
1339            venue_order_id,
1340            Some(self.account_id),
1341        )));
1342    }
1343
1344    fn dispatch_order_event(&self, event: OrderEventAny) {
1345        if let Some(handler) = &self.event_handler {
1346            handler(event);
1347        } else {
1348            msgbus::send_order_event(MessagingSwitchboard::exec_engine_process(), event);
1349        }
1350    }
1351
1352    /// Creates the matching engine from the cached instrument when it does not exist yet, so it can
1353    /// answer for an order it has never seen. Returns whether an engine now exists.
1354    fn ensure_engine_for(&mut self, instrument_id: InstrumentId) -> bool {
1355        if !self.matching_engines.contains_key(&instrument_id) {
1356            let instrument = self.cache.borrow().instrument(&instrument_id).cloned();
1357            let Some(instrument) = instrument else {
1358                log::warn!(
1359                    "Cannot process command for {instrument_id}: instrument missing from cache",
1360                );
1361                return false;
1362            };
1363            self.ensure_matching_engine(&instrument);
1364        }
1365
1366        self.matching_engines.contains_key(&instrument_id)
1367    }
1368
1369    fn apply_submit_order(&mut self, cmd: &SubmitOrder) -> anyhow::Result<()> {
1370        let mut order = self.cache.borrow().try_order_owned(&cmd.client_order_id)?;
1371
1372        let instrument_id = order.instrument_id();
1373        let instrument = self.cache.borrow().try_instrument(&instrument_id)?.clone();
1374
1375        self.ensure_matching_engine(&instrument);
1376
1377        let cache = self.cache.borrow();
1378
1379        if let Some(engine) = self.matching_engines.get_mut(&instrument_id) {
1380            if let Some(quote) = cache.quote(&instrument_id)
1381                && check_quote_or_drop("cached quote tick", quote, &instrument)
1382            {
1383                engine.process_quote_tick(quote);
1384            }
1385
1386            if self.config.trade_execution
1387                && let Some(trade) = cache.trade(&instrument_id)
1388                && check_trade_or_drop("cached trade tick", trade, &instrument)
1389            {
1390                engine.process_trade_tick(trade);
1391            }
1392        }
1393        drop(cache);
1394
1395        if let Some(engine) = self.matching_engines.get_mut(&instrument_id) {
1396            engine.process_order(&mut order, self.account_id);
1397            self.sync_expired_cleanup(instrument_id);
1398        }
1399
1400        Ok(())
1401    }
1402
1403    /// Applies a submit-order-list command to the matching engines (venue-side), less the per-order
1404    /// `OrderSubmitted` dispatch kept by the client handler, returning the legs that could not
1405    /// reach a matching engine.
1406    fn apply_submit_order_list(&mut self, cmd: &SubmitOrderList) -> Vec<OrderAny> {
1407        let orders: Vec<OrderAny> = self
1408            .cache
1409            .borrow()
1410            .orders_for_ids(&cmd.order_list.client_order_ids, cmd);
1411
1412        let mut cleanup_instrument_ids = Vec::new();
1413        let mut unresolved: Vec<OrderAny> = Vec::new();
1414
1415        for order in &orders {
1416            if order.is_closed() {
1417                continue;
1418            }
1419
1420            let instrument_id = order.instrument_id();
1421            if !cleanup_instrument_ids.contains(&instrument_id) {
1422                cleanup_instrument_ids.push(instrument_id);
1423            }
1424            let instrument = self.cache.borrow().instrument(&instrument_id).cloned();
1425
1426            let Some(instrument) = instrument else {
1427                // Skipped per leg rather than failing the whole command: the legs that did reach
1428                // the venue are live orders.
1429                unresolved.push(order.clone());
1430                continue;
1431            };
1432
1433            self.ensure_matching_engine(&instrument);
1434
1435            let cache = self.cache.borrow();
1436
1437            if let Some(engine) = self.matching_engines.get_mut(&instrument_id) {
1438                if let Some(quote) = cache.quote(&instrument_id)
1439                    && check_quote_or_drop("cached quote tick", quote, &instrument)
1440                {
1441                    engine.process_quote_tick(quote);
1442                }
1443
1444                if self.config.trade_execution
1445                    && let Some(trade) = cache.trade(&instrument_id)
1446                    && check_trade_or_drop("cached trade tick", trade, &instrument)
1447                {
1448                    engine.process_trade_tick(trade);
1449                }
1450            }
1451            drop(cache);
1452
1453            if let Some(engine) = self.matching_engines.get_mut(&instrument_id) {
1454                let mut order_clone = order.clone();
1455                engine.process_order(&mut order_clone, self.account_id);
1456            }
1457        }
1458
1459        if !cleanup_instrument_ids.is_empty() {
1460            self.sync_expired_cleanup_many(&cleanup_instrument_ids);
1461        }
1462
1463        unresolved
1464    }
1465
1466    /// Rejects a single leg of `cmd` the venue could not accept, leaving its siblings untouched.
1467    fn reject_submit_leg(&self, cmd: &SubmitOrderList, order: &OrderAny, reason: &str) {
1468        let ts_now = self.clock.borrow().timestamp_ns();
1469        self.dispatch_order_event(OrderEventAny::Rejected(OrderRejected::new(
1470            cmd.trader_id,
1471            cmd.strategy_id,
1472            order.instrument_id(),
1473            order.client_order_id(),
1474            self.account_id,
1475            Ustr::from(reason),
1476            UUID4::new(),
1477            ts_now,
1478            ts_now,
1479            false,
1480            false,
1481        )));
1482    }
1483
1484    fn apply_modify_order(&mut self, cmd: &ModifyOrder) {
1485        let account_id = self.account_id;
1486        if let Some(engine) = self.matching_engines.get_mut(&cmd.instrument_id) {
1487            engine.process_modify(cmd, account_id);
1488        }
1489    }
1490
1491    fn apply_batch_modify_orders(&mut self, cmd: &BatchModifyOrders) {
1492        let account_id = self.account_id;
1493        if let Some(engine) = self.matching_engines.get_mut(&cmd.instrument_id) {
1494            engine.process_batch_modify(cmd, account_id);
1495        }
1496    }
1497
1498    fn apply_cancel_order(&mut self, cmd: &CancelOrder) {
1499        let account_id = self.account_id;
1500        if let Some(engine) = self.matching_engines.get_mut(&cmd.instrument_id) {
1501            engine.process_cancel(cmd, account_id);
1502        }
1503    }
1504
1505    fn apply_cancel_all_orders(&mut self, cmd: &CancelAllOrders) {
1506        let instrument_id = cmd.instrument_id;
1507        if let Some(engine) = self.matching_engines.get_mut(&instrument_id) {
1508            engine.process_cancel_all(cmd, self.account_id);
1509        } else {
1510            log::debug!("No open orders to cancel for {instrument_id}: no matching engine");
1511        }
1512    }
1513
1514    fn apply_batch_cancel_orders(&mut self, cmd: &BatchCancelOrders) {
1515        let account_id = self.account_id;
1516        if let Some(engine) = self.matching_engines.get_mut(&cmd.instrument_id) {
1517            engine.process_batch_cancel(cmd, account_id);
1518        }
1519    }
1520
1521    /// Enqueues a trading command to be applied after its inbound latency elapses, as backtest
1522    /// `generate_inflight_command` does, but keyed off arrival rather than `command.ts_init()`.
1523    fn enqueue(&mut self, command: TradingCommand, now_ns: UnixNanos) {
1524        let leg_latency = self.command_leg_latency(&command);
1525        let due_ns = now_ns + leg_latency;
1526
1527        let seq = self.inbound_seq;
1528        self.inbound_seq += 1;
1529
1530        self.inflight_orders.insert(&command);
1531        self.inbound_queue.push(DelayedCommand {
1532            due_ns,
1533            seq,
1534            command,
1535        });
1536
1537        self.arm_inbound_alert(now_ns);
1538    }
1539
1540    /// Defers `command` by its inbound latency leg, or applies it inline when that leg is zero.
1541    ///
1542    /// A zero-leg command is still queued behind a head already due, so the two apply in
1543    /// `(due_ns, seq)` order.
1544    fn defer_or_apply(&mut self, command: TradingCommand) {
1545        let now_ns = self.clock.borrow().timestamp_ns();
1546        let head_is_due = self
1547            .inbound_queue
1548            .peek()
1549            .is_some_and(|delayed| delayed.due_ns <= now_ns);
1550
1551        if head_is_due || self.command_leg_latency(&command) > DurationNanos::ZERO {
1552            self.enqueue(command, now_ns);
1553            return;
1554        }
1555
1556        if let Err(e) = self.apply_trading_command(&command) {
1557            log::error!("Error applying command: {e}");
1558            self.reject_command(&command, "Command could not be applied at the venue");
1559        }
1560    }
1561
1562    /// Returns the inbound latency leg for `command`, or zero when no model is set.
1563    fn command_leg_latency(&self, command: &TradingCommand) -> DurationNanos {
1564        let Some(latency_model) = self.config.latency_model.as_ref() else {
1565            return DurationNanos::ZERO;
1566        };
1567
1568        match command {
1569            TradingCommand::SubmitOrder(_) | TradingCommand::SubmitOrderList(_) => {
1570                latency_model.get_insert_latency()
1571            }
1572            TradingCommand::ModifyOrder(_) | TradingCommand::ModifyOrders(_) => {
1573                latency_model.get_update_latency()
1574            }
1575            TradingCommand::CancelOrder(_)
1576            | TradingCommand::CancelOrders(_)
1577            | TradingCommand::CancelAllOrders(_) => latency_model.get_delete_latency(),
1578            TradingCommand::QueryOrder(_) | TradingCommand::QueryAccount(_) => DurationNanos::ZERO,
1579        }
1580    }
1581
1582    fn pop_due(&mut self, now_ns: UnixNanos) -> Option<DelayedCommand> {
1583        self.inbound_queue
1584            .peek()
1585            .is_some_and(|delayed| delayed.due_ns <= now_ns)
1586            .then(|| self.inbound_queue.pop().expect("peek returned Some"))
1587    }
1588
1589    fn on_quote_tick(inner: &Rc<RefCell<Self>>, quote: &QuoteTick) {
1590        Self::drain_inbound(inner);
1591        inner.borrow_mut().process_quote_tick(quote);
1592    }
1593
1594    fn on_trade_tick(inner: &Rc<RefCell<Self>>, trade: &TradeTick) {
1595        Self::drain_inbound(inner);
1596        inner.borrow_mut().process_trade_tick(trade);
1597    }
1598
1599    fn on_bar(inner: &Rc<RefCell<Self>>, bar: &Bar) {
1600        Self::drain_inbound(inner);
1601        inner.borrow_mut().process_bar(bar);
1602    }
1603
1604    fn on_order_book_deltas(inner: &Rc<RefCell<Self>>, deltas: &OrderBookDeltas) {
1605        Self::drain_inbound(inner);
1606        inner.borrow_mut().process_order_book_deltas(deltas);
1607    }
1608
1609    fn on_instrument_status(inner: &Rc<RefCell<Self>>, status: &InstrumentStatus) {
1610        Self::drain_inbound(inner);
1611        inner.borrow_mut().process_instrument_status(status);
1612    }
1613
1614    fn on_instrument_close(inner: &Rc<RefCell<Self>>, close: &InstrumentClose) {
1615        Self::drain_inbound(inner);
1616        inner.borrow_mut().process_instrument_close(close);
1617    }
1618
1619    /// Applies every inbound command whose latency has elapsed, in `(due_ns, seq)` order, then
1620    /// arms the alert for the earliest command still queued.
1621    ///
1622    /// Called at the top of each data handler and public `process_*` method, so a command due
1623    /// before a tick is processed is applied before that tick, and by the alert when no data is
1624    /// flowing.
1625    fn drain_inbound(inner: &Rc<RefCell<Self>>) {
1626        // The alert fires on the runner task, where a nested msgbus dispatch may already hold the
1627        // borrow; the next data tick or public `process_*` call releases the queue instead.
1628        let Ok(mut this) = inner.try_borrow_mut() else {
1629            log::debug!("Skipping sandbox inbound drain due to active borrow");
1630            return;
1631        };
1632
1633        if this.config.latency_model.is_none() {
1634            return;
1635        }
1636
1637        let now_ns = this.clock.borrow().timestamp_ns();
1638
1639        while let Some(delayed) = this.pop_due(now_ns) {
1640            if let Err(e) = this.apply_trading_command(&delayed.command) {
1641                log::error!("Error applying deferred command: {e}");
1642                this.reject_command(
1643                    &delayed.command,
1644                    "Command could not be applied at the venue",
1645                );
1646            }
1647        }
1648
1649        this.arm_inbound_alert(now_ns);
1650    }
1651
1652    /// Takes every command still deferred by inbound latency, ordered for [`Self::reject_discarded`]
1653    /// to unwind last-issued-first.
1654    fn take_inbound_queue(&mut self) -> Vec<DelayedCommand> {
1655        self.inflight_orders.clear();
1656
1657        if self.inbound_queue.is_empty() {
1658            return Vec::new();
1659        }
1660
1661        log::warn!(
1662            "Discarding {} command(s) still in flight at stop",
1663            self.inbound_queue.len(),
1664        );
1665
1666        // Unwind last-issued-first by `seq`, so each rejection restores the state the command
1667        // before it established.
1668        let mut discarded = std::mem::take(&mut self.inbound_queue).into_vec();
1669        discarded.sort_unstable_by_key(|delayed| std::cmp::Reverse(delayed.seq));
1670        discarded
1671    }
1672
1673    /// Rejects every command [`Self::take_inbound_queue`] discarded.
1674    fn reject_discarded(&self, discarded: Vec<DelayedCommand>) {
1675        // Shared across the whole unwind: one order can have several pending commands in flight,
1676        // but only the first rejection it receives has a valid FSM transition.
1677        let mut pending_rejected = AHashSet::new();
1678
1679        for delayed in discarded {
1680            self.reject_command_deduped(
1681                &delayed.command,
1682                "Client stopped before the command was sent",
1683                &mut pending_rejected,
1684            );
1685        }
1686    }
1687
1688    /// Clears every command still deferred by inbound latency, without rejecting any of them:
1689    /// `reset` discards all client state, so there is nothing to release.
1690    fn clear_inbound_queue(&mut self) {
1691        self.inbound_queue.clear();
1692        self.inflight_orders.clear();
1693    }
1694
1695    /// (Re)arms the `LiveClock` alert for the earliest queued `due_ns` while that is still ahead
1696    /// of `now_ns`.
1697    ///
1698    /// A due time already reached is not armed for: the drain releasing it is already pending on
1699    /// the runner, and an alert at a time the clock has passed only asks the clock to warn. The
1700    /// clock reads its own time again when arming, so a leg shorter than that gap can still warn.
1701    fn arm_inbound_alert(&self, now_ns: UnixNanos) {
1702        let Some(earliest_due) = self.inbound_queue.peek().map(|delayed| delayed.due_ns) else {
1703            return;
1704        };
1705
1706        if earliest_due <= now_ns {
1707            return;
1708        }
1709
1710        let name = inbound_alert_name(self.client_id);
1711        let armed_ns = self.clock.borrow().next_time_ns(&name);
1712
1713        match armed_ns {
1714            // Already armed no later than the new earliest due, so that alert still wakes the drain
1715            Some(armed_ns) if armed_ns <= earliest_due => return,
1716            // Canceling first avoids the warning `replace_existing_timer` would log
1717            Some(_) => self.clock.borrow_mut().cancel_timer(&name),
1718            None => {}
1719        }
1720
1721        let inner_weak = self.self_weak.clone();
1722        let alert_name = name.clone();
1723
1724        let callback: Rc<dyn Fn(TimeEvent)> = Rc::new(move |_event: TimeEvent| {
1725            let Some(inner_rc) = inner_weak.upgrade() else {
1726                return;
1727            };
1728
1729            // Retire the spent one-shot before the drain's exit path reads it back as still armed
1730            if let Ok(this) = inner_rc.try_borrow() {
1731                this.clock.borrow_mut().cancel_timer(&alert_name);
1732            }
1733
1734            // The pass arms for whatever it leaves queued
1735            Self::drain_inbound(&inner_rc);
1736        });
1737
1738        if let Err(e) = self.clock.borrow_mut().set_time_alert_ns(
1739            &name,
1740            earliest_due,
1741            Some(TimeEventCallback::from(callback)),
1742            Some(true),
1743        ) {
1744            log::error!("Failed to arm sandbox inbound alert '{name}': {e}");
1745        }
1746    }
1747}
1748
1749fn inbound_alert_name(client_id: ClientId) -> String {
1750    format!("{client_id}-sandbox-inbound-alert")
1751}
1752
1753fn check_quote_or_drop(context: &str, quote: &QuoteTick, instrument: &InstrumentAny) -> bool {
1754    if quote_matches_instrument_precision(quote, instrument) {
1755        return true;
1756    }
1757
1758    log::warn!(
1759        "Dropping {context} for {} due to precision mismatch \
1760         (bid_px={}, ask_px={}, bid_sz={}, ask_sz={}, expected_price={}, expected_size={})",
1761        instrument.id(),
1762        quote.bid_price.precision,
1763        quote.ask_price.precision,
1764        quote.bid_size.precision,
1765        quote.ask_size.precision,
1766        instrument.price_precision(),
1767        instrument.size_precision(),
1768    );
1769    false
1770}
1771
1772fn check_trade_or_drop(context: &str, trade: &TradeTick, instrument: &InstrumentAny) -> bool {
1773    if trade_matches_instrument_precision(trade, instrument) {
1774        return true;
1775    }
1776
1777    log::warn!(
1778        "Dropping {context} for {} due to precision mismatch \
1779         (px={}, sz={}, expected_price={}, expected_size={})",
1780        instrument.id(),
1781        trade.price.precision,
1782        trade.size.precision,
1783        instrument.price_precision(),
1784        instrument.size_precision(),
1785    );
1786    false
1787}
1788
1789fn check_bar_or_drop(context: &str, bar: &Bar, instrument: &InstrumentAny) -> bool {
1790    if bar_matches_instrument_precision(bar, instrument) {
1791        return true;
1792    }
1793
1794    log::warn!(
1795        "Dropping {context} for {} due to precision mismatch \
1796         (open={}, high={}, low={}, close={}, volume={}, expected_price={}, expected_size={})",
1797        instrument.id(),
1798        bar.open.precision,
1799        bar.high.precision,
1800        bar.low.precision,
1801        bar.close.precision,
1802        bar.volume.precision,
1803        instrument.price_precision(),
1804        instrument.size_precision(),
1805    );
1806    false
1807}
1808
1809fn quote_matches_instrument_precision(quote: &QuoteTick, instrument: &InstrumentAny) -> bool {
1810    let price_precision = instrument.price_precision();
1811    let size_precision = instrument.size_precision();
1812
1813    quote.bid_price.precision == price_precision
1814        && quote.ask_price.precision == price_precision
1815        && quote.bid_size.precision == size_precision
1816        && quote.ask_size.precision == size_precision
1817}
1818
1819fn trade_matches_instrument_precision(trade: &TradeTick, instrument: &InstrumentAny) -> bool {
1820    let price_precision = instrument.price_precision();
1821    let size_precision = instrument.size_precision();
1822
1823    trade.price.precision == price_precision && trade.size.precision == size_precision
1824}
1825
1826fn bar_matches_instrument_precision(bar: &Bar, instrument: &InstrumentAny) -> bool {
1827    let price_precision = instrument.price_precision();
1828    let size_precision = instrument.size_precision();
1829
1830    bar.open.precision == price_precision
1831        && bar.high.precision == price_precision
1832        && bar.low.precision == price_precision
1833        && bar.close.precision == price_precision
1834        && bar.volume.precision == size_precision
1835}