nautilus_backtest/
engine.rs

1// -------------------------------------------------------------------------------------------------
2//  Copyright (C) 2015-2025 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// Under development
17#![allow(dead_code)]
18#![allow(unused_variables)]
19
20//! The core `BacktestEngine` for backtesting on historical data.
21
22use std::{
23    any::Any,
24    cell::RefCell,
25    collections::{HashMap, HashSet, VecDeque},
26    fmt::Debug,
27    rc::Rc,
28};
29
30use nautilus_common::timer::TimeEventHandlerV2;
31use nautilus_core::{UUID4, UnixNanos};
32use nautilus_data::client::DataClientAdapter;
33use nautilus_execution::models::{fee::FeeModelAny, fill::FillModel, latency::LatencyModel};
34use nautilus_model::{
35    data::Data,
36    enums::{AccountType, BookType, OmsType},
37    identifiers::{AccountId, ClientId, InstrumentId, Venue},
38    instruments::{Instrument, InstrumentAny},
39    types::{Currency, Money},
40};
41use nautilus_system::{config::NautilusKernelConfig, kernel::NautilusKernel};
42use rust_decimal::Decimal;
43
44use crate::{
45    accumulator::TimeEventAccumulator, config::BacktestEngineConfig,
46    data_client::BacktestDataClient, exchange::SimulatedExchange,
47    execution_client::BacktestExecutionClient, modules::SimulationModule,
48};
49
50/// Core backtesting engine for running event-driven strategy backtests on historical data.
51///
52/// The `BacktestEngine` provides a high-fidelity simulation environment that processes
53/// historical market data chronologically through an event-driven architecture. It maintains
54/// simulated exchanges with realistic order matching and execution, allowing strategies
55/// to be tested exactly as they would run in live trading:
56///
57/// - Event-driven data replay with configurable latency models.
58/// - Multi-venue and multi-asset support.
59/// - Realistic order matching and execution simulation.
60/// - Strategy and portfolio performance analysis.
61/// - Seamless transition from backtesting to live trading.
62pub struct BacktestEngine {
63    instance_id: UUID4,
64    config: BacktestEngineConfig,
65    kernel: NautilusKernel,
66    accumulator: TimeEventAccumulator,
67    run_config_id: Option<UUID4>,
68    run_id: Option<UUID4>,
69    venues: HashMap<Venue, Rc<RefCell<SimulatedExchange>>>,
70    has_data: HashSet<InstrumentId>,
71    has_book_data: HashSet<InstrumentId>,
72    data: VecDeque<Data>,
73    index: usize,
74    iteration: usize,
75    run_started: Option<UnixNanos>,
76    run_finished: Option<UnixNanos>,
77    backtest_start: Option<UnixNanos>,
78    backtest_end: Option<UnixNanos>,
79}
80
81impl Debug for BacktestEngine {
82    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
83        f.debug_struct(stringify!(BacktestEngine))
84            .field("instance_id", &self.instance_id)
85            .field("run_config_id", &self.run_config_id)
86            .field("run_id", &self.run_id)
87            .finish()
88    }
89}
90
91impl BacktestEngine {
92    /// Create a new [`BacktestEngine`] instance.
93    ///
94    /// # Errors
95    ///
96    /// Returns an error if the core `NautilusKernel` fails to initialize.
97    pub fn new(config: BacktestEngineConfig) -> anyhow::Result<Self> {
98        let kernel = NautilusKernel::new("BacktestEngine".to_string(), config.clone())?;
99
100        Ok(Self {
101            instance_id: kernel.instance_id,
102            config,
103            accumulator: TimeEventAccumulator::new(),
104            kernel,
105            run_config_id: None,
106            run_id: None,
107            venues: HashMap::new(),
108            has_data: HashSet::new(),
109            has_book_data: HashSet::new(),
110            data: VecDeque::new(),
111            index: 0,
112            iteration: 0,
113            run_started: None,
114            run_finished: None,
115            backtest_start: None,
116            backtest_end: None,
117        })
118    }
119
120    /// # Errors
121    ///
122    /// Returns an error if initializing the simulated exchange for the venue fails.
123    #[allow(clippy::too_many_arguments)]
124    pub fn add_venue(
125        &mut self,
126        venue: Venue,
127        oms_type: OmsType,
128        account_type: AccountType,
129        book_type: BookType,
130        starting_balances: Vec<Money>,
131        base_currency: Option<Currency>,
132        default_leverage: Option<Decimal>,
133        leverages: HashMap<InstrumentId, Decimal>,
134        modules: Vec<Box<dyn SimulationModule>>,
135        fill_model: FillModel,
136        fee_model: FeeModelAny,
137        latency_model: Option<LatencyModel>,
138        routing: Option<bool>,
139        reject_stop_orders: Option<bool>,
140        support_gtd_orders: Option<bool>,
141        support_contingent_orders: Option<bool>,
142        use_position_ids: Option<bool>,
143        use_random_ids: Option<bool>,
144        use_reduce_only: Option<bool>,
145        use_message_queue: Option<bool>,
146        bar_execution: Option<bool>,
147        bar_adaptive_high_low_ordering: Option<bool>,
148        trade_execution: Option<bool>,
149        allow_cash_borrowing: Option<bool>,
150        frozen_account: Option<bool>,
151    ) -> anyhow::Result<()> {
152        let default_leverage: Decimal = default_leverage.unwrap_or_else(|| {
153            if account_type == AccountType::Margin {
154                Decimal::from(10)
155            } else {
156                Decimal::from(0)
157            }
158        });
159
160        let exchange = SimulatedExchange::new(
161            venue,
162            oms_type,
163            account_type,
164            starting_balances,
165            base_currency,
166            default_leverage,
167            leverages,
168            modules,
169            self.kernel.cache.clone(),
170            self.kernel.clock.clone(),
171            fill_model,
172            fee_model,
173            book_type,
174            latency_model,
175            bar_execution,
176            reject_stop_orders,
177            support_gtd_orders,
178            support_contingent_orders,
179            use_position_ids,
180            use_random_ids,
181            use_reduce_only,
182            use_message_queue,
183            allow_cash_borrowing,
184            frozen_account,
185        )?;
186        let exchange = Rc::new(RefCell::new(exchange));
187        self.venues.insert(venue, exchange.clone());
188
189        let account_id = AccountId::from(format!("{venue}-001").as_str());
190        let exec_client = BacktestExecutionClient::new(
191            self.config.trader_id(),
192            account_id,
193            exchange.clone(),
194            self.kernel.cache.clone(),
195            self.kernel.clock.clone(),
196            routing,
197            frozen_account,
198        );
199        let exec_client = Rc::new(exec_client);
200
201        exchange.borrow_mut().register_client(exec_client.clone());
202        self.kernel.exec_engine.register_client(exec_client)?;
203
204        log::info!("Adding exchange {venue} to engine");
205
206        Ok(())
207    }
208
209    pub fn change_fill_model(&mut self, venue: Venue, fill_model: FillModel) {
210        if let Some(exchange) = self.venues.get_mut(&venue) {
211            exchange.borrow_mut().set_fill_model(fill_model);
212        } else {
213            log::warn!(
214                "BacktestEngine::change_fill_model called for unknown venue {venue}. Ignoring."
215            );
216        }
217    }
218
219    /// Adds an instrument to the backtest engine for the specified venue.
220    ///
221    /// # Errors
222    ///
223    /// Returns an error if:
224    /// - The instrument's associated venue has not been added via `add_venue`.
225    /// - Attempting to add a `CurrencyPair` instrument for a single-currency CASH account.
226    ///
227    /// # Panics
228    ///
229    /// Panics if adding the instrument to the simulated exchange fails.
230    pub fn add_instrument(&mut self, instrument: InstrumentAny) -> anyhow::Result<()> {
231        let instrument_id = instrument.id();
232        if let Some(exchange) = self.venues.get_mut(&instrument.id().venue) {
233            // check if instrument is of variant CurrencyPair
234            if matches!(instrument, InstrumentAny::CurrencyPair(_))
235                && exchange.borrow().account_type != AccountType::Margin
236                && exchange.borrow().base_currency.is_some()
237            {
238                anyhow::bail!(
239                    "Cannot add a `CurrencyPair` instrument {} for a venue with a single-currency CASH account",
240                    instrument_id
241                )
242            }
243            exchange
244                .borrow_mut()
245                .add_instrument(instrument.clone())
246                .unwrap();
247        } else {
248            anyhow::bail!(
249                "Cannot add an `Instrument` object without first adding its associated venue {}",
250                instrument.id().venue
251            )
252        }
253
254        // Check client has been registered
255        self.add_market_data_client_if_not_exists(instrument.id().venue);
256
257        self.kernel
258            .data_engine
259            .borrow_mut()
260            .process(&instrument as &dyn Any);
261        log::info!(
262            "Added instrument {} to exchange {}",
263            instrument_id,
264            instrument_id.venue
265        );
266        Ok(())
267    }
268
269    pub fn add_data(
270        &mut self,
271        data: Vec<Data>,
272        client_id: Option<ClientId>,
273        validate: bool,
274        sort: bool,
275    ) {
276        if data.is_empty() {
277            log::warn!("add_data called with empty data slice – ignoring");
278            return;
279        }
280
281        // If requested, sort by ts_init so internal stream is monotonic.
282        let mut to_add = data;
283        if sort {
284            to_add.sort_by_key(nautilus_model::data::HasTsInit::ts_init);
285        }
286
287        // Instrument & book tracking using Data helpers
288        if validate {
289            for item in &to_add {
290                let instr_id = item.instrument_id();
291                self.has_data.insert(instr_id);
292
293                if item.is_order_book_data() {
294                    self.has_book_data.insert(instr_id);
295                }
296
297                // Ensure appropriate market data client exists
298                self.add_market_data_client_if_not_exists(instr_id.venue);
299            }
300        }
301
302        // Extend master data vector and ensure internal iterator (index) remains valid.
303        for item in to_add {
304            self.data.push_back(item);
305        }
306
307        if sort {
308            // VecDeque cannot be sorted directly; convert to Vec for sorting, then back.
309            let mut vec: Vec<Data> = self.data.drain(..).collect();
310            vec.sort_by_key(nautilus_model::data::HasTsInit::ts_init);
311            self.data = vec.into();
312        }
313
314        log::info!(
315            "Added {} data element{} to BacktestEngine",
316            self.data.len(),
317            if self.data.len() == 1 { "" } else { "s" }
318        );
319    }
320
321    pub fn add_actor(&mut self) {
322        todo!("implement add_actor")
323    }
324
325    pub fn add_actors(&mut self) {
326        todo!("implement add_actors")
327    }
328
329    pub fn add_strategy(&mut self) {
330        todo!("implement add_strategy")
331    }
332
333    pub fn add_strategies(&mut self) {
334        todo!("implement add_strategies")
335    }
336
337    pub fn add_exec_algorithm(&mut self) {
338        todo!("implement add_exec_algorithm")
339    }
340
341    pub fn add_exec_algorithms(&mut self) {
342        todo!("implement add_exec_algorithms")
343    }
344
345    pub fn reset(&mut self) {
346        todo!("implement reset")
347    }
348
349    pub fn clear_data(&mut self) {
350        todo!("implement clear_data")
351    }
352
353    pub fn clear_strategies(&mut self) {
354        todo!("implement clear_strategies")
355    }
356
357    pub fn clear_exec_algorithms(&mut self) {
358        todo!("implement clear_exec_algorithms")
359    }
360
361    pub fn dispose(&mut self) {
362        todo!("implement dispose")
363    }
364
365    pub fn run(&mut self) {
366        todo!("implement run")
367    }
368
369    pub fn end(&mut self) {
370        todo!("implement end")
371    }
372
373    pub fn get_result(&self) {
374        // TODO: implement full BacktestResult aggregation once portfolio analysis
375        // components are available in Rust. For now we simply log and return.
376        log::info!("BacktestEngine::get_result called – not yet implemented");
377    }
378
379    pub fn next(&mut self) {
380        self.data.pop_front();
381    }
382
383    pub fn advance_time(&mut self, _ts_now: UnixNanos) -> Vec<TimeEventHandlerV2> {
384        // TODO: integrate TestClock advancement when kernel clocks are exposed.
385        self.accumulator.drain()
386    }
387
388    pub fn process_raw_time_event_handlers(
389        &mut self,
390        handlers: Vec<TimeEventHandlerV2>,
391        ts_now: UnixNanos,
392        only_now: bool,
393        as_of_now: bool,
394    ) {
395        let mut last_ts_init: Option<UnixNanos> = None;
396
397        for handler in handlers {
398            let ts_event_init = handler.event.ts_event; // event time
399
400            if Self::should_skip_time_event(ts_event_init, ts_now, only_now, as_of_now) {
401                continue;
402            }
403
404            if last_ts_init != Some(ts_event_init) {
405                // First handler for this timestamp – process exchange queues beforehand.
406                for exchange in self.venues.values() {
407                    exchange.borrow_mut().process(ts_event_init);
408                }
409                last_ts_init = Some(ts_event_init);
410            }
411
412            handler.run();
413        }
414    }
415
416    pub fn log_pre_run(&self) {
417        todo!("implement log_pre_run_diagnostics")
418    }
419
420    pub fn log_run(&self) {
421        todo!("implement log_run")
422    }
423
424    pub fn log_post_run(&self) {
425        todo!("implement log_post_run")
426    }
427
428    pub fn add_data_client_if_not_exists(&mut self, client_id: ClientId) {
429        if self
430            .kernel
431            .data_engine
432            .borrow()
433            .registered_clients()
434            .contains(&client_id)
435        {
436            return;
437        }
438
439        // Create a generic, venue-agnostic backtest data client. We use a dummy
440        // venue derived from the client id for uniqueness.
441        let venue = Venue::from(client_id.as_str());
442        let backtest_client = BacktestDataClient::new(client_id, venue, self.kernel.cache.clone());
443        let data_client_adapter = DataClientAdapter::new(
444            backtest_client.client_id,
445            None, // no specific venue association
446            false,
447            false,
448            Box::new(backtest_client),
449        );
450
451        self.kernel
452            .data_engine
453            .borrow_mut()
454            .register_client(data_client_adapter, None);
455    }
456
457    // Helper matching Cython semantics for determining whether to skip
458    // processing a time event.
459    fn should_skip_time_event(
460        ts_event_init: UnixNanos,
461        ts_now: UnixNanos,
462        only_now: bool,
463        as_of_now: bool,
464    ) -> bool {
465        if only_now {
466            ts_event_init != ts_now
467        } else if as_of_now {
468            ts_event_init > ts_now
469        } else {
470            ts_event_init >= ts_now
471        }
472    }
473
474    // TODO: We might want venue to be optional for multi-venue clients
475    pub fn add_market_data_client_if_not_exists(&mut self, venue: Venue) {
476        let client_id = ClientId::from(venue.as_str());
477        if !self
478            .kernel
479            .data_engine
480            .borrow()
481            .registered_clients()
482            .contains(&client_id)
483        {
484            let backtest_client =
485                BacktestDataClient::new(client_id, venue, self.kernel.cache.clone());
486            let data_client_adapter = DataClientAdapter::new(
487                client_id,
488                Some(venue), // TBD
489                false,
490                false,
491                Box::new(backtest_client),
492            );
493            self.kernel
494                .data_engine
495                .borrow_mut()
496                .register_client(data_client_adapter, None);
497        }
498    }
499}
500
501#[cfg(test)]
502mod tests {
503    use std::collections::HashMap;
504
505    use nautilus_execution::models::{fee::FeeModelAny, fill::FillModel};
506    use nautilus_model::{
507        enums::{AccountType, BookType, OmsType},
508        identifiers::{ClientId, Venue},
509        instruments::{
510            CryptoPerpetual, Instrument, InstrumentAny, stubs::crypto_perpetual_ethusdt,
511        },
512        types::Money,
513    };
514    use rstest::rstest;
515
516    use crate::{config::BacktestEngineConfig, engine::BacktestEngine};
517
518    #[allow(clippy::missing_panics_doc)] // OK for testing
519    fn get_backtest_engine(config: Option<BacktestEngineConfig>) -> BacktestEngine {
520        let config = config.unwrap_or_default();
521        let mut engine = BacktestEngine::new(config).unwrap();
522        engine
523            .add_venue(
524                Venue::from("BINANCE"),
525                OmsType::Netting,
526                AccountType::Margin,
527                BookType::L2_MBP,
528                vec![Money::from("1_000_000 USD")],
529                None,
530                None,
531                HashMap::new(),
532                vec![],
533                FillModel::default(),
534                FeeModelAny::default(),
535                None,
536                None,
537                None,
538                None,
539                None,
540                None,
541                None,
542                None,
543                None,
544                None,
545                None,
546                None,
547                None,
548                None,
549            )
550            .unwrap();
551        engine
552    }
553
554    #[rstest]
555    fn test_engine_venue_and_instrument_initialization(crypto_perpetual_ethusdt: CryptoPerpetual) {
556        let venue = Venue::from("BINANCE");
557        let client_id = ClientId::from(venue.as_str());
558        let instrument = InstrumentAny::CryptoPerpetual(crypto_perpetual_ethusdt);
559        let instrument_id = instrument.id();
560        let mut engine = get_backtest_engine(None);
561        engine.add_instrument(instrument).unwrap();
562
563        // Check the venue and exec client has been added
564        assert_eq!(engine.venues.len(), 1);
565        assert!(engine.venues.contains_key(&venue));
566        assert!(engine.kernel.exec_engine.get_client(&client_id).is_some());
567
568        // Check the instrument has been added
569        assert!(
570            engine
571                .venues
572                .get(&venue)
573                .is_some_and(|venue| venue.borrow().get_matching_engine(&instrument_id).is_some())
574        );
575        assert_eq!(
576            engine
577                .kernel
578                .data_engine
579                .borrow()
580                .registered_clients()
581                .len(),
582            1
583        );
584        assert!(
585            engine
586                .kernel
587                .data_engine
588                .borrow()
589                .registered_clients()
590                .contains(&client_id)
591        );
592    }
593}