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, fmt::Debug, rc::Rc};
19
20use ahash::AHashMap;
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,
35        },
36    },
37    msgbus::{
38        self, MStr, MessagingSwitchboard, Pattern, TypedHandler,
39        typed_handler::ShareableMessageHandler,
40    },
41    timer::{TimeEvent, TimeEventCallback},
42};
43use nautilus_core::{Params, UnixNanos, WeakCell, datetime::NANOSECONDS_IN_SECOND};
44use nautilus_execution::{
45    client::core::ExecutionClientCore,
46    matching_engine::OrderMatchingEngine,
47    models::{fee::FeeModelHandle, fill::FillModelHandle},
48};
49use nautilus_model::{
50    accounts::AccountAny,
51    data::{Bar, InstrumentClose, InstrumentStatus, OrderBookDeltas, QuoteTick, TradeTick},
52    enums::OmsType,
53    events::{OrderEventAny, PositionEvent},
54    identifiers::{AccountId, ClientId, ClientOrderId, InstrumentId, Venue},
55    instruments::{Instrument, InstrumentAny},
56    orders::{Order, OrderAny},
57    reports::{ExecutionMassStatus, FillReport, OrderStatusReport, PositionStatusReport},
58    types::{AccountBalance, MarginBalance, Money},
59};
60
61use crate::config::SandboxExecutionClientConfig;
62
63/// Interval between periodic sweeps that retire expired matching engines with no open position.
64///
65/// This bounds retained matching-engine and cache state for quote-only instruments that expire
66/// without an `InstrumentClose`, expired-order, or `PositionClosed` event to trigger cleanup.
67const EXPIRED_ENGINE_SWEEP_INTERVAL_NS: u64 = 60 * NANOSECONDS_IN_SECOND;
68
69/// Inner state for the sandbox execution client.
70///
71/// This is wrapped in `Rc<RefCell<>>` so message handlers can hold weak references.
72struct SandboxInner {
73    /// Dynamic clock for matching engines.
74    clock: Rc<RefCell<dyn Clock>>,
75    /// Reference to the cache.
76    cache: Rc<RefCell<Cache>>,
77    /// The sandbox configuration.
78    config: SandboxExecutionClientConfig,
79    /// Shared fill-model handle for every matching engine on this client.
80    fill_model: FillModelHandle,
81    /// Matching engines per instrument.
82    matching_engines: AHashMap<InstrumentId, OrderMatchingEngine>,
83    /// Next raw ID assigned to a matching engine.
84    next_engine_raw_id: u32,
85    /// Current account balances.
86    balances: AHashMap<String, Money>,
87    event_handler: Option<Rc<dyn Fn(OrderEventAny)>>,
88}
89
90fn check_quote_or_drop(context: &str, quote: &QuoteTick, instrument: &InstrumentAny) -> bool {
91    if quote_matches_instrument_precision(quote, instrument) {
92        return true;
93    }
94
95    log::warn!(
96        "Dropping {context} for {} due to precision mismatch \
97         (bid_px={}, ask_px={}, bid_sz={}, ask_sz={}, expected_price={}, expected_size={})",
98        instrument.id(),
99        quote.bid_price.precision,
100        quote.ask_price.precision,
101        quote.bid_size.precision,
102        quote.ask_size.precision,
103        instrument.price_precision(),
104        instrument.size_precision(),
105    );
106    false
107}
108
109fn check_trade_or_drop(context: &str, trade: &TradeTick, instrument: &InstrumentAny) -> bool {
110    if trade_matches_instrument_precision(trade, instrument) {
111        return true;
112    }
113
114    log::warn!(
115        "Dropping {context} for {} due to precision mismatch \
116         (px={}, sz={}, expected_price={}, expected_size={})",
117        instrument.id(),
118        trade.price.precision,
119        trade.size.precision,
120        instrument.price_precision(),
121        instrument.size_precision(),
122    );
123    false
124}
125
126fn check_bar_or_drop(context: &str, bar: &Bar, instrument: &InstrumentAny) -> bool {
127    if bar_matches_instrument_precision(bar, instrument) {
128        return true;
129    }
130
131    log::warn!(
132        "Dropping {context} for {} due to precision mismatch \
133         (open={}, high={}, low={}, close={}, volume={}, expected_price={}, expected_size={})",
134        instrument.id(),
135        bar.open.precision,
136        bar.high.precision,
137        bar.low.precision,
138        bar.close.precision,
139        bar.volume.precision,
140        instrument.price_precision(),
141        instrument.size_precision(),
142    );
143    false
144}
145
146fn quote_matches_instrument_precision(quote: &QuoteTick, instrument: &InstrumentAny) -> bool {
147    let price_precision = instrument.price_precision();
148    let size_precision = instrument.size_precision();
149
150    quote.bid_price.precision == price_precision
151        && quote.ask_price.precision == price_precision
152        && quote.bid_size.precision == size_precision
153        && quote.ask_size.precision == size_precision
154}
155
156fn trade_matches_instrument_precision(trade: &TradeTick, instrument: &InstrumentAny) -> bool {
157    let price_precision = instrument.price_precision();
158    let size_precision = instrument.size_precision();
159
160    trade.price.precision == price_precision && trade.size.precision == size_precision
161}
162
163fn bar_matches_instrument_precision(bar: &Bar, instrument: &InstrumentAny) -> bool {
164    let price_precision = instrument.price_precision();
165    let size_precision = instrument.size_precision();
166
167    bar.open.precision == price_precision
168        && bar.high.precision == price_precision
169        && bar.low.precision == price_precision
170        && bar.close.precision == price_precision
171        && bar.volume.precision == size_precision
172}
173
174impl SandboxInner {
175    /// Ensures a matching engine exists for the given instrument.
176    fn ensure_matching_engine(&mut self, instrument: &InstrumentAny) {
177        let instrument_id = instrument.id();
178
179        if !self.matching_engines.contains_key(&instrument_id) {
180            let engine_config = self.config.to_matching_engine_config();
181            let fill_model = self.fill_model.clone();
182            let fee_model = self
183                .config
184                .fee_model
185                .clone()
186                .map(FeeModelHandle::from)
187                .unwrap_or_default();
188            let raw_id = self.next_engine_raw_id;
189            self.next_engine_raw_id = self.next_engine_raw_id.wrapping_add(1);
190
191            let mut engine = OrderMatchingEngine::new(
192                instrument.clone(),
193                raw_id,
194                fill_model,
195                fee_model,
196                self.config.book_type,
197                self.config.oms_type,
198                self.config.account_type,
199                self.clock.clone(),
200                self.cache.clone(),
201                engine_config,
202            );
203
204            if let Some(handler) = &self.event_handler {
205                engine.set_event_handler(handler.clone());
206            }
207
208            self.matching_engines.insert(instrument_id, engine);
209        }
210    }
211
212    /// Processes a quote tick through the matching engine.
213    fn process_quote_tick(&mut self, quote: &QuoteTick) {
214        let instrument_id = quote.instrument_id;
215
216        // Try to get instrument from cache, create engine if found
217        let instrument = self.cache.borrow().instrument(&instrument_id).cloned();
218        if let Some(instrument) = instrument {
219            if !check_quote_or_drop("quote tick", quote, &instrument) {
220                return;
221            }
222
223            self.ensure_matching_engine(&instrument);
224
225            if let Some(engine) = self.matching_engines.get_mut(&instrument_id) {
226                engine.process_quote_tick(quote);
227            }
228        }
229    }
230
231    /// Processes a trade tick through the matching engine.
232    fn process_trade_tick(&mut self, trade: &TradeTick) {
233        if !self.config.trade_execution {
234            return;
235        }
236
237        let instrument_id = trade.instrument_id;
238
239        let instrument = self.cache.borrow().instrument(&instrument_id).cloned();
240        if let Some(instrument) = instrument {
241            if !check_trade_or_drop("trade tick", trade, &instrument) {
242                return;
243            }
244
245            self.ensure_matching_engine(&instrument);
246
247            if let Some(engine) = self.matching_engines.get_mut(&instrument_id) {
248                engine.process_trade_tick(trade);
249            }
250        }
251    }
252
253    /// Processes a bar through the matching engine.
254    fn process_bar(&mut self, bar: &Bar) {
255        if !self.config.bar_execution {
256            return;
257        }
258
259        let instrument_id = bar.bar_type.instrument_id();
260
261        let instrument = self.cache.borrow().instrument(&instrument_id).cloned();
262        if let Some(instrument) = instrument {
263            if !check_bar_or_drop("bar", bar, &instrument) {
264                return;
265            }
266
267            self.ensure_matching_engine(&instrument);
268
269            if let Some(engine) = self.matching_engines.get_mut(&instrument_id) {
270                engine.process_bar(bar);
271            }
272        }
273    }
274
275    fn process_order_book_deltas(&mut self, deltas: &OrderBookDeltas) {
276        let instrument_id = deltas.instrument_id;
277
278        let instrument = self.cache.borrow().instrument(&instrument_id).cloned();
279        if let Some(instrument) = instrument {
280            self.ensure_matching_engine(&instrument);
281
282            if let Some(engine) = self.matching_engines.get_mut(&instrument_id)
283                && let Err(e) = engine.process_order_book_deltas(deltas)
284            {
285                log::error!("Error processing order book deltas: {e}");
286            }
287        }
288    }
289
290    fn process_instrument_status(&mut self, status: &InstrumentStatus) {
291        let instrument_id = status.instrument_id;
292
293        if let Some(engine) = self.matching_engines.get_mut(&instrument_id) {
294            engine.process_status(status.action);
295            return;
296        }
297
298        let instrument = self.cache.borrow().instrument(&instrument_id).cloned();
299        if let Some(instrument) = instrument {
300            self.ensure_matching_engine(&instrument);
301
302            if let Some(engine) = self.matching_engines.get_mut(&instrument_id) {
303                engine.process_status(status.action);
304            }
305        } else {
306            log::warn!(
307                "Ignoring instrument status for {instrument_id}: instrument missing from cache",
308            );
309        }
310    }
311
312    fn process_instrument_close(&mut self, close: &InstrumentClose) {
313        let instrument_id = close.instrument_id;
314
315        // A delayed close belongs to an existing exposure lifecycle. Unlike an
316        // instrument status update, it must not recreate execution state from
317        // cache after rotation/unsubscribe; pending-settlement ownership stays
318        // with the already-initialized matching engine.
319        if let Some(engine) = self.matching_engines.get_mut(&instrument_id) {
320            engine.process_instrument_close(*close);
321            self.sync_expired_cleanup(instrument_id);
322        } else {
323            log::warn!(
324                "Ignoring instrument close for {instrument_id}: no existing matching engine",
325            );
326        }
327    }
328
329    fn is_expired_now(&self, instrument_id: InstrumentId) -> bool {
330        let Some(engine) = self.matching_engines.get(&instrument_id) else {
331            return false;
332        };
333
334        let now_ns = self.clock.borrow().timestamp_ns();
335        engine
336            .instrument
337            .expiration_ns()
338            .is_some_and(|ns| now_ns >= ns)
339    }
340
341    fn has_open_orders(&self, instrument_id: InstrumentId) -> bool {
342        self.cache.borrow().has_orders_open(
343            Some(&self.config.venue),
344            Some(&instrument_id),
345            None,
346            None,
347            None,
348        )
349    }
350
351    fn sync_expired_cleanup(&mut self, instrument_id: InstrumentId) {
352        if !self.is_expired_now(instrument_id) {
353            return;
354        }
355
356        let has_open_positions = self.cache.borrow().has_positions_open(
357            Some(&self.config.venue),
358            Some(&instrument_id),
359            None,
360            None,
361            None,
362        );
363
364        if has_open_positions {
365            return;
366        }
367
368        self.matching_engines.remove(&instrument_id);
369        self.cache
370            .borrow_mut()
371            .purge_instrument_skip_order_guard(instrument_id);
372    }
373
374    fn sync_expired_cleanup_many(&mut self, instrument_ids: &[InstrumentId]) {
375        for &instrument_id in instrument_ids {
376            self.sync_expired_cleanup(instrument_id);
377        }
378    }
379
380    /// Retires matching engines whose instrument has expired with no open position or order.
381    ///
382    /// This is the periodic trigger for quote-only instruments that create a matching engine from
383    /// market data but never reach an `InstrumentClose`, expired-order, or `PositionClosed` event.
384    /// It performs no settlement: `sync_expired_cleanup` retains any expired engine that still has
385    /// an open position.
386    ///
387    /// Instruments with open orders are retained too. The event-driven callers of
388    /// `sync_expired_cleanup` each terminalize order state through the matching engine first, which
389    /// is what `Cache::purge_instrument_skip_order_guard` requires of its callers; this sweep has
390    /// no such event, so purging here would orphan a resting order behind a removed engine.
391    fn sweep_expired_engines(&mut self) {
392        let expired_ids: Vec<InstrumentId> = self
393            .matching_engines
394            .keys()
395            .copied()
396            .filter(|instrument_id| {
397                self.is_expired_now(*instrument_id) && !self.has_open_orders(*instrument_id)
398            })
399            .collect();
400
401        self.sync_expired_cleanup_many(&expired_ids);
402    }
403}
404
405/// Registered message handlers for later deregistration.
406struct RegisteredHandlers {
407    deltas_pattern: MStr<Pattern>,
408    deltas_handler: TypedHandler<OrderBookDeltas>,
409    quote_pattern: MStr<Pattern>,
410    quote_handler: TypedHandler<QuoteTick>,
411    trade_pattern: MStr<Pattern>,
412    trade_handler: TypedHandler<TradeTick>,
413    bar_pattern: MStr<Pattern>,
414    bar_handler: TypedHandler<Bar>,
415    status_pattern: MStr<Pattern>,
416    status_handler: ShareableMessageHandler,
417    close_pattern: MStr<Pattern>,
418    close_handler: ShareableMessageHandler,
419    position_pattern: MStr<Pattern>,
420    position_handler: TypedHandler<PositionEvent>,
421}
422
423/// A sandbox execution client for paper trading against live market data.
424///
425/// The `SandboxExecutionClient` simulates order execution using the `OrderMatchingEngine`
426/// to match orders against market data. This enables strategy testing in real-time
427/// without actual order execution on exchanges.
428pub struct SandboxExecutionClient {
429    /// The core execution client functionality.
430    core: RefCell<ExecutionClientCore>,
431    /// Factory for generating order events.
432    factory: OrderEventFactory,
433    /// The sandbox configuration.
434    config: SandboxExecutionClientConfig,
435    /// Inner state wrapped for handler access.
436    inner: Rc<RefCell<SandboxInner>>,
437    /// Registered message handlers for cleanup.
438    handlers: RefCell<Option<RegisteredHandlers>>,
439    /// Reference to the clock.
440    clock: Rc<RefCell<dyn Clock>>,
441    /// Reference to the cache.
442    cache: Rc<RefCell<Cache>>,
443}
444
445impl Debug for SandboxExecutionClient {
446    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
447        f.debug_struct(stringify!(SandboxExecutionClient))
448            .field("venue", &self.config.venue)
449            .field("account_id", &self.core.borrow().account_id)
450            .field("connected", &self.core.borrow().is_connected())
451            .field(
452                "matching_engines",
453                &self.inner.borrow().matching_engines.len(),
454            )
455            .finish()
456    }
457}
458
459impl SandboxExecutionClient {
460    /// Creates a new [`SandboxExecutionClient`] instance.
461    #[must_use]
462    pub fn new(
463        core: ExecutionClientCore,
464        config: SandboxExecutionClientConfig,
465        clock: Rc<RefCell<dyn Clock>>,
466        cache: Rc<RefCell<Cache>>,
467    ) -> Self {
468        let mut balances = AHashMap::new();
469        for money in &config.starting_balances {
470            balances.insert(money.currency.code.to_string(), *money);
471        }
472
473        let fill_model = config
474            .fill_model
475            .clone()
476            .map(FillModelHandle::from)
477            .unwrap_or_default();
478        let inner = Rc::new(RefCell::new(SandboxInner {
479            clock: clock.clone(),
480            cache: cache.clone(),
481            config: config.clone(),
482            fill_model,
483            matching_engines: AHashMap::new(),
484            next_engine_raw_id: 0,
485            balances,
486            event_handler: None,
487        }));
488
489        let factory = OrderEventFactory::new(
490            core.trader_id,
491            core.account_id,
492            core.account_type,
493            core.base_currency,
494        );
495
496        Self {
497            core: RefCell::new(core),
498            factory,
499            config,
500            inner,
501            handlers: RefCell::new(None),
502            clock,
503            cache,
504        }
505    }
506
507    /// Returns a reference to the configuration.
508    #[must_use]
509    pub const fn config(&self) -> &SandboxExecutionClientConfig {
510        &self.config
511    }
512
513    /// Returns the number of active matching engines.
514    #[must_use]
515    pub fn matching_engine_count(&self) -> usize {
516        self.inner.borrow().matching_engines.len()
517    }
518
519    fn dispatch_order_event(&self, event: OrderEventAny) {
520        if let Some(handler) = &self.inner.borrow().event_handler {
521            handler(event);
522        } else {
523            let endpoint = MessagingSwitchboard::exec_engine_process();
524            msgbus::send_order_event(endpoint, event);
525        }
526    }
527
528    /// Registers message handlers for market data subscriptions.
529    ///
530    /// This subscribes to order book deltas, quotes, trades, and bars for the
531    /// configured venue, routing all received data to the matching engines.
532    fn register_message_handlers(&self) {
533        if self.handlers.borrow().is_some() {
534            log::warn!("Sandbox message handlers already registered");
535            return;
536        }
537
538        let inner_weak = WeakCell::from(Rc::downgrade(&self.inner));
539        let venue = self.config.venue;
540        let account_id = self.core.borrow().account_id;
541
542        // Order book deltas handler
543        let deltas_handler = {
544            let inner = inner_weak.clone();
545            TypedHandler::from(move |deltas: &OrderBookDeltas| {
546                if deltas.instrument_id.venue == venue
547                    && let Some(inner_rc) = inner.upgrade()
548                {
549                    inner_rc.borrow_mut().process_order_book_deltas(deltas);
550                }
551            })
552        };
553
554        // Quote tick handler
555        let quote_handler = {
556            let inner = inner_weak.clone();
557            TypedHandler::from(move |quote: &QuoteTick| {
558                if quote.instrument_id.venue == venue
559                    && let Some(inner_rc) = inner.upgrade()
560                {
561                    inner_rc.borrow_mut().process_quote_tick(quote);
562                }
563            })
564        };
565
566        // Trade tick handler
567        let trade_handler = {
568            let inner = inner_weak.clone();
569            TypedHandler::from(move |trade: &TradeTick| {
570                if trade.instrument_id.venue == venue
571                    && let Some(inner_rc) = inner.upgrade()
572                {
573                    inner_rc.borrow_mut().process_trade_tick(trade);
574                }
575            })
576        };
577
578        // Bar handler (topic is data.bars.{bar_type}, filter by venue in handler)
579        let bar_handler = {
580            let inner = inner_weak.clone();
581            TypedHandler::from(move |bar: &Bar| {
582                if bar.bar_type.instrument_id().venue == venue
583                    && let Some(inner_rc) = inner.upgrade()
584                {
585                    inner_rc.borrow_mut().process_bar(bar);
586                }
587            })
588        };
589
590        let status_handler = {
591            let inner = inner_weak.clone();
592            ShareableMessageHandler::from_typed(move |status: &InstrumentStatus| {
593                if status.instrument_id.venue == venue
594                    && let Some(inner_rc) = inner.upgrade()
595                {
596                    inner_rc.borrow_mut().process_instrument_status(status);
597                }
598            })
599        };
600
601        let close_handler = {
602            let inner = inner_weak.clone();
603            ShareableMessageHandler::from_typed(move |close: &InstrumentClose| {
604                if close.instrument_id.venue == venue
605                    && let Some(inner_rc) = inner.upgrade()
606                {
607                    inner_rc.borrow_mut().process_instrument_close(close);
608                }
609            })
610        };
611
612        let position_handler = {
613            TypedHandler::from(move |event: &PositionEvent| {
614                let PositionEvent::PositionClosed(position_closed) = event else {
615                    return;
616                };
617
618                if position_closed.instrument_id.venue == venue
619                    && position_closed.account_id == account_id
620                    && let Some(inner_rc) = inner_weak.upgrade()
621                {
622                    // ExecutionEngine updates the cached position state before publishing
623                    // PositionClosed, so this retry observes the post-settlement cache view.
624                    if let Ok(mut inner) = inner_rc.try_borrow_mut() {
625                        inner.sync_expired_cleanup(position_closed.instrument_id);
626                    } else {
627                        log::debug!(
628                            "Skipping immediate expired cleanup retry for {} due to active sandbox borrow",
629                            position_closed.instrument_id,
630                        );
631                    }
632                }
633            })
634        };
635
636        // Subscribe patterns
637        let deltas_pattern: MStr<Pattern> = format!("data.book.deltas.{venue}.*").into();
638        let quote_pattern: MStr<Pattern> = format!("data.quotes.{venue}.*").into();
639        let trade_pattern: MStr<Pattern> = format!("data.trades.{venue}.*").into();
640        let bar_pattern: MStr<Pattern> = "data.bars.*".into();
641        let status_pattern: MStr<Pattern> = format!("data.status.{venue}.*").into();
642        let close_pattern: MStr<Pattern> = format!("data.close.{venue}.*").into();
643        let position_pattern: MStr<Pattern> = "events.position.*".into();
644
645        msgbus::subscribe_book_deltas(deltas_pattern, deltas_handler.clone(), Some(10));
646        msgbus::subscribe_quotes(quote_pattern, quote_handler.clone(), Some(10));
647        msgbus::subscribe_trades(trade_pattern, trade_handler.clone(), Some(10));
648        msgbus::subscribe_bars(bar_pattern, bar_handler.clone(), Some(10));
649        msgbus::subscribe_any(status_pattern, status_handler.clone(), Some(10));
650        msgbus::subscribe_instrument_close(close_pattern, close_handler.clone(), Some(10));
651        msgbus::subscribe_position_events(position_pattern, position_handler.clone(), Some(10));
652
653        // Store handlers for later deregistration
654        *self.handlers.borrow_mut() = Some(RegisteredHandlers {
655            deltas_pattern,
656            deltas_handler,
657            quote_pattern,
658            quote_handler,
659            trade_pattern,
660            trade_handler,
661            bar_pattern,
662            bar_handler,
663            status_pattern,
664            status_handler,
665            close_pattern,
666            close_handler,
667            position_pattern,
668            position_handler,
669        });
670
671        log::debug!(
672            "Sandbox registered message handlers for venue={}",
673            self.config.venue
674        );
675    }
676
677    /// Deregisters message handlers to stop receiving market data.
678    fn deregister_message_handlers(&self) {
679        if let Some(handlers) = self.handlers.borrow_mut().take() {
680            msgbus::unsubscribe_book_deltas(handlers.deltas_pattern, &handlers.deltas_handler);
681            msgbus::unsubscribe_quotes(handlers.quote_pattern, &handlers.quote_handler);
682            msgbus::unsubscribe_trades(handlers.trade_pattern, &handlers.trade_handler);
683            msgbus::unsubscribe_bars(handlers.bar_pattern, &handlers.bar_handler);
684            msgbus::unsubscribe_any(handlers.status_pattern, &handlers.status_handler);
685            msgbus::unsubscribe_instrument_close(handlers.close_pattern, &handlers.close_handler);
686            msgbus::unsubscribe_position_events(
687                handlers.position_pattern,
688                &handlers.position_handler,
689            );
690
691            log::debug!(
692                "Sandbox deregistered message handlers for venue={}",
693                self.config.venue
694            );
695        }
696    }
697
698    fn expiry_sweep_timer_name(&self) -> String {
699        format!("{}-sandbox-expiry-sweep", self.core.borrow().client_id)
700    }
701
702    /// Registers the periodic sweep that retires expired matching engines with no open position.
703    fn register_expiry_sweep_timer(&self) {
704        let inner_weak = WeakCell::from(Rc::downgrade(&self.inner));
705        let callback: Rc<dyn Fn(TimeEvent)> = Rc::new(move |_event: TimeEvent| {
706            let Some(inner_rc) = inner_weak.upgrade() else {
707                return;
708            };
709
710            // The timer fires on the runner task, but a nested msgbus dispatch may already hold the
711            // borrow; skipping is safe because the next interval retries.
712            if let Ok(mut inner) = inner_rc.try_borrow_mut() {
713                inner.sweep_expired_engines();
714            } else {
715                log::debug!("Skipping sandbox expiry sweep due to active borrow");
716            }
717        });
718
719        let name = self.expiry_sweep_timer_name();
720
721        if let Err(e) = self.clock.borrow_mut().set_timer_ns(
722            &name,
723            EXPIRED_ENGINE_SWEEP_INTERVAL_NS,
724            None,
725            None,
726            Some(TimeEventCallback::from(callback)),
727            None,
728            None,
729        ) {
730            log::error!("Failed to register sandbox expiry sweep timer: {e}");
731        }
732    }
733
734    /// Cancels the periodic expired-engine sweep timer.
735    fn cancel_expiry_sweep_timer(&self) {
736        self.clock
737            .borrow_mut()
738            .cancel_timer(&self.expiry_sweep_timer_name());
739    }
740
741    /// Returns current account balances, preferring cache state over starting balances.
742    fn get_current_account_balances(&self) -> Vec<AccountBalance> {
743        let account_id = self.core.borrow().account_id;
744        let cache = self.cache.borrow();
745
746        // Use account from cache if available (updated by fill events)
747        if let Some(account) = cache.account(&account_id) {
748            return account.balances().into_values().collect();
749        }
750
751        // Fall back to starting balances
752        self.get_account_balances()
753    }
754
755    fn sync_cached_account_config(&self) -> anyhow::Result<()> {
756        let Some(mut account) = self.get_account() else {
757            return Ok(());
758        };
759
760        account.set_calculate_account_state(!self.config.frozen_account);
761
762        if let AccountAny::Margin(margin_account) = &mut account {
763            margin_account.set_default_leverage(self.config.default_leverage);
764            for (instrument_id, leverage) in &self.config.leverages {
765                margin_account.set_leverage(*instrument_id, *leverage);
766            }
767        }
768
769        self.cache.borrow_mut().update_account(&account)
770    }
771
772    /// Processes a quote tick through the matching engine.
773    ///
774    /// # Errors
775    ///
776    /// Returns an error if the instrument is not found in the cache.
777    pub fn process_quote_tick(&self, quote: &QuoteTick) -> anyhow::Result<()> {
778        let instrument_id = quote.instrument_id;
779        let instrument = self.cache.borrow().try_instrument(&instrument_id)?.clone();
780
781        if !check_quote_or_drop("quote tick", quote, &instrument) {
782            return Ok(());
783        }
784
785        let mut inner = self.inner.borrow_mut();
786        inner.ensure_matching_engine(&instrument);
787        if let Some(engine) = inner.matching_engines.get_mut(&instrument_id) {
788            engine.process_quote_tick(quote);
789        }
790        Ok(())
791    }
792
793    /// Processes a trade tick through the matching engine.
794    ///
795    /// # Errors
796    ///
797    /// Returns an error if the instrument is not found in the cache.
798    pub fn process_trade_tick(&self, trade: &TradeTick) -> anyhow::Result<()> {
799        if !self.config.trade_execution {
800            return Ok(());
801        }
802
803        let instrument_id = trade.instrument_id;
804        let instrument = self.cache.borrow().try_instrument(&instrument_id)?.clone();
805
806        if !check_trade_or_drop("trade tick", trade, &instrument) {
807            return Ok(());
808        }
809
810        let mut inner = self.inner.borrow_mut();
811        inner.ensure_matching_engine(&instrument);
812        if let Some(engine) = inner.matching_engines.get_mut(&instrument_id) {
813            engine.process_trade_tick(trade);
814        }
815        Ok(())
816    }
817
818    /// Processes a bar through the matching engine.
819    ///
820    /// # Errors
821    ///
822    /// Returns an error if the instrument is not found in the cache.
823    pub fn process_bar(&self, bar: &Bar) -> anyhow::Result<()> {
824        if !self.config.bar_execution {
825            return Ok(());
826        }
827
828        let instrument_id = bar.bar_type.instrument_id();
829        let instrument = self.cache.borrow().try_instrument(&instrument_id)?.clone();
830
831        if !check_bar_or_drop("bar", bar, &instrument) {
832            return Ok(());
833        }
834
835        let mut inner = self.inner.borrow_mut();
836        inner.ensure_matching_engine(&instrument);
837        if let Some(engine) = inner.matching_engines.get_mut(&instrument_id) {
838            engine.process_bar(bar);
839        }
840        Ok(())
841    }
842
843    /// Processes order book deltas through the matching engine.
844    ///
845    /// # Errors
846    ///
847    /// Returns an error if the instrument is not found in the cache.
848    pub fn process_order_book_deltas(&self, deltas: &OrderBookDeltas) -> anyhow::Result<()> {
849        let instrument_id = deltas.instrument_id;
850        let instrument = self.cache.borrow().try_instrument(&instrument_id)?.clone();
851
852        let mut inner = self.inner.borrow_mut();
853        inner.ensure_matching_engine(&instrument);
854        if let Some(engine) = inner.matching_engines.get_mut(&instrument_id) {
855            engine.process_order_book_deltas(deltas)?;
856        }
857        Ok(())
858    }
859
860    /// Resets the sandbox to its initial state.
861    pub fn reset(&self) {
862        let mut inner = self.inner.borrow_mut();
863        for engine in inner.matching_engines.values_mut() {
864            engine.reset();
865        }
866
867        inner.balances.clear();
868        for money in &self.config.starting_balances {
869            inner
870                .balances
871                .insert(money.currency.code.to_string(), *money);
872        }
873
874        log::info!(
875            "Sandbox execution client reset: venue={}",
876            self.config.venue
877        );
878    }
879
880    /// Generates account balance entries from current balances.
881    fn get_account_balances(&self) -> Vec<AccountBalance> {
882        self.inner
883            .borrow()
884            .balances
885            .values()
886            .map(|money| AccountBalance::new(*money, Money::zero(money.currency), *money))
887            .collect()
888    }
889
890    fn get_order(&self, client_order_id: &ClientOrderId) -> anyhow::Result<OrderAny> {
891        Ok(self.cache.borrow().try_order_owned(client_order_id)?)
892    }
893}
894
895#[async_trait(?Send)]
896impl ExecutionClient for SandboxExecutionClient {
897    fn is_connected(&self) -> bool {
898        self.core.borrow().is_connected()
899    }
900
901    fn client_id(&self) -> ClientId {
902        self.core.borrow().client_id
903    }
904
905    fn account_id(&self) -> AccountId {
906        self.core.borrow().account_id
907    }
908
909    fn venue(&self) -> Venue {
910        self.core.borrow().venue
911    }
912
913    fn oms_type(&self) -> OmsType {
914        self.config.oms_type
915    }
916
917    fn on_instrument(&mut self, instrument: InstrumentAny) {
918        let instrument_id = instrument.id();
919        let mut inner = self.inner.borrow_mut();
920        if let Some(engine) = inner.matching_engines.get_mut(&instrument_id)
921            && let Err(e) = engine.update_instrument(instrument)
922        {
923            log::error!("Failed to update instrument {instrument_id} in sandbox engine: {e}");
924        }
925    }
926
927    fn get_account(&self) -> Option<AccountAny> {
928        let account_id = self.core.borrow().account_id;
929        self.cache.borrow().account_owned(&account_id)
930    }
931
932    fn generate_account_state(
933        &self,
934        balances: Vec<AccountBalance>,
935        margins: Vec<MarginBalance>,
936        reported: bool,
937        ts_event: UnixNanos,
938        info: Option<Params>,
939    ) -> anyhow::Result<()> {
940        let ts_init = self.clock.borrow().timestamp_ns();
941        let state = self
942            .factory
943            .generate_account_state(balances, margins, reported, ts_event, ts_init, info);
944        let endpoint = MessagingSwitchboard::portfolio_update_account();
945        msgbus::send_account_state(endpoint, &state);
946        self.sync_cached_account_config()?;
947        Ok(())
948    }
949
950    fn start(&mut self) -> anyhow::Result<()> {
951        if self.core.borrow().is_started() {
952            return Ok(());
953        }
954
955        if let Some(sender) = try_get_exec_event_sender() {
956            let handler: Rc<dyn Fn(OrderEventAny)> = Rc::new(move |event: OrderEventAny| {
957                if let Err(e) = sender.send(ExecutionEvent::Order(event)) {
958                    log::warn!("Failed to send order event: {e}");
959                }
960            });
961            let mut inner = self.inner.borrow_mut();
962            inner.event_handler = Some(handler.clone());
963            for engine in inner.matching_engines.values_mut() {
964                engine.set_event_handler(handler.clone());
965            }
966        }
967
968        // Register message handlers to receive market data
969        self.register_message_handlers();
970        self.register_expiry_sweep_timer();
971
972        self.core.borrow().set_started();
973        let core = self.core.borrow();
974        log::info!(
975            "Sandbox execution client started: venue={}, account_id={}, oms_type={:?}, account_type={:?}",
976            self.config.venue,
977            core.account_id,
978            self.config.oms_type,
979            self.config.account_type,
980        );
981        Ok(())
982    }
983
984    fn stop(&mut self) -> anyhow::Result<()> {
985        if self.core.borrow().is_stopped() {
986            return Ok(());
987        }
988
989        // Deregister message handlers to stop receiving data
990        self.deregister_message_handlers();
991        self.cancel_expiry_sweep_timer();
992
993        self.core.borrow().set_stopped();
994        self.core.borrow().set_disconnected();
995        log::info!(
996            "Sandbox execution client stopped: venue={}",
997            self.config.venue
998        );
999        Ok(())
1000    }
1001
1002    async fn connect(&mut self) -> anyhow::Result<()> {
1003        if self.core.borrow().is_connected() {
1004            return Ok(());
1005        }
1006
1007        let balances = self.get_account_balances();
1008        let ts_event = self.clock.borrow().timestamp_ns();
1009        self.generate_account_state(balances, vec![], false, ts_event, None)?;
1010
1011        self.core.borrow().set_connected();
1012        log::info!(
1013            "Sandbox execution client connected: venue={}",
1014            self.config.venue
1015        );
1016        Ok(())
1017    }
1018
1019    async fn disconnect(&mut self) -> anyhow::Result<()> {
1020        if self.core.borrow().is_disconnected() {
1021            return Ok(());
1022        }
1023
1024        self.core.borrow().set_disconnected();
1025        log::info!(
1026            "Sandbox execution client disconnected: venue={}",
1027            self.config.venue
1028        );
1029        Ok(())
1030    }
1031
1032    fn submit_order(&self, cmd: SubmitOrder) -> anyhow::Result<()> {
1033        let mut order = self.get_order(&cmd.client_order_id)?;
1034
1035        if order.is_closed() {
1036            log::warn!("Cannot submit closed order {}", order.client_order_id());
1037            return Ok(());
1038        }
1039
1040        let ts_init = self.clock.borrow().timestamp_ns();
1041        let event = self.factory.generate_order_submitted(&order, ts_init);
1042        self.dispatch_order_event(event);
1043
1044        let instrument_id = order.instrument_id();
1045        let instrument = self.cache.borrow().try_instrument(&instrument_id)?.clone();
1046
1047        let mut inner = self.inner.borrow_mut();
1048        inner.ensure_matching_engine(&instrument);
1049
1050        // Update matching engine with latest market data from cache
1051        let cache = self.cache.borrow();
1052
1053        if let Some(engine) = inner.matching_engines.get_mut(&instrument_id) {
1054            if let Some(quote) = cache.quote(&instrument_id)
1055                && check_quote_or_drop("cached quote tick", quote, &instrument)
1056            {
1057                engine.process_quote_tick(quote);
1058            }
1059
1060            if self.config.trade_execution
1061                && let Some(trade) = cache.trade(&instrument_id)
1062                && check_trade_or_drop("cached trade tick", trade, &instrument)
1063            {
1064                engine.process_trade_tick(trade);
1065            }
1066        }
1067        drop(cache);
1068
1069        let account_id = self.core.borrow().account_id;
1070
1071        if let Some(engine) = inner.matching_engines.get_mut(&instrument_id) {
1072            engine.process_order(&mut order, account_id);
1073            inner.sync_expired_cleanup(instrument_id);
1074        }
1075
1076        Ok(())
1077    }
1078
1079    fn submit_order_list(&self, cmd: SubmitOrderList) -> anyhow::Result<()> {
1080        let ts_init = self.clock.borrow().timestamp_ns();
1081        let mut cleanup_instrument_ids = Vec::new();
1082
1083        let orders: Vec<OrderAny> = self
1084            .cache
1085            .borrow()
1086            .orders_for_ids(&cmd.order_list.client_order_ids, &cmd);
1087
1088        for order in &orders {
1089            if order.is_closed() {
1090                log::warn!("Cannot submit closed order {}", order.client_order_id());
1091                continue;
1092            }
1093
1094            let event = self.factory.generate_order_submitted(order, ts_init);
1095            self.dispatch_order_event(event);
1096        }
1097
1098        let account_id = self.core.borrow().account_id;
1099
1100        for order in &orders {
1101            if order.is_closed() {
1102                continue;
1103            }
1104
1105            let instrument_id = order.instrument_id();
1106            if !cleanup_instrument_ids.contains(&instrument_id) {
1107                cleanup_instrument_ids.push(instrument_id);
1108            }
1109            let instrument = self.cache.borrow().instrument(&instrument_id).cloned();
1110
1111            if let Some(instrument) = instrument {
1112                let mut inner = self.inner.borrow_mut();
1113                inner.ensure_matching_engine(&instrument);
1114
1115                // Update with latest market data
1116                let cache = self.cache.borrow();
1117
1118                if let Some(engine) = inner.matching_engines.get_mut(&instrument_id) {
1119                    if let Some(quote) = cache.quote(&instrument_id)
1120                        && check_quote_or_drop("cached quote tick", quote, &instrument)
1121                    {
1122                        engine.process_quote_tick(quote);
1123                    }
1124
1125                    if self.config.trade_execution
1126                        && let Some(trade) = cache.trade(&instrument_id)
1127                        && check_trade_or_drop("cached trade tick", trade, &instrument)
1128                    {
1129                        engine.process_trade_tick(trade);
1130                    }
1131                }
1132                drop(cache);
1133
1134                if let Some(engine) = inner.matching_engines.get_mut(&instrument_id) {
1135                    let mut order_clone = order.clone();
1136                    engine.process_order(&mut order_clone, account_id);
1137                }
1138            }
1139        }
1140
1141        if !cleanup_instrument_ids.is_empty() {
1142            self.inner
1143                .borrow_mut()
1144                .sync_expired_cleanup_many(&cleanup_instrument_ids);
1145        }
1146
1147        Ok(())
1148    }
1149
1150    fn modify_order(&self, cmd: ModifyOrder) -> anyhow::Result<()> {
1151        let instrument_id = cmd.instrument_id;
1152        let account_id = self.core.borrow().account_id;
1153
1154        let mut inner = self.inner.borrow_mut();
1155        if let Some(engine) = inner.matching_engines.get_mut(&instrument_id) {
1156            engine.process_modify(&cmd, account_id);
1157        }
1158        Ok(())
1159    }
1160
1161    fn batch_modify_orders(&self, cmd: BatchModifyOrders) -> anyhow::Result<()> {
1162        let instrument_id = cmd.instrument_id;
1163        let account_id = self.core.borrow().account_id;
1164
1165        let mut inner = self.inner.borrow_mut();
1166        if let Some(engine) = inner.matching_engines.get_mut(&instrument_id) {
1167            engine.process_batch_modify(&cmd, account_id);
1168        }
1169        Ok(())
1170    }
1171
1172    fn cancel_order(&self, cmd: CancelOrder) -> anyhow::Result<()> {
1173        let instrument_id = cmd.instrument_id;
1174        let account_id = self.core.borrow().account_id;
1175
1176        let mut inner = self.inner.borrow_mut();
1177        if let Some(engine) = inner.matching_engines.get_mut(&instrument_id) {
1178            engine.process_cancel(&cmd, account_id);
1179        }
1180        Ok(())
1181    }
1182
1183    fn cancel_all_orders(&self, cmd: CancelAllOrders) -> anyhow::Result<()> {
1184        let instrument_id = cmd.instrument_id;
1185        let account_id = self.core.borrow().account_id;
1186
1187        let mut inner = self.inner.borrow_mut();
1188        if let Some(engine) = inner.matching_engines.get_mut(&instrument_id) {
1189            engine.process_cancel_all(&cmd, account_id);
1190        }
1191        Ok(())
1192    }
1193
1194    fn batch_cancel_orders(&self, cmd: BatchCancelOrders) -> anyhow::Result<()> {
1195        let instrument_id = cmd.instrument_id;
1196        let account_id = self.core.borrow().account_id;
1197
1198        let mut inner = self.inner.borrow_mut();
1199        if let Some(engine) = inner.matching_engines.get_mut(&instrument_id) {
1200            engine.process_batch_cancel(&cmd, account_id);
1201        }
1202        Ok(())
1203    }
1204
1205    fn query_account(&self, _cmd: QueryAccount) -> anyhow::Result<()> {
1206        let balances = self.get_current_account_balances();
1207        let ts_event = self.clock.borrow().timestamp_ns();
1208        self.generate_account_state(balances, vec![], false, ts_event, None)?;
1209        Ok(())
1210    }
1211
1212    fn query_order(&self, _cmd: QueryOrder) -> anyhow::Result<()> {
1213        // Orders are tracked in the cache, no external query needed for sandbox
1214        Ok(())
1215    }
1216
1217    async fn generate_order_status_report(
1218        &self,
1219        _cmd: &GenerateOrderStatusReport,
1220    ) -> anyhow::Result<Option<OrderStatusReport>> {
1221        // Sandbox orders are tracked internally
1222        Ok(None)
1223    }
1224
1225    async fn generate_order_status_reports(
1226        &self,
1227        _cmd: &GenerateOrderStatusReports,
1228    ) -> anyhow::Result<Vec<OrderStatusReport>> {
1229        // Sandbox orders are tracked internally
1230        Ok(Vec::new())
1231    }
1232
1233    async fn generate_fill_reports(
1234        &self,
1235        _cmd: GenerateFillReports,
1236    ) -> anyhow::Result<Vec<FillReport>> {
1237        // Sandbox fills are tracked internally
1238        Ok(Vec::new())
1239    }
1240
1241    async fn generate_position_status_reports(
1242        &self,
1243        _cmd: &GeneratePositionStatusReports,
1244    ) -> anyhow::Result<Vec<PositionStatusReport>> {
1245        // Sandbox positions are tracked internally
1246        Ok(Vec::new())
1247    }
1248
1249    async fn generate_mass_status(
1250        &self,
1251        _lookback_mins: Option<u64>,
1252    ) -> anyhow::Result<Option<ExecutionMassStatus>> {
1253        let core = self.core.borrow();
1254        let ts_init = self.clock.borrow().timestamp_ns();
1255        Ok(Some(ExecutionMassStatus::new(
1256            core.client_id,
1257            core.account_id,
1258            core.venue,
1259            ts_init,
1260            None,
1261        )))
1262    }
1263}