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 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
421 .map(|item| item.map_err(anyhow::Error::from))
422 .peekable();
423
424 match stream.peek() {
425 Some(Ok(_)) => streams.push(stream),
426 Some(Err(_)) => {
428 stream.next().transpose()?;
429 }
430 None => log::warn!("No data found for config: {:?}", data_config.data_type()),
431 }
432 }
433
434 stream_chunks(
435 engine,
436 config,
437 merge_streams(streams).peekable(),
438 chunk_size,
439 )
440}
441
442fn merge_streams<I: Iterator<Item = anyhow::Result<Data>>>(
446 mut streams: Vec<Peekable<I>>,
447) -> impl Iterator<Item = anyhow::Result<Data>> {
448 std::iter::from_fn(move || {
449 let mut next: Option<(usize, UnixNanos)> = None;
450
451 for (i, stream) in streams.iter_mut().enumerate() {
452 match stream.peek() {
453 Some(Ok(data)) => {
454 let ts_init = data.ts_init();
455 if next.is_none_or(|(_, ts)| ts_init < ts) {
456 next = Some((i, ts_init));
457 }
458 }
459 Some(Err(_)) => return stream.next(),
460 None => {}
461 }
462 }
463
464 streams[next?.0].next()
465 })
466}
467
468fn stream_chunks<I: Iterator<Item = anyhow::Result<Data>>>(
472 engine: &mut BacktestEngine,
473 config: &BacktestRunConfig,
474 mut iter: Peekable<I>,
475 chunk_size: usize,
476) -> anyhow::Result<()> {
477 if iter.peek().is_none() {
478 return engine.end();
479 }
480
481 let mut next_start = config.start();
482
483 loop {
484 let chunk = take_aligned_chunk(&mut iter, chunk_size)?;
485 if chunk.is_empty() {
486 break;
487 }
488
489 let is_last = iter.peek().is_none();
490 let end = if is_last {
491 config.end()
492 } else {
493 chunk.last().map(HasTsInit::ts_init)
494 };
495
496 engine.add_data(chunk, None, false, true)?;
497 engine.run(next_start, end, Some(config.id().to_string()), true)?;
498 engine.clear_data();
499
500 if engine.kernel().is_shutdown_requested() {
503 return Ok(());
504 }
505
506 next_start = end;
509 }
510
511 engine.end()
512}
513
514fn take_aligned_chunk<I: Iterator<Item = anyhow::Result<Data>>>(
517 iter: &mut Peekable<I>,
518 chunk_size: usize,
519) -> anyhow::Result<Vec<Data>> {
520 let mut chunk = Vec::with_capacity(chunk_size);
521
522 for _ in 0..chunk_size {
523 match iter.next() {
524 Some(item) => chunk.push(item?),
525 None => return Ok(chunk),
526 }
527 }
528
529 if let Some(boundary_ts) = chunk.last().map(HasTsInit::ts_init) {
530 while let Some(item) = iter.next_if(|item| {
532 item.as_ref()
533 .is_ok_and(|data| data.ts_init() == boundary_ts)
534 }) {
535 chunk.push(item?);
536 }
537 }
538
539 Ok(chunk)
540}
541
542fn create_catalog(config: &BacktestDataConfig) -> anyhow::Result<ParquetDataCatalog> {
543 let uri = match config.catalog_fs_protocol() {
544 Some(protocol) => format!("{protocol}://{}", config.catalog_path()),
545 None => config.catalog_path().to_string(),
546 };
547 let storage_options = config
548 .catalog_fs_rust_storage_options()
549 .cloned()
550 .or_else(|| config.catalog_fs_storage_options().cloned());
551 ParquetDataCatalog::from_uri(&uri, storage_options, None, None, None)
552}
553
554fn load_data(
555 config: &BacktestDataConfig,
556 run_start: Option<UnixNanos>,
557 run_end: Option<UnixNanos>,
558) -> anyhow::Result<Vec<Data>> {
559 let mut catalog = create_catalog(config)?;
560 let result = dispatch_query(&mut catalog, config, run_start, run_end)?;
561 Ok(result.collect::<Result<Vec<_>, _>>()?)
562}
563
564fn dispatch_query(
565 catalog: &mut ParquetDataCatalog,
566 config: &BacktestDataConfig,
567 run_start: Option<UnixNanos>,
568 run_end: Option<UnixNanos>,
569) -> anyhow::Result<QueryResult> {
570 catalog.reset_session();
571
572 let identifiers = config.query_identifiers();
573 let start = max_opt(config.start_time(), run_start);
574 let end = min_opt(config.end_time(), run_end);
575 let filter = config.filter_expr();
576 let optimize = config.optimize_file_loading();
577
578 #[rustfmt::skip]
579 let result = match config.data_type() {
580 NautilusDataType::QuoteTick => catalog.query::<QuoteTick>(identifiers, start, end, filter, None, optimize),
581 NautilusDataType::TradeTick => catalog.query::<TradeTick>(identifiers, start, end, filter, None, optimize),
582 NautilusDataType::Bar => catalog.query::<Bar>(identifiers, start, end, filter, None, optimize),
583 NautilusDataType::OrderBookDelta => catalog.query::<OrderBookDelta>(identifiers, start, end, filter, None, optimize),
584 NautilusDataType::OrderBookDepth10 => catalog.query::<OrderBookDepth10>(identifiers, start, end, filter, None, optimize),
585 NautilusDataType::MarkPriceUpdate => catalog.query::<MarkPriceUpdate>(identifiers, start, end, filter, None, optimize),
586 NautilusDataType::IndexPriceUpdate => catalog.query::<IndexPriceUpdate>(identifiers, start, end, filter, None, optimize),
587 NautilusDataType::FundingRateUpdate => catalog.query::<FundingRateUpdate>(identifiers, start, end, filter, None, optimize),
588 NautilusDataType::InstrumentStatus => catalog.query::<InstrumentStatus>(identifiers, start, end, filter, None, optimize),
589 NautilusDataType::OptionGreeks => catalog.query::<OptionGreeks>(identifiers, start, end, filter, None, optimize),
590 NautilusDataType::InstrumentClose => catalog.query::<InstrumentClose>(identifiers, start, end, filter, None, optimize),
591 };
592 result
593}
594
595fn max_opt(a: Option<UnixNanos>, b: Option<UnixNanos>) -> Option<UnixNanos> {
596 match (a, b) {
597 (Some(a), Some(b)) => Some(a.max(b)),
598 (Some(a), None) => Some(a),
599 (None, Some(b)) => Some(b),
600 (None, None) => None,
601 }
602}
603
604fn min_opt(a: Option<UnixNanos>, b: Option<UnixNanos>) -> Option<UnixNanos> {
605 match (a, b) {
606 (Some(a), Some(b)) => Some(a.min(b)),
607 (Some(a), None) => Some(a),
608 (None, Some(b)) => Some(b),
609 (None, None) => None,
610 }
611}
612
613#[cfg(test)]
614mod tests {
615 #[cfg(feature = "python")]
616 use nautilus_model::enums::{AccountType, OmsType};
617 use nautilus_model::{
618 enums::AggressorSide,
619 identifiers::{InstrumentId, TradeId},
620 types::{Price, Quantity},
621 };
622 #[cfg(feature = "python")]
623 use pyo3::{ffi::c_str, prelude::*, types::PyDict};
624 use rstest::rstest;
625
626 use super::*;
627 use crate::config::MAX_BACKTEST_CHUNK_SIZE;
628 #[cfg(feature = "python")]
629 use crate::{
630 config::BacktestVenueConfig,
631 modules::SimulationModuleAny,
632 python::modules::{PySimulationModule, PythonSimulationModule},
633 };
634
635 fn quote(ts_init: u64) -> Data {
636 Data::Quote(QuoteTick::new(
637 InstrumentId::from("EUR/USD.SIM"),
638 Price::from("1.0001"),
639 Price::from("1.0002"),
640 Quantity::from("100"),
641 Quantity::from("100"),
642 UnixNanos::from(ts_init),
643 UnixNanos::from(ts_init),
644 ))
645 }
646
647 fn trade(ts_init: u64) -> Data {
648 Data::Trade(TradeTick::new(
649 InstrumentId::from("EUR/USD.SIM"),
650 Price::from("1.0001"),
651 Quantity::from("100"),
652 AggressorSide::Buy,
653 TradeId::from("T-1"),
654 UnixNanos::from(ts_init),
655 UnixNanos::from(ts_init),
656 ))
657 }
658
659 fn stream_failure() -> anyhow::Error {
660 anyhow::anyhow!("injected stream failure")
661 }
662
663 #[rstest]
664 fn merge_streams_orders_items_across_streams_by_ts_init() {
665 let streams = vec![
666 vec![Ok(quote(1)), Ok(quote(3)), Ok(quote(3))]
667 .into_iter()
668 .peekable(),
669 vec![Ok(trade(2)), Ok(trade(3))].into_iter().peekable(),
670 vec![].into_iter().peekable(),
671 ];
672
673 let merged: Vec<(u64, bool)> = merge_streams(streams)
674 .map(|item| item.expect("the merged stream must not fail"))
675 .map(|data| (data.ts_init().as_u64(), matches!(data, Data::Trade(_))))
676 .collect();
677
678 assert_eq!(
679 merged,
680 vec![(1, false), (2, true), (3, false), (3, false), (3, true)]
681 );
682 }
683
684 #[rstest]
685 fn merge_streams_leaves_its_streams_undrained() {
686 let ok_quote: fn(u64) -> anyhow::Result<Data> = |ts_init| Ok(quote(ts_init));
688 let evens = (0u64..).step_by(2).map(ok_quote);
689 let odds = (1u64..).step_by(2).map(ok_quote);
690
691 let merged: Vec<u64> = merge_streams(vec![evens.peekable(), odds.peekable()])
692 .take(4)
693 .map(|item| item.expect("the merged stream must not fail").ts_init())
694 .map(|ts_init| ts_init.as_u64())
695 .collect();
696
697 assert_eq!(merged, vec![0, 1, 2, 3]);
698 }
699
700 #[rstest]
701 fn merge_streams_reports_a_stream_failure() {
702 let streams = vec![
703 vec![Ok(quote(1)), Err(stream_failure())]
704 .into_iter()
705 .peekable(),
706 vec![Ok(quote(2))].into_iter().peekable(),
707 ];
708 let mut merged = merge_streams(streams);
709
710 let first = merged.next().expect("the first item must be present");
711 let second = merged.next().expect("the failure must be yielded");
712
713 assert_eq!(
714 first.expect("the first item must not fail").ts_init(),
715 UnixNanos::from(1)
716 );
717 assert_eq!(
718 second
719 .expect_err("a failed stream must not read as exhaustion")
720 .to_string(),
721 "injected stream failure"
722 );
723 }
724
725 #[rstest]
726 fn take_aligned_chunk_reports_a_stream_failure() {
727 let mut iter = vec![Ok(quote(1)), Err(stream_failure())]
728 .into_iter()
729 .peekable();
730
731 let chunk = take_aligned_chunk(&mut iter, 4);
732
733 assert_eq!(
734 chunk
735 .expect_err("a failed stream must not read as a short chunk")
736 .to_string(),
737 "injected stream failure"
738 );
739 }
740
741 #[rstest]
742 fn take_aligned_chunk_reports_a_failure_found_at_the_boundary() {
743 let mut iter = vec![Ok(quote(1)), Err(stream_failure()), Ok(quote(1))]
744 .into_iter()
745 .peekable();
746
747 let first = take_aligned_chunk(&mut iter, 1).expect("the first chunk must be complete");
748 let second = take_aligned_chunk(&mut iter, 1);
749
750 assert_eq!(first.len(), 1);
751 assert_eq!(first[0].ts_init(), UnixNanos::from(1));
752 assert_eq!(
753 second
754 .expect_err("the failure must survive the boundary extension")
755 .to_string(),
756 "injected stream failure"
757 );
758 }
759
760 #[rstest]
761 fn take_aligned_chunk_extends_past_the_boundary_for_equal_timestamps() {
762 let mut iter = vec![Ok(quote(1)), Ok(quote(1)), Ok(quote(2))]
763 .into_iter()
764 .peekable();
765
766 let chunk = take_aligned_chunk(&mut iter, 1).expect("the chunk must be complete");
767
768 assert_eq!(chunk.len(), 2);
769 assert_eq!(chunk[0].ts_init(), UnixNanos::from(1));
770 assert_eq!(chunk[1].ts_init(), UnixNanos::from(1));
771 }
772
773 #[rstest]
774 fn take_aligned_chunk_reserves_maximum_supported_capacity() {
775 let mut iter = vec![Ok(quote(1))].into_iter().peekable();
776
777 let chunk = take_aligned_chunk(&mut iter, MAX_BACKTEST_CHUNK_SIZE).unwrap();
778
779 assert_eq!(chunk.len(), 1);
780 assert!(chunk.capacity() >= MAX_BACKTEST_CHUNK_SIZE);
781 assert_eq!(chunk[0].ts_init(), UnixNanos::from(1));
782 }
783
784 #[cfg(feature = "python")]
785 #[rstest]
786 fn build_engine_accepts_python_module_from_node_config() {
787 Python::initialize();
788
789 Python::attach(|py| {
790 let locals = PyDict::new(py);
791 locals
792 .set_item("SimulationModule", py.get_type::<PySimulationModule>())
793 .unwrap();
794 let module = py
795 .eval(
796 c_str!(
797 "type('NodeSimulationModule', (SimulationModule,), {\
798 'process': lambda self, ts_now, context: \
799 (setattr(self, 'calls', self.calls + 1), [])[1]\
800 })()"
801 ),
802 None,
803 Some(&locals),
804 )
805 .unwrap();
806 module.setattr("calls", 0).unwrap();
807
808 let venue = BacktestVenueConfig::builder()
809 .name("SIM")
810 .oms_type(OmsType::Netting)
811 .account_type(AccountType::Margin)
812 .book_type(BookType::L1_MBP)
813 .starting_balances(vec!["1000 USD".to_string()])
814 .modules(vec![SimulationModuleAny::Python(
815 PythonSimulationModule::new(module.clone().unbind()),
816 )])
817 .build()
818 .unwrap();
819 let config = BacktestRunConfig::builder()
820 .venues(vec![venue])
821 .data(Vec::new())
822 .build()
823 .unwrap();
824 let mut engine = build_engine(&config).unwrap();
825
826 engine.run(None, None, None, false).unwrap();
827
828 assert_eq!(
829 module.getattr("calls").unwrap().extract::<u32>().unwrap(),
830 1
831 );
832 });
833 }
834}