1use std::iter::Peekable;
19
20use ahash::{AHashMap, AHashSet};
21use nautilus_core::UnixNanos;
22use nautilus_model::{
23 data::{
24 Bar, Data, FundingRateUpdate, HasTsInit, IndexPriceUpdate, InstrumentClose,
25 InstrumentStatus, MarkPriceUpdate, OptionGreeks, OrderBookDelta, OrderBookDepth10,
26 QuoteTick, TradeTick,
27 },
28 enums::{BookType, OtoTriggerMode},
29 identifiers::{InstrumentId, Venue},
30 types::Money,
31};
32use nautilus_persistence::backend::{catalog::ParquetDataCatalog, session::QueryResult};
33
34use crate::{
35 config::{BacktestDataConfig, BacktestRunConfig, NautilusDataType, SimulatedVenueConfig},
36 engine::BacktestEngine,
37 result::BacktestResult,
38};
39
40#[derive(Debug)]
45#[cfg_attr(
46 feature = "python",
47 pyo3::pyclass(module = "nautilus_trader.backtest", unsendable)
48)]
49#[cfg_attr(
50 feature = "python",
51 pyo3_stub_gen::derive::gen_stub_pyclass(module = "nautilus_trader.backtest")
52)]
53pub struct BacktestNode {
54 configs: Vec<BacktestRunConfig>,
55 engines: AHashMap<String, BacktestEngine>,
56}
57
58impl BacktestNode {
59 pub fn new(configs: Vec<BacktestRunConfig>) -> anyhow::Result<Self> {
70 anyhow::ensure!(!configs.is_empty(), "At least one run config is required");
71 validate_configs(&configs)?;
72 Ok(Self {
73 configs,
74 engines: AHashMap::new(),
75 })
76 }
77
78 #[must_use]
80 pub fn configs(&self) -> &[BacktestRunConfig] {
81 &self.configs
82 }
83
84 pub fn build(&mut self) -> anyhow::Result<()> {
96 for config in &self.configs {
97 if self.engines.contains_key(config.id()) {
98 continue;
99 }
100
101 match build_engine(config) {
102 Ok(engine) => {
103 self.engines.insert(config.id().to_string(), engine);
104 }
105 Err(e) if config.raise_exception() => return Err(e),
106 Err(e) => {
107 log::error!("Error building backtest '{}': {e:#}", config.id());
108 }
109 }
110 }
111
112 Ok(())
113 }
114
115 #[must_use]
117 pub fn get_engine_mut(&mut self, id: &str) -> Option<&mut BacktestEngine> {
118 self.engines.get_mut(id)
119 }
120
121 #[must_use]
123 pub fn get_engine(&self, id: &str) -> Option<&BacktestEngine> {
124 self.engines.get(id)
125 }
126
127 #[must_use]
129 pub fn get_engines(&self) -> Vec<&BacktestEngine> {
130 self.engines.values().collect()
131 }
132
133 pub fn run(&mut self) -> anyhow::Result<Vec<BacktestResult>> {
147 if self.engines.is_empty() {
149 self.build()?;
150 }
151
152 let mut results = Vec::new();
153
154 for config in &self.configs {
155 let Some(engine) = self.engines.get_mut(config.id()) else {
156 continue;
157 };
158
159 let run_result = match config.chunk_size() {
160 None => run_oneshot(engine, config),
161 Some(chunk_size) => run_streaming(engine, config, chunk_size),
162 };
163
164 if let Err(e) = run_result {
165 if config.raise_exception() {
166 return Err(e);
167 }
168
169 log::error!("Error running backtest '{}': {e:#}", config.id());
170 engine.clear_data();
171 continue;
172 }
173
174 results.push(engine.get_result());
175
176 if config.dispose_on_completion() {
177 engine.dispose();
178 } else {
179 engine.clear_data();
180 }
181 }
182
183 Ok(results)
184 }
185
186 pub fn load_catalog(config: &BacktestDataConfig) -> anyhow::Result<ParquetDataCatalog> {
192 create_catalog(config)
193 }
194
195 pub fn load_data_config(
201 config: &BacktestDataConfig,
202 start: Option<UnixNanos>,
203 end: Option<UnixNanos>,
204 ) -> anyhow::Result<Vec<Data>> {
205 load_data(config, start, end)
206 }
207
208 pub fn dispose(&mut self) {
210 for engine in self.engines.values_mut() {
211 engine.dispose();
212 }
213 self.engines.clear();
214 }
215}
216
217fn build_engine(config: &BacktestRunConfig) -> anyhow::Result<BacktestEngine> {
218 let engine_config = config.engine().clone();
219 let mut engine = BacktestEngine::new(engine_config)?;
220
221 for venue_config in config.venues() {
222 let starting_balances: Vec<Money> = venue_config
223 .starting_balances()
224 .iter()
225 .map(|s| s.parse::<Money>())
226 .collect::<Result<Vec<_>, _>>()
227 .map_err(|e| anyhow::anyhow!("Invalid starting balance: {e}"))?;
228
229 let default_leverage = venue_config.default_leverage();
230 let leverages = venue_config.leverages().cloned().unwrap_or_default();
231 let margin_model = venue_config.margin_model().cloned().map(Into::into);
232 let modules = venue_config
233 .modules()
234 .iter()
235 .cloned()
236 .map(Into::into)
237 .collect();
238 let fill_model = venue_config
239 .fill_model()
240 .cloned()
241 .unwrap_or_default()
242 .into();
243 let fee_model = venue_config.fee_model().cloned().unwrap_or_default().into();
244 let latency_model = venue_config.latency_model().cloned().map(Into::into);
245 let sim_config = SimulatedVenueConfig::builder()
246 .venue(Venue::from(venue_config.name().as_str()))
247 .oms_type(venue_config.oms_type())
248 .account_type(venue_config.account_type())
249 .book_type(venue_config.book_type())
250 .starting_balances(starting_balances)
251 .maybe_base_currency(venue_config.base_currency())
252 .maybe_default_leverage(default_leverage)
253 .leverages(leverages)
254 .maybe_margin_model(margin_model)
255 .modules(modules)
256 .fill_model(fill_model)
257 .fee_model(fee_model)
258 .maybe_latency_model(latency_model)
259 .routing(venue_config.routing())
260 .reject_stop_orders(venue_config.reject_stop_orders())
261 .support_gtd_orders(venue_config.support_gtd_orders())
262 .support_contingent_orders(venue_config.support_contingent_orders())
263 .use_position_ids(venue_config.use_position_ids())
264 .use_random_ids(venue_config.use_random_ids())
265 .use_reduce_only(venue_config.use_reduce_only())
266 .use_market_order_acks(venue_config.use_market_order_acks())
267 .bar_execution(venue_config.bar_execution())
268 .bar_adaptive_high_low_ordering(venue_config.bar_adaptive_high_low_ordering())
269 .trade_execution(venue_config.trade_execution())
270 .liquidity_consumption(venue_config.liquidity_consumption())
271 .allow_cash_borrowing(venue_config.allow_cash_borrowing())
272 .frozen_account(venue_config.frozen_account())
273 .queue_position(venue_config.queue_position())
274 .oto_full_trigger(venue_config.oto_trigger_mode() == OtoTriggerMode::Full)
275 .price_protection_points(venue_config.price_protection_points())
276 .liquidation_enabled(venue_config.liquidation_enabled())
277 .liquidation_trigger_ratio(venue_config.liquidation_trigger_ratio())
278 .liquidation_cancel_open_orders(venue_config.liquidation_cancel_open_orders())
279 .build()?;
280 engine.add_venue(sim_config)?;
281 }
282
283 for data_config in config.data() {
284 let catalog = create_catalog(data_config)?;
285 let instr_ids: Vec<InstrumentId> = data_config.get_instrument_ids()?;
286 let filter: Option<Vec<String>> = if instr_ids.is_empty() {
287 None
288 } else {
289 Some(instr_ids.iter().map(ToString::to_string).collect())
290 };
291
292 let instruments = catalog.query_instruments(filter.as_deref())?;
293
294 if !instr_ids.is_empty() && instruments.is_empty() {
295 let ids: Vec<String> = instr_ids.iter().map(ToString::to_string).collect();
296 anyhow::bail!(
297 "No instruments found in catalog for requested IDs: [{}]",
298 ids.join(", ")
299 );
300 }
301
302 for instrument in instruments {
303 engine.add_instrument(&instrument)?;
304 }
305 }
306
307 Ok(engine)
308}
309
310fn validate_configs(configs: &[BacktestRunConfig]) -> anyhow::Result<()> {
311 anyhow::ensure!(
314 configs.len() <= 1,
315 "Only one run config per BacktestNode is supported \
316 (kernel MessageBus is a thread-local singleton)"
317 );
318
319 let mut seen_ids = AHashSet::new();
320
321 for config in configs {
322 anyhow::ensure!(
323 seen_ids.insert(config.id()),
324 "Duplicate run config ID '{}'",
325 config.id()
326 );
327
328 let venue_names: Vec<String> = config
329 .venues()
330 .iter()
331 .map(|v| v.name().to_string())
332 .collect();
333
334 for data_config in config.data() {
335 if let (Some(start), Some(end)) = (data_config.start_time(), data_config.end_time()) {
336 anyhow::ensure!(
337 start <= end,
338 "Data config start_time ({start}) must be <= end_time ({end})"
339 );
340 }
341
342 for instrument_id in data_config.get_instrument_ids()? {
343 let venue = instrument_id.venue.to_string();
344 anyhow::ensure!(
345 venue_names.contains(&venue),
346 "No venue config found for venue '{venue}' (required by instrument {instrument_id})"
347 );
348 }
349 }
350
351 for venue_config in config.venues() {
352 let needs_book_data = matches!(
353 venue_config.book_type(),
354 BookType::L2_MBP | BookType::L3_MBO
355 );
356
357 if needs_book_data {
358 let venue_name = venue_config.name().to_string();
359 let has_book_data = config.data().iter().any(|dc| {
360 let is_book_type = matches!(
361 dc.data_type(),
362 NautilusDataType::OrderBookDelta | NautilusDataType::OrderBookDepth10
363 );
364
365 if !is_book_type {
366 return false;
367 }
368
369 let ids = dc.get_instrument_ids().unwrap_or_default();
371 ids.is_empty() || ids.iter().any(|id| id.venue.to_string() == venue_name)
372 });
373 anyhow::ensure!(
374 has_book_data,
375 "Venue '{venue_name}' has book_type {:?} but no order book data configured",
376 venue_config.book_type()
377 );
378 }
379 }
380 }
381 Ok(())
382}
383
384fn run_oneshot(engine: &mut BacktestEngine, config: &BacktestRunConfig) -> anyhow::Result<()> {
385 for data_config in config.data() {
386 let data = load_data(data_config, config.start(), config.end())?;
387 if data.is_empty() {
388 log::warn!("No data found for config: {:?}", data_config.data_type());
389 continue;
390 }
391 engine.add_data(data, data_config.client_id(), false, false)?;
392 }
393
394 engine.sort_data();
395 engine.run(
396 config.start(),
397 config.end(),
398 Some(config.id().to_string()),
399 false,
400 )
401}
402
403fn run_streaming(
404 engine: &mut BacktestEngine,
405 config: &BacktestRunConfig,
406 chunk_size: usize,
407) -> anyhow::Result<()> {
408 let data_configs = config.data();
409
410 if data_configs.len() == 1 {
411 let data_config = &data_configs[0];
414 let mut catalog = create_catalog(data_config)?;
415 let result = dispatch_query(&mut catalog, data_config, config.start(), config.end())?;
416 let data = result.map(|item| item.map_err(anyhow::Error::from));
417 stream_chunks(engine, config, data.peekable(), chunk_size)?;
418 } else {
419 let all_data = load_and_merge_data(config)?;
421 stream_chunks(
422 engine,
423 config,
424 all_data.into_iter().map(Ok).peekable(),
425 chunk_size,
426 )?;
427 }
428
429 Ok(())
430}
431
432fn stream_chunks<I: Iterator<Item = anyhow::Result<Data>>>(
436 engine: &mut BacktestEngine,
437 config: &BacktestRunConfig,
438 mut iter: Peekable<I>,
439 chunk_size: usize,
440) -> anyhow::Result<()> {
441 if iter.peek().is_none() {
442 return engine.end();
443 }
444
445 let mut next_start = config.start();
446
447 loop {
448 let chunk = take_aligned_chunk(&mut iter, chunk_size)?;
449 if chunk.is_empty() {
450 break;
451 }
452
453 let is_last = iter.peek().is_none();
454 let end = if is_last {
455 config.end()
456 } else {
457 chunk.last().map(HasTsInit::ts_init)
458 };
459
460 engine.add_data(chunk, None, false, true)?;
461 engine.run(next_start, end, Some(config.id().to_string()), true)?;
462 engine.clear_data();
463
464 if engine.kernel().is_shutdown_requested() {
467 return Ok(());
468 }
469
470 next_start = end;
473 }
474
475 engine.end()
476}
477
478fn take_aligned_chunk<I: Iterator<Item = anyhow::Result<Data>>>(
481 iter: &mut Peekable<I>,
482 chunk_size: usize,
483) -> anyhow::Result<Vec<Data>> {
484 let mut chunk = Vec::with_capacity(chunk_size);
485
486 for _ in 0..chunk_size {
487 match iter.next() {
488 Some(item) => chunk.push(item?),
489 None => return Ok(chunk),
490 }
491 }
492
493 if let Some(boundary_ts) = chunk.last().map(HasTsInit::ts_init) {
494 while let Some(item) = iter.next_if(|item| {
496 item.as_ref()
497 .is_ok_and(|data| data.ts_init() == boundary_ts)
498 }) {
499 chunk.push(item?);
500 }
501 }
502
503 Ok(chunk)
504}
505
506fn load_and_merge_data(config: &BacktestRunConfig) -> anyhow::Result<Vec<Data>> {
507 let mut all_data = Vec::new();
508
509 for data_config in config.data() {
510 let data = load_data(data_config, config.start(), config.end())?;
511 if data.is_empty() {
512 log::warn!("No data found for config: {:?}", data_config.data_type());
513 continue;
514 }
515 all_data.extend(data);
516 }
517 all_data.sort_by_key(HasTsInit::ts_init);
518 Ok(all_data)
519}
520
521fn create_catalog(config: &BacktestDataConfig) -> anyhow::Result<ParquetDataCatalog> {
522 let uri = match config.catalog_fs_protocol() {
523 Some(protocol) => format!("{protocol}://{}", config.catalog_path()),
524 None => config.catalog_path().to_string(),
525 };
526 let storage_options = config
527 .catalog_fs_rust_storage_options()
528 .cloned()
529 .or_else(|| config.catalog_fs_storage_options().cloned());
530 ParquetDataCatalog::from_uri(&uri, storage_options, None, None, None)
531}
532
533fn load_data(
534 config: &BacktestDataConfig,
535 run_start: Option<UnixNanos>,
536 run_end: Option<UnixNanos>,
537) -> anyhow::Result<Vec<Data>> {
538 let mut catalog = create_catalog(config)?;
539 let result = dispatch_query(&mut catalog, config, run_start, run_end)?;
540 Ok(result.collect::<Result<Vec<_>, _>>()?)
541}
542
543fn dispatch_query(
544 catalog: &mut ParquetDataCatalog,
545 config: &BacktestDataConfig,
546 run_start: Option<UnixNanos>,
547 run_end: Option<UnixNanos>,
548) -> anyhow::Result<QueryResult> {
549 catalog.reset_session();
550
551 let identifiers = config.query_identifiers();
552 let start = max_opt(config.start_time(), run_start);
553 let end = min_opt(config.end_time(), run_end);
554 let filter = config.filter_expr();
555 let optimize = config.optimize_file_loading();
556
557 match config.data_type() {
558 NautilusDataType::QuoteTick => {
559 catalog.query::<QuoteTick>(identifiers, start, end, filter, None, optimize)
560 }
561 NautilusDataType::TradeTick => {
562 catalog.query::<TradeTick>(identifiers, start, end, filter, None, optimize)
563 }
564 NautilusDataType::Bar => {
565 catalog.query::<Bar>(identifiers, start, end, filter, None, optimize)
566 }
567 NautilusDataType::OrderBookDelta => {
568 catalog.query::<OrderBookDelta>(identifiers, start, end, filter, None, optimize)
569 }
570 NautilusDataType::OrderBookDepth10 => {
571 catalog.query::<OrderBookDepth10>(identifiers, start, end, filter, None, optimize)
572 }
573 NautilusDataType::MarkPriceUpdate => {
574 catalog.query::<MarkPriceUpdate>(identifiers, start, end, filter, None, optimize)
575 }
576 NautilusDataType::IndexPriceUpdate => {
577 catalog.query::<IndexPriceUpdate>(identifiers, start, end, filter, None, optimize)
578 }
579 NautilusDataType::FundingRateUpdate => {
580 catalog.query::<FundingRateUpdate>(identifiers, start, end, filter, None, optimize)
581 }
582 NautilusDataType::InstrumentStatus => {
583 catalog.query::<InstrumentStatus>(identifiers, start, end, filter, None, optimize)
584 }
585 NautilusDataType::OptionGreeks => {
586 catalog.query::<OptionGreeks>(identifiers, start, end, filter, None, optimize)
587 }
588 NautilusDataType::InstrumentClose => {
589 catalog.query::<InstrumentClose>(identifiers, start, end, filter, None, optimize)
590 }
591 }
592}
593
594fn max_opt(a: Option<UnixNanos>, b: Option<UnixNanos>) -> Option<UnixNanos> {
595 match (a, b) {
596 (Some(a), Some(b)) => Some(a.max(b)),
597 (Some(a), None) => Some(a),
598 (None, Some(b)) => Some(b),
599 (None, None) => None,
600 }
601}
602
603fn min_opt(a: Option<UnixNanos>, b: Option<UnixNanos>) -> Option<UnixNanos> {
604 match (a, b) {
605 (Some(a), Some(b)) => Some(a.min(b)),
606 (Some(a), None) => Some(a),
607 (None, Some(b)) => Some(b),
608 (None, None) => None,
609 }
610}
611
612#[cfg(test)]
613mod tests {
614 #[cfg(feature = "python")]
615 use nautilus_model::enums::{AccountType, OmsType};
616 use nautilus_model::{
617 identifiers::InstrumentId,
618 types::{Price, Quantity},
619 };
620 #[cfg(feature = "python")]
621 use pyo3::{ffi::c_str, prelude::*, types::PyDict};
622 use rstest::rstest;
623
624 use super::*;
625 #[cfg(feature = "python")]
626 use crate::{
627 config::BacktestVenueConfig,
628 modules::SimulationModuleAny,
629 python::modules::{PySimulationModule, PythonSimulationModule},
630 };
631
632 fn quote(ts_init: u64) -> Data {
633 Data::Quote(QuoteTick::new(
634 InstrumentId::from("EUR/USD.SIM"),
635 Price::from("1.0001"),
636 Price::from("1.0002"),
637 Quantity::from("100"),
638 Quantity::from("100"),
639 UnixNanos::from(ts_init),
640 UnixNanos::from(ts_init),
641 ))
642 }
643
644 fn stream_failure() -> anyhow::Error {
645 anyhow::anyhow!("injected stream failure")
646 }
647
648 #[rstest]
649 fn take_aligned_chunk_reports_a_stream_failure() {
650 let mut iter = vec![Ok(quote(1)), Err(stream_failure())]
651 .into_iter()
652 .peekable();
653
654 let chunk = take_aligned_chunk(&mut iter, 4);
655
656 assert_eq!(
657 chunk
658 .expect_err("a failed stream must not read as a short chunk")
659 .to_string(),
660 "injected stream failure"
661 );
662 }
663
664 #[rstest]
665 fn take_aligned_chunk_reports_a_failure_found_at_the_boundary() {
666 let mut iter = vec![Ok(quote(1)), Err(stream_failure()), Ok(quote(1))]
667 .into_iter()
668 .peekable();
669
670 let first = take_aligned_chunk(&mut iter, 1).expect("the first chunk must be complete");
671 let second = take_aligned_chunk(&mut iter, 1);
672
673 assert_eq!(first.len(), 1);
674 assert_eq!(first[0].ts_init(), UnixNanos::from(1));
675 assert_eq!(
676 second
677 .expect_err("the failure must survive the boundary extension")
678 .to_string(),
679 "injected stream failure"
680 );
681 }
682
683 #[rstest]
684 fn take_aligned_chunk_extends_past_the_boundary_for_equal_timestamps() {
685 let mut iter = vec![Ok(quote(1)), Ok(quote(1)), Ok(quote(2))]
686 .into_iter()
687 .peekable();
688
689 let chunk = take_aligned_chunk(&mut iter, 1).expect("the chunk must be complete");
690
691 assert_eq!(chunk.len(), 2);
692 assert_eq!(chunk[0].ts_init(), UnixNanos::from(1));
693 assert_eq!(chunk[1].ts_init(), UnixNanos::from(1));
694 }
695
696 #[cfg(feature = "python")]
697 #[rstest]
698 fn build_engine_accepts_python_module_from_node_config() {
699 Python::initialize();
700
701 Python::attach(|py| {
702 let locals = PyDict::new(py);
703 locals
704 .set_item("SimulationModule", py.get_type::<PySimulationModule>())
705 .unwrap();
706 let module = py
707 .eval(
708 c_str!(
709 "type('NodeSimulationModule', (SimulationModule,), {\
710 'process': lambda self, ts_now, context: \
711 (setattr(self, 'calls', self.calls + 1), [])[1]\
712 })()"
713 ),
714 None,
715 Some(&locals),
716 )
717 .unwrap();
718 module.setattr("calls", 0).unwrap();
719
720 let venue = BacktestVenueConfig::builder()
721 .name("SIM")
722 .oms_type(OmsType::Netting)
723 .account_type(AccountType::Margin)
724 .book_type(BookType::L1_MBP)
725 .starting_balances(vec!["1000 USD".to_string()])
726 .modules(vec![SimulationModuleAny::Python(
727 PythonSimulationModule::new(module.clone().unbind()),
728 )])
729 .build()
730 .unwrap();
731 let config = BacktestRunConfig::builder()
732 .venues(vec![venue])
733 .data(Vec::new())
734 .build()
735 .unwrap();
736 let mut engine = build_engine(&config).unwrap();
737
738 engine.run(None, None, None, false).unwrap();
739
740 assert_eq!(
741 module.getattr("calls").unwrap().extract::<u32>().unwrap(),
742 1
743 );
744 });
745 }
746}