1use std::iter::Peekable;
19
20use ahash::{AHashMap, AHashSet};
21use nautilus_core::{Params, UnixNanos};
22use nautilus_model::{
23 data::{Data, HasTsInit, NautilusDataType},
24 enums::{BookType, OtoTriggerMode},
25 identifiers::{InstrumentId, Venue},
26 types::Money,
27};
28use nautilus_persistence::{
29 catalog::traits::{CatalogInstrumentQuery, CatalogQuery, DataCatalog},
30 config::DataCatalogConfig,
31};
32
33use crate::{
34 config::{BacktestDataConfig, BacktestRunConfig, SimulatedVenueConfig},
35 engine::BacktestEngine,
36 result::BacktestResult,
37};
38
39#[derive(Debug)]
44#[cfg_attr(
45 feature = "python",
46 pyo3::pyclass(module = "nautilus_trader.backtest", unsendable)
47)]
48#[cfg_attr(
49 feature = "python",
50 pyo3_stub_gen::derive::gen_stub_pyclass(module = "nautilus_trader.backtest")
51)]
52pub struct BacktestNode {
53 configs: Vec<BacktestRunConfig>,
54 engines: AHashMap<String, BacktestEngine>,
55}
56
57impl BacktestNode {
58 pub fn new(configs: Vec<BacktestRunConfig>) -> anyhow::Result<Self> {
69 anyhow::ensure!(!configs.is_empty(), "At least one run config is required");
70 validate_configs(&configs)?;
71 Ok(Self {
72 configs,
73 engines: AHashMap::new(),
74 })
75 }
76
77 #[must_use]
79 pub fn configs(&self) -> &[BacktestRunConfig] {
80 &self.configs
81 }
82
83 pub fn build(&mut self) -> anyhow::Result<()> {
95 for config in &self.configs {
96 if self.engines.contains_key(config.id()) {
97 continue;
98 }
99
100 match build_engine(config) {
101 Ok(engine) => {
102 self.engines.insert(config.id().to_string(), engine);
103 }
104 Err(e) if config.raise_exception() => return Err(e),
105 Err(e) => {
106 log::error!("Error building backtest '{}': {e:#}", config.id());
107 }
108 }
109 }
110
111 Ok(())
112 }
113
114 #[must_use]
116 pub fn get_engine_mut(&mut self, id: &str) -> Option<&mut BacktestEngine> {
117 self.engines.get_mut(id)
118 }
119
120 #[must_use]
122 pub fn get_engine(&self, id: &str) -> Option<&BacktestEngine> {
123 self.engines.get(id)
124 }
125
126 #[must_use]
128 pub fn get_engines(&self) -> Vec<&BacktestEngine> {
129 self.engines.values().collect()
130 }
131
132 pub fn run(&mut self) -> anyhow::Result<Vec<BacktestResult>> {
146 if self.engines.is_empty() {
148 self.build()?;
149 }
150
151 let mut results = Vec::new();
152
153 for config in &self.configs {
154 let Some(engine) = self.engines.get_mut(config.id()) else {
155 continue;
156 };
157
158 let run_result = match config.chunk_size() {
159 None => run_oneshot(engine, config),
160 Some(chunk_size) => run_streaming(engine, config, chunk_size),
161 };
162
163 if let Err(e) = run_result {
164 if config.raise_exception() {
165 return Err(e);
166 }
167
168 log::error!("Error running backtest '{}': {e:#}", config.id());
169 engine.clear_data();
170 continue;
171 }
172
173 results.push(engine.get_result());
174
175 if config.dispose_on_completion() {
176 engine.dispose();
177 } else {
178 engine.clear_data();
179 }
180 }
181
182 Ok(results)
183 }
184
185 pub fn load_catalog(config: &BacktestDataConfig) -> anyhow::Result<DataCatalog> {
191 create_catalog(config)
192 }
193
194 pub fn load_data_config(
200 config: &BacktestDataConfig,
201 start: Option<UnixNanos>,
202 end: Option<UnixNanos>,
203 ) -> anyhow::Result<Vec<Data>> {
204 load_data(config, start, end)
205 }
206
207 pub fn dispose(&mut self) {
209 for engine in self.engines.values_mut() {
210 engine.dispose();
211 }
212 self.engines.clear();
213 }
214}
215
216fn build_engine(config: &BacktestRunConfig) -> anyhow::Result<BacktestEngine> {
217 let engine_config = config.engine().clone();
218 let mut engine = BacktestEngine::new(engine_config)?;
219
220 for venue_config in config.venues() {
221 let starting_balances: Vec<Money> = venue_config
222 .starting_balances()
223 .iter()
224 .map(|s| s.parse::<Money>())
225 .collect::<Result<Vec<_>, _>>()
226 .map_err(|e| anyhow::anyhow!("Invalid starting balance: {e}"))?;
227
228 let default_leverage = venue_config.default_leverage();
229 let leverages = venue_config.leverages().cloned().unwrap_or_default();
230 let margin_model = venue_config.margin_model().cloned().map(Into::into);
231 let modules = venue_config
232 .modules()
233 .iter()
234 .cloned()
235 .map(Into::into)
236 .collect();
237 let fill_model = venue_config
238 .fill_model()
239 .cloned()
240 .unwrap_or_default()
241 .into();
242 let fee_model = venue_config.fee_model().cloned().unwrap_or_default().into();
243 let latency_model = venue_config.latency_model().cloned().map(Into::into);
244 let sim_config = SimulatedVenueConfig::builder()
245 .venue(Venue::from(venue_config.name().as_str()))
246 .oms_type(venue_config.oms_type())
247 .account_type(venue_config.account_type())
248 .book_type(venue_config.book_type())
249 .starting_balances(starting_balances)
250 .maybe_base_currency(venue_config.base_currency())
251 .maybe_default_leverage(default_leverage)
252 .leverages(leverages)
253 .maybe_margin_model(margin_model)
254 .modules(modules)
255 .fill_model(fill_model)
256 .fee_model(fee_model)
257 .maybe_latency_model(latency_model)
258 .routing(venue_config.routing())
259 .reject_stop_orders(venue_config.reject_stop_orders())
260 .support_gtd_orders(venue_config.support_gtd_orders())
261 .support_contingent_orders(venue_config.support_contingent_orders())
262 .use_position_ids(venue_config.use_position_ids())
263 .use_random_ids(venue_config.use_random_ids())
264 .use_reduce_only(venue_config.use_reduce_only())
265 .use_market_order_acks(venue_config.use_market_order_acks())
266 .bar_execution(venue_config.bar_execution())
267 .bar_adaptive_high_low_ordering(venue_config.bar_adaptive_high_low_ordering())
268 .trade_execution(venue_config.trade_execution())
269 .liquidity_consumption(venue_config.liquidity_consumption())
270 .allow_cash_borrowing(venue_config.allow_cash_borrowing())
271 .frozen_account(venue_config.frozen_account())
272 .queue_position(venue_config.queue_position())
273 .oto_full_trigger(venue_config.oto_trigger_mode() == OtoTriggerMode::Full)
274 .price_protection_points(venue_config.price_protection_points())
275 .liquidation_enabled(venue_config.liquidation_enabled())
276 .liquidation_trigger_ratio(venue_config.liquidation_trigger_ratio())
277 .liquidation_cancel_open_orders(venue_config.liquidation_cancel_open_orders())
278 .build()?;
279 engine.add_venue(sim_config)?;
280 }
281
282 for data_config in config.data() {
283 let mut catalog = create_catalog(data_config)?;
284 let instr_ids: Vec<InstrumentId> = data_config.get_instrument_ids()?;
285 let filter: Option<Vec<String>> = if instr_ids.is_empty() {
286 None
287 } else {
288 Some(instr_ids.iter().map(ToString::to_string).collect())
289 };
290
291 let instruments =
292 catalog.instruments(&CatalogInstrumentQuery::new().with_instrument_ids(filter))?;
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::OrderBookDepth
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 let mut catalogs = data_configs
413 .iter()
414 .map(create_catalog)
415 .collect::<anyhow::Result<Vec<_>>>()?;
416 let mut streams = Vec::with_capacity(catalogs.len());
417
418 for (catalog, data_config) in catalogs.iter_mut().zip(data_configs) {
419 let result = dispatch_query(catalog, data_config, config.start(), config.end())?;
420 let mut stream = result.peekable();
421
422 match stream.peek() {
423 Some(Ok(_)) => streams.push(stream),
424 Some(Err(_)) => {
426 stream.next().transpose()?;
427 }
428 None => log::warn!("No data found for config: {:?}", data_config.data_type()),
429 }
430 }
431
432 stream_chunks(
433 engine,
434 config,
435 merge_streams(streams).peekable(),
436 chunk_size,
437 )
438}
439
440fn merge_streams<I: Iterator<Item = anyhow::Result<Data>>>(
444 mut streams: Vec<Peekable<I>>,
445) -> impl Iterator<Item = anyhow::Result<Data>> {
446 std::iter::from_fn(move || {
447 let mut next: Option<(usize, UnixNanos)> = None;
448
449 for (i, stream) in streams.iter_mut().enumerate() {
450 match stream.peek() {
451 Some(Ok(data)) => {
452 let ts_init = data.ts_init();
453 if next.is_none_or(|(_, ts)| ts_init < ts) {
454 next = Some((i, ts_init));
455 }
456 }
457 Some(Err(_)) => return stream.next(),
458 None => {}
459 }
460 }
461
462 streams[next?.0].next()
463 })
464}
465
466fn stream_chunks<I: Iterator<Item = anyhow::Result<Data>>>(
470 engine: &mut BacktestEngine,
471 config: &BacktestRunConfig,
472 mut iter: Peekable<I>,
473 chunk_size: usize,
474) -> anyhow::Result<()> {
475 if iter.peek().is_none() {
476 return engine.end();
477 }
478
479 let mut next_start = config.start();
480
481 loop {
482 let chunk = take_aligned_chunk(&mut iter, chunk_size)?;
483 if chunk.is_empty() {
484 break;
485 }
486
487 let is_last = iter.peek().is_none();
488 let end = if is_last {
489 config.end()
490 } else {
491 chunk.last().map(HasTsInit::ts_init)
492 };
493
494 engine.add_data(chunk, None, false, true)?;
495 engine.run(next_start, end, Some(config.id().to_string()), true)?;
496 engine.clear_data();
497
498 if engine.kernel().is_shutdown_requested() {
501 return Ok(());
502 }
503
504 next_start = end;
507 }
508
509 engine.end()
510}
511
512fn take_aligned_chunk<I: Iterator<Item = anyhow::Result<Data>>>(
515 iter: &mut Peekable<I>,
516 chunk_size: usize,
517) -> anyhow::Result<Vec<Data>> {
518 let mut chunk = Vec::with_capacity(chunk_size);
519
520 for _ in 0..chunk_size {
521 match iter.next() {
522 Some(item) => chunk.push(item?),
523 None => return Ok(chunk),
524 }
525 }
526
527 if let Some(boundary_ts) = chunk.last().map(HasTsInit::ts_init) {
528 while let Some(item) = iter.next_if(|item| {
530 item.as_ref()
531 .is_ok_and(|data| data.ts_init() == boundary_ts)
532 }) {
533 chunk.push(item?);
534 }
535 }
536
537 Ok(chunk)
538}
539
540fn create_catalog(config: &BacktestDataConfig) -> anyhow::Result<DataCatalog> {
541 DataCatalogConfig::new(
542 config.catalog_path().to_string(),
543 config.catalog_fs_protocol().map(str::to_string),
544 Some(config.catalog_backend()),
545 )
546 .with_storage_options(
547 config
548 .catalog_fs_rust_storage_options()
549 .cloned()
550 .or_else(|| config.catalog_fs_storage_options().cloned()),
551 )
552 .create_catalog()
553}
554
555fn load_data(
556 config: &BacktestDataConfig,
557 run_start: Option<UnixNanos>,
558 run_end: Option<UnixNanos>,
559) -> anyhow::Result<Vec<Data>> {
560 let mut catalog = create_catalog(config)?;
561 let result = dispatch_query(&mut catalog, config, run_start, run_end)?;
562 result.collect::<Result<Vec<_>, _>>()
563}
564
565fn dispatch_query(
566 catalog: &mut DataCatalog,
567 config: &BacktestDataConfig,
568 start: Option<UnixNanos>,
569 end: Option<UnixNanos>,
570) -> anyhow::Result<Box<dyn Iterator<Item = anyhow::Result<Data>>>> {
571 catalog.reset_session();
572 let mut query = CatalogQuery::new(config.data_type().clone())
573 .with_identifiers(config.query_identifiers())
574 .with_range(
575 max_opt(config.start_time(), start),
576 min_opt(config.end_time(), end),
577 )
578 .with_where_clause(config.filter_expr().map(str::to_string));
579 let mut params = Params::new();
580 params.insert(
581 "optimize_file_loading".to_string(),
582 config.optimize_file_loading().into(),
583 );
584 query.params = Some(params);
585 let mut session = catalog.query_batch_session(&query, None)?;
586 let mut failed = false;
587 Ok(Box::new(
588 std::iter::from_fn(move || {
589 if failed {
590 return None;
591 }
592
593 match session.next_batch() {
594 Ok(Some(batch)) => Some(Ok(batch.to_data_vec_for_compat())),
595 Ok(None) => None,
596 Err(e) => {
597 failed = true;
598 Some(Err(e))
599 }
600 }
601 })
602 .flat_map(|batch| match batch {
603 Ok(rows) => rows.into_iter().map(Ok).collect::<Vec<_>>(),
604 Err(e) => vec![Err(e)],
605 }),
606 ))
607}
608
609fn max_opt(a: Option<UnixNanos>, b: Option<UnixNanos>) -> Option<UnixNanos> {
610 match (a, b) {
611 (Some(a), Some(b)) => Some(a.max(b)),
612 (Some(a), None) => Some(a),
613 (None, Some(b)) => Some(b),
614 (None, None) => None,
615 }
616}
617
618fn min_opt(a: Option<UnixNanos>, b: Option<UnixNanos>) -> Option<UnixNanos> {
619 match (a, b) {
620 (Some(a), Some(b)) => Some(a.min(b)),
621 (Some(a), None) => Some(a),
622 (None, Some(b)) => Some(b),
623 (None, None) => None,
624 }
625}
626
627#[cfg(test)]
628mod tests {
629 #[cfg(feature = "python")]
630 use nautilus_model::enums::{AccountType, OmsType};
631 use nautilus_model::{
632 data::{QuoteTick, TradeTick},
633 enums::AggressorSide,
634 identifiers::{InstrumentId, TradeId},
635 types::{Price, Quantity},
636 };
637 #[cfg(feature = "python")]
638 use pyo3::{ffi::c_str, prelude::*, types::PyDict};
639 use rstest::rstest;
640
641 use super::*;
642 use crate::config::MAX_BACKTEST_CHUNK_SIZE;
643 #[cfg(feature = "python")]
644 use crate::{
645 config::BacktestVenueConfig,
646 modules::SimulationModuleAny,
647 python::modules::{PySimulationModule, PythonSimulationModule},
648 };
649
650 fn quote(ts_init: u64) -> Data {
651 Data::Quote(QuoteTick::new(
652 InstrumentId::from("EUR/USD.SIM"),
653 Price::from("1.0001"),
654 Price::from("1.0002"),
655 Quantity::from("100"),
656 Quantity::from("100"),
657 UnixNanos::from(ts_init),
658 UnixNanos::from(ts_init),
659 ))
660 }
661
662 fn trade(ts_init: u64) -> Data {
663 Data::Trade(TradeTick::new(
664 InstrumentId::from("EUR/USD.SIM"),
665 Price::from("1.0001"),
666 Quantity::from("100"),
667 AggressorSide::Buy,
668 TradeId::from("T-1"),
669 UnixNanos::from(ts_init),
670 UnixNanos::from(ts_init),
671 ))
672 }
673
674 fn stream_failure() -> anyhow::Error {
675 anyhow::anyhow!("injected stream failure")
676 }
677
678 #[rstest]
679 fn merge_streams_orders_items_across_streams_by_ts_init() {
680 let streams = vec![
681 vec![Ok(quote(1)), Ok(quote(3)), Ok(quote(3))]
682 .into_iter()
683 .peekable(),
684 vec![Ok(trade(2)), Ok(trade(3))].into_iter().peekable(),
685 vec![].into_iter().peekable(),
686 ];
687
688 let merged: Vec<(u64, bool)> = merge_streams(streams)
689 .map(|item| item.expect("the merged stream must not fail"))
690 .map(|data| (data.ts_init().as_u64(), matches!(data, Data::Trade(_))))
691 .collect();
692
693 assert_eq!(
694 merged,
695 vec![(1, false), (2, true), (3, false), (3, false), (3, true)]
696 );
697 }
698
699 #[rstest]
700 fn merge_streams_leaves_its_streams_undrained() {
701 let ok_quote: fn(u64) -> anyhow::Result<Data> = |ts_init| Ok(quote(ts_init));
703 let evens = (0u64..).step_by(2).map(ok_quote);
704 let odds = (1u64..).step_by(2).map(ok_quote);
705
706 let merged: Vec<u64> = merge_streams(vec![evens.peekable(), odds.peekable()])
707 .take(4)
708 .map(|item| item.expect("the merged stream must not fail").ts_init())
709 .map(|ts_init| ts_init.as_u64())
710 .collect();
711
712 assert_eq!(merged, vec![0, 1, 2, 3]);
713 }
714
715 #[rstest]
716 fn merge_streams_reports_a_stream_failure() {
717 let streams = vec![
718 vec![Ok(quote(1)), Err(stream_failure())]
719 .into_iter()
720 .peekable(),
721 vec![Ok(quote(2))].into_iter().peekable(),
722 ];
723 let mut merged = merge_streams(streams);
724
725 let first = merged.next().expect("the first item must be present");
726 let second = merged.next().expect("the failure must be yielded");
727
728 assert_eq!(
729 first.expect("the first item must not fail").ts_init(),
730 UnixNanos::from(1)
731 );
732 assert_eq!(
733 second
734 .expect_err("a failed stream must not read as exhaustion")
735 .to_string(),
736 "injected stream failure"
737 );
738 }
739
740 #[rstest]
741 fn take_aligned_chunk_reports_a_stream_failure() {
742 let mut iter = vec![Ok(quote(1)), Err(stream_failure())]
743 .into_iter()
744 .peekable();
745
746 let chunk = take_aligned_chunk(&mut iter, 4);
747
748 assert_eq!(
749 chunk
750 .expect_err("a failed stream must not read as a short chunk")
751 .to_string(),
752 "injected stream failure"
753 );
754 }
755
756 #[rstest]
757 fn take_aligned_chunk_reports_a_failure_found_at_the_boundary() {
758 let mut iter = vec![Ok(quote(1)), Err(stream_failure()), Ok(quote(1))]
759 .into_iter()
760 .peekable();
761
762 let first = take_aligned_chunk(&mut iter, 1).expect("the first chunk must be complete");
763 let second = take_aligned_chunk(&mut iter, 1);
764
765 assert_eq!(first.len(), 1);
766 assert_eq!(first[0].ts_init(), UnixNanos::from(1));
767 assert_eq!(
768 second
769 .expect_err("the failure must survive the boundary extension")
770 .to_string(),
771 "injected stream failure"
772 );
773 }
774
775 #[rstest]
776 fn take_aligned_chunk_extends_past_the_boundary_for_equal_timestamps() {
777 let mut iter = vec![Ok(quote(1)), Ok(quote(1)), Ok(quote(2))]
778 .into_iter()
779 .peekable();
780
781 let chunk = take_aligned_chunk(&mut iter, 1).expect("the chunk must be complete");
782
783 assert_eq!(chunk.len(), 2);
784 assert_eq!(chunk[0].ts_init(), UnixNanos::from(1));
785 assert_eq!(chunk[1].ts_init(), UnixNanos::from(1));
786 }
787
788 #[rstest]
789 fn take_aligned_chunk_reserves_maximum_supported_capacity() {
790 let mut iter = vec![Ok(quote(1))].into_iter().peekable();
791
792 let chunk = take_aligned_chunk(&mut iter, MAX_BACKTEST_CHUNK_SIZE).unwrap();
793
794 assert_eq!(chunk.len(), 1);
795 assert!(chunk.capacity() >= MAX_BACKTEST_CHUNK_SIZE);
796 assert_eq!(chunk[0].ts_init(), UnixNanos::from(1));
797 }
798
799 #[cfg(feature = "python")]
800 #[rstest]
801 fn build_engine_accepts_python_module_from_node_config() {
802 Python::initialize();
803
804 Python::attach(|py| {
805 let locals = PyDict::new(py);
806 locals
807 .set_item("SimulationModule", py.get_type::<PySimulationModule>())
808 .unwrap();
809 let module = py
810 .eval(
811 c_str!(
812 "type('NodeSimulationModule', (SimulationModule,), {\
813 'process': lambda self, ts_now, context: \
814 (setattr(self, 'calls', self.calls + 1), [])[1]\
815 })()"
816 ),
817 None,
818 Some(&locals),
819 )
820 .unwrap();
821 module.setattr("calls", 0).unwrap();
822
823 let venue = BacktestVenueConfig::builder()
824 .name("SIM")
825 .oms_type(OmsType::Netting)
826 .account_type(AccountType::Margin)
827 .book_type(BookType::L1_MBP)
828 .starting_balances(vec!["1000 USD".to_string()])
829 .modules(vec![SimulationModuleAny::Python(
830 PythonSimulationModule::new(module.clone().unbind()),
831 )])
832 .build()
833 .unwrap();
834 let config = BacktestRunConfig::builder()
835 .venues(vec![venue])
836 .data(Vec::new())
837 .build()
838 .unwrap();
839 let mut engine = build_engine(&config).unwrap();
840
841 engine.run(None, None, None, false).unwrap();
842
843 assert_eq!(
844 module.getattr("calls").unwrap().extract::<u32>().unwrap(),
845 1
846 );
847 });
848 }
849}