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