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