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