1use std::{iter::Peekable, mem};
19
20use ahash::{AHashMap, AHashSet};
21use nautilus_core::{Params, UnixNanos};
22use nautilus_data::replay::{DataIterator, ReplayBatch};
23use nautilus_model::{
24 data::{Data, DataBatch, HasTsInit, NautilusDataType, OrderBookDelta, OrderBookDeltas},
25 enums::{BookType, OtoTriggerMode, RecordFlag},
26 identifiers::{InstrumentId, Venue},
27 types::Money,
28};
29use nautilus_persistence::{
30 backend::default_catalog_factories,
31 catalog::{
32 factory as catalog_factory,
33 traits::{CatalogInstrumentQuery, CatalogQuery, DataCatalog},
34 },
35 config::DataCatalogConfig,
36};
37
38use crate::{
39 config::{BacktestDataConfig, BacktestRunConfig, SimulatedVenueConfig},
40 engine::BacktestEngine,
41 result::BacktestResult,
42};
43
44#[derive(Debug)]
49#[cfg_attr(
50 feature = "python",
51 pyo3::pyclass(module = "nautilus_trader.backtest", unsendable)
52)]
53#[cfg_attr(
54 feature = "python",
55 pyo3_stub_gen::derive::gen_stub_pyclass(module = "nautilus_trader.backtest")
56)]
57pub struct BacktestNode {
58 configs: Vec<BacktestRunConfig>,
59 engines: AHashMap<String, BacktestEngine>,
60 disposed: bool,
61}
62
63impl BacktestNode {
64 pub fn new(configs: Vec<BacktestRunConfig>) -> anyhow::Result<Self> {
75 anyhow::ensure!(!configs.is_empty(), "At least one run config is required");
76 validate_configs(&configs)?;
77 Ok(Self {
78 configs,
79 engines: AHashMap::new(),
80 disposed: false,
81 })
82 }
83
84 #[must_use]
86 pub fn configs(&self) -> &[BacktestRunConfig] {
87 &self.configs
88 }
89
90 pub fn build(&mut self) -> anyhow::Result<()> {
105 self.ensure_not_disposed()?;
106
107 for config in &self.configs {
108 if self.engines.contains_key(config.id()) {
109 continue;
110 }
111
112 match build_engine(config) {
113 Ok(engine) => {
114 self.engines.insert(config.id().to_string(), engine);
115 }
116 Err(e) if config.raise_exception() => return Err(e),
117 Err(e) => {
118 log::error!("Error building backtest '{}': {e:#}", config.id());
119 }
120 }
121 }
122
123 Ok(())
124 }
125
126 #[must_use]
128 pub fn get_engine_mut(&mut self, id: &str) -> Option<&mut BacktestEngine> {
129 self.engines.get_mut(id)
130 }
131
132 #[must_use]
134 pub fn get_engine(&self, id: &str) -> Option<&BacktestEngine> {
135 self.engines.get(id)
136 }
137
138 #[must_use]
140 pub fn get_engines(&self) -> Vec<&BacktestEngine> {
141 self.engines.values().collect()
142 }
143
144 pub fn run(&mut self) -> anyhow::Result<Vec<BacktestResult>> {
163 self.ensure_not_disposed()?;
164
165 if self.engines.is_empty() {
167 self.build()?;
168 }
169
170 let mut results = Vec::new();
171
172 for config in &self.configs {
173 let Some(engine) = self.engines.get_mut(config.id()) else {
174 continue;
175 };
176
177 let run_result = match config.chunk_size() {
178 None => run_oneshot(engine, config),
179 Some(chunk_size) => run_streaming(engine, config, chunk_size),
180 };
181
182 if let Err(e) = run_result {
183 if config.raise_exception() {
184 return Err(e);
185 }
186
187 log::error!("Error running backtest '{}': {e:#}", config.id());
188 engine.clear_data();
189 continue;
190 }
191
192 results.push(engine.get_result());
193
194 if config.dispose_on_completion() {
195 engine.dispose();
196 self.disposed = true;
197 } else {
198 engine.clear_data();
199 }
200 }
201
202 Ok(results)
203 }
204
205 pub fn load_catalog(config: &BacktestDataConfig) -> anyhow::Result<DataCatalog> {
211 create_catalog(config)
212 }
213
214 pub fn load_data_config(
220 config: &BacktestDataConfig,
221 start: Option<UnixNanos>,
222 end: Option<UnixNanos>,
223 ) -> anyhow::Result<Vec<Data>> {
224 load_data(config, start, end)
225 }
226
227 pub fn dispose(&mut self) {
231 self.disposed = true;
232
233 for engine in self.engines.values_mut() {
234 engine.dispose();
235 }
236 self.engines.clear();
237 }
238
239 fn ensure_not_disposed(&self) -> anyhow::Result<()> {
240 anyhow::ensure!(
241 !self.disposed,
242 "BacktestNode has been disposed; create a new BacktestNode to run again"
243 );
244 Ok(())
245 }
246}
247
248fn build_engine(config: &BacktestRunConfig) -> anyhow::Result<BacktestEngine> {
249 let engine_config = config.engine().clone();
250 let mut engine = BacktestEngine::new(engine_config)?;
251
252 for venue_config in config.venues() {
253 let starting_balances: Vec<Money> = venue_config
254 .starting_balances()
255 .iter()
256 .map(|s| s.parse::<Money>())
257 .collect::<Result<Vec<_>, _>>()
258 .map_err(|e| anyhow::anyhow!("Invalid starting balance: {e}"))?;
259
260 let default_leverage = venue_config.default_leverage();
261 let leverages = venue_config.leverages().cloned().unwrap_or_default();
262 let margin_model = venue_config.margin_model().cloned().map(Into::into);
263 let modules = venue_config
264 .modules()
265 .iter()
266 .cloned()
267 .map(Into::into)
268 .collect();
269 let fill_model = venue_config
270 .fill_model()
271 .cloned()
272 .unwrap_or_default()
273 .into();
274
275 let fee_model = venue_config
276 .fee_model()
277 .cloned()
278 .ok_or_else(|| {
279 anyhow::anyhow!(
280 "BacktestVenueConfig for '{}' requires an explicit fee_model, including an explicit zero-fee model",
281 venue_config.name()
282 )
283 })?
284 .into();
285
286 let latency_model = venue_config.latency_model().cloned().map(Into::into);
287 let sim_config = SimulatedVenueConfig::builder()
288 .venue(Venue::from(venue_config.name().as_str()))
289 .oms_type(venue_config.oms_type())
290 .account_type(venue_config.account_type())
291 .book_type(venue_config.book_type())
292 .starting_balances(starting_balances)
293 .maybe_base_currency(venue_config.base_currency())
294 .maybe_default_leverage(default_leverage)
295 .leverages(leverages)
296 .maybe_margin_model(margin_model)
297 .modules(modules)
298 .fill_model(fill_model)
299 .fee_model(fee_model)
300 .maybe_latency_model(latency_model)
301 .routing(venue_config.routing())
302 .reject_stop_orders(venue_config.reject_stop_orders())
303 .support_gtd_orders(venue_config.support_gtd_orders())
304 .support_contingent_orders(venue_config.support_contingent_orders())
305 .use_position_ids(venue_config.use_position_ids())
306 .use_random_ids(venue_config.use_random_ids())
307 .use_reduce_only(venue_config.use_reduce_only())
308 .use_market_order_acks(venue_config.use_market_order_acks())
309 .bar_execution(venue_config.bar_execution())
310 .bar_adaptive_high_low_ordering(venue_config.bar_adaptive_high_low_ordering())
311 .trade_execution(venue_config.trade_execution())
312 .liquidity_consumption(venue_config.liquidity_consumption())
313 .allow_cash_borrowing(venue_config.allow_cash_borrowing())
314 .frozen_account(venue_config.frozen_account())
315 .queue_position(venue_config.queue_position())
316 .oto_full_trigger(venue_config.oto_trigger_mode() == OtoTriggerMode::Full)
317 .price_protection_points(venue_config.price_protection_points())
318 .liquidation_enabled(venue_config.liquidation_enabled())
319 .liquidation_trigger_ratio(venue_config.liquidation_trigger_ratio())
320 .liquidation_cancel_open_orders(venue_config.liquidation_cancel_open_orders())
321 .build()?;
322 engine.add_venue(sim_config)?;
323 }
324
325 for data_config in config.data() {
326 let mut catalog = create_catalog(data_config)?;
327 let instr_ids: Vec<InstrumentId> = data_config.get_instrument_ids()?;
328 let filter: Option<Vec<String>> = if instr_ids.is_empty() {
329 None
330 } else {
331 Some(instr_ids.iter().map(ToString::to_string).collect())
332 };
333
334 let instruments =
335 catalog.instruments(&CatalogInstrumentQuery::new().with_instrument_ids(filter))?;
336
337 if !instr_ids.is_empty() && instruments.is_empty() {
338 let ids: Vec<String> = instr_ids.iter().map(ToString::to_string).collect();
339 anyhow::bail!(
340 "No instruments found in catalog for requested IDs: [{}]",
341 ids.join(", ")
342 );
343 }
344
345 for instrument in instruments {
346 engine.add_instrument(&instrument)?;
347 }
348 }
349
350 Ok(engine)
351}
352
353fn validate_configs(configs: &[BacktestRunConfig]) -> anyhow::Result<()> {
354 anyhow::ensure!(
357 configs.len() <= 1,
358 "Only one run config per BacktestNode is supported \
359 (kernel MessageBus is a thread-local singleton)"
360 );
361
362 let mut seen_ids = AHashSet::new();
363
364 for config in configs {
365 anyhow::ensure!(
366 seen_ids.insert(config.id()),
367 "Duplicate run config ID '{}'",
368 config.id()
369 );
370
371 let venue_names: Vec<String> = config
372 .venues()
373 .iter()
374 .map(|v| v.name().to_string())
375 .collect();
376
377 for data_config in config.data() {
378 if let (Some(start), Some(end)) = (data_config.start_time(), data_config.end_time()) {
379 anyhow::ensure!(
380 start <= end,
381 "Data config start_time ({start}) must be <= end_time ({end})"
382 );
383 }
384
385 for instrument_id in data_config.get_instrument_ids()? {
386 let venue = instrument_id.venue.to_string();
387 anyhow::ensure!(
388 venue_names.contains(&venue),
389 "No venue config found for venue '{venue}' (required by instrument {instrument_id})"
390 );
391 }
392 }
393
394 for venue_config in config.venues() {
395 let needs_book_data = matches!(
396 venue_config.book_type(),
397 BookType::L2_MBP | BookType::L3_MBO
398 );
399
400 if needs_book_data {
401 let venue_name = venue_config.name().to_string();
402 let has_book_data = config.data().iter().any(|dc| {
403 let is_book_type = matches!(
404 dc.data_type(),
405 NautilusDataType::OrderBookDelta | NautilusDataType::OrderBookDepth
406 );
407
408 if !is_book_type {
409 return false;
410 }
411
412 let ids = dc.get_instrument_ids().unwrap_or_default();
414 ids.is_empty() || ids.iter().any(|id| id.venue.to_string() == venue_name)
415 });
416 anyhow::ensure!(
417 has_book_data,
418 "Venue '{venue_name}' has book_type {:?} but no order book data configured",
419 venue_config.book_type()
420 );
421 }
422 }
423 }
424 Ok(())
425}
426
427fn run_oneshot(engine: &mut BacktestEngine, config: &BacktestRunConfig) -> anyhow::Result<()> {
428 for data_config in config.data() {
429 let data = load_data(data_config, config.start(), config.end())?;
430 if data.is_empty() {
431 log::warn!("No data found for config: {:?}", data_config.data_type());
432 continue;
433 }
434 engine.add_data(data, data_config.client_id(), false, false)?;
435 }
436
437 engine.sort_data();
438 engine.run(
439 config.start(),
440 config.end(),
441 Some(config.id().to_string()),
442 false,
443 )
444}
445
446fn run_streaming(
447 engine: &mut BacktestEngine,
448 config: &BacktestRunConfig,
449 chunk_size: usize,
450) -> anyhow::Result<()> {
451 let data_configs = config.data();
452
453 let mut catalogs = data_configs
455 .iter()
456 .map(create_catalog)
457 .collect::<anyhow::Result<Vec<_>>>()?;
458 let mut streams = Vec::with_capacity(catalogs.len());
459
460 for (index, (catalog, data_config)) in catalogs.iter_mut().zip(data_configs).enumerate() {
461 let result: Box<dyn Iterator<Item = anyhow::Result<ReplayBatch>>> = if data_config
462 .batch_deltas()
463 && *data_config.data_type() == NautilusDataType::OrderBookDelta
464 {
465 let rows = dispatch_query(catalog, data_config, config.start(), config.end())?;
466
467 Box::new(rows.map(|item| {
468 item.map(|data| match data {
469 Data::BookDeltas(deltas) => ReplayBatch::from_book_deltas(deltas),
470 _ => unreachable!("delta query yields assembled book events"),
471 })
472 }))
473 } else {
474 let batches = dispatch_batch_query(catalog, data_config, config.start(), config.end())?;
475 Box::new(batches.map(|item| item.map(ReplayBatch::Typed)))
476 };
477
478 let mut stream = result.peekable();
479
480 match stream.peek() {
481 Some(Ok(_)) => streams.push((
482 format!("config {index} ({:?})", data_config.data_type()),
483 stream,
484 )),
485 Some(Err(_)) => {
487 stream.next().transpose()?;
488 }
489 None => log::warn!("No data found for config: {:?}", data_config.data_type()),
490 }
491 }
492
493 stream_chunks(
494 engine,
495 config,
496 merge_streams(streams)?.peekable(),
497 chunk_size,
498 )
499}
500
501fn merge_streams<I: Iterator<Item = anyhow::Result<ReplayBatch>>>(
504 streams: Vec<(String, I)>,
505) -> anyhow::Result<impl Iterator<Item = anyhow::Result<Data>>> {
506 let mut iterator = DataIterator::new();
507 let (names, mut streams): (Vec<_>, Vec<_>) = streams.into_iter().unzip();
508 for (stream, name) in streams.iter_mut().zip(&names) {
509 if let Some(batch) = stream.next() {
510 iterator.add_batch(name, batch?, true);
511 }
512 }
513
514 let mut refill: Option<usize> = None;
515 let mut failed = false;
516 Ok(std::iter::from_fn(move || {
517 if failed {
518 return None;
519 }
520
521 let load = (|| -> anyhow::Result<()> {
522 if let Some(index) = refill.take() {
523 let name = &names[index];
524 match streams[index].next() {
525 Some(batch) => iterator.refill_batch(name, batch?)?,
526 None => iterator.remove_data(name, true),
527 }
528 }
529
530 Ok(())
531 })();
532
533 if let Err(e) = load {
534 failed = true;
535 return Some(Err(e));
536 }
537
538 let (data, exhausted) = iterator.next_stream_item()?;
539 refill = exhausted.and_then(|name| names.iter().position(|candidate| candidate == name));
540 Some(Ok(data))
541 }))
542}
543
544fn stream_chunks<I: Iterator<Item = anyhow::Result<Data>>>(
548 engine: &mut BacktestEngine,
549 config: &BacktestRunConfig,
550 mut iter: Peekable<I>,
551 chunk_size: usize,
552) -> anyhow::Result<()> {
553 if iter.peek().is_none() {
554 return engine.end();
555 }
556
557 let mut next_start = config.start();
558 let mut started = false;
559
560 loop {
561 let chunk = take_aligned_chunk(&mut iter, chunk_size)
562 .map_err(|e| end_after_load_failure(engine, started, e))?;
563 if chunk.is_empty() {
564 break;
565 }
566
567 let is_last = iter.peek().is_none();
568 let end = if is_last {
569 config.end()
570 } else {
571 chunk.last().map(HasTsInit::ts_init)
572 };
573
574 engine
575 .add_data(chunk, None, false, true)
576 .map_err(|e| end_after_load_failure(engine, started, e))?;
577 engine.run(next_start, end, Some(config.id().to_string()), true)?;
578 engine.clear_data();
579 started = true;
580
581 if engine.kernel().is_shutdown_requested() {
584 return Ok(());
585 }
586
587 next_start = end;
590 }
591
592 engine.end()
593}
594
595fn end_after_load_failure(
598 engine: &mut BacktestEngine,
599 started: bool,
600 error: anyhow::Error,
601) -> anyhow::Error {
602 if started && let Err(e) = engine.end() {
603 log::error!("Failed to end backtest after a data load failure: {e:#}");
604 }
605 error
606}
607
608fn take_aligned_chunk<I: Iterator<Item = anyhow::Result<Data>>>(
611 iter: &mut Peekable<I>,
612 chunk_size: usize,
613) -> anyhow::Result<Vec<Data>> {
614 let mut chunk = Vec::with_capacity(chunk_size);
615
616 for _ in 0..chunk_size {
617 match iter.next() {
618 Some(item) => chunk.push(item?),
619 None => return Ok(chunk),
620 }
621 }
622
623 if let Some(boundary_ts) = chunk.last().map(HasTsInit::ts_init) {
624 while let Some(item) = iter.next_if(|item| {
626 item.as_ref()
627 .is_ok_and(|data| data.ts_init() == boundary_ts)
628 }) {
629 chunk.push(item?);
630 }
631 }
632
633 Ok(chunk)
634}
635
636fn create_catalog(config: &BacktestDataConfig) -> anyhow::Result<DataCatalog> {
637 let catalog_config = DataCatalogConfig::new(
638 config.catalog_path().to_string(),
639 config.catalog_fs_protocol().map(str::to_string),
640 Some(config.catalog_backend()),
641 )
642 .with_storage_options(
643 config
644 .catalog_fs_rust_storage_options()
645 .cloned()
646 .or_else(|| config.catalog_fs_storage_options().cloned()),
647 );
648 catalog_factory::create_catalog(
649 catalog_config.catalog_backend(),
650 &catalog_config.connect_config(),
651 &default_catalog_factories(),
652 )
653}
654
655fn load_data(
656 config: &BacktestDataConfig,
657 run_start: Option<UnixNanos>,
658 run_end: Option<UnixNanos>,
659) -> anyhow::Result<Vec<Data>> {
660 let mut catalog = create_catalog(config)?;
661 let result = dispatch_query(&mut catalog, config, run_start, run_end)?;
662 result.collect::<Result<Vec<_>, _>>()
663}
664
665fn dispatch_query(
666 catalog: &mut DataCatalog,
667 config: &BacktestDataConfig,
668 start: Option<UnixNanos>,
669 end: Option<UnixNanos>,
670) -> anyhow::Result<Box<dyn Iterator<Item = anyhow::Result<Data>>>> {
671 let batches = dispatch_batch_query(catalog, config, start, end)?;
672
673 let rows = batches
674 .map(|batch| batch.map(|batch| batch.to_data_vec_for_compat()))
675 .flat_map(|batch| match batch {
676 Ok(rows) => rows.into_iter().map(Ok).collect::<Vec<_>>(),
677 Err(e) => vec![Err(e)],
678 });
679
680 if config.batch_deltas() && *config.data_type() == NautilusDataType::OrderBookDelta {
681 return Ok(Box::new(BookDeltasBatcher::new(rows)));
682 }
683
684 Ok(Box::new(rows))
685}
686
687fn dispatch_batch_query(
688 catalog: &mut DataCatalog,
689 config: &BacktestDataConfig,
690 start: Option<UnixNanos>,
691 end: Option<UnixNanos>,
692) -> anyhow::Result<impl Iterator<Item = anyhow::Result<DataBatch>> + use<>> {
693 catalog.reset_session();
694 let mut query = CatalogQuery::new(config.data_type().clone())
695 .with_identifiers(config.query_identifiers())
696 .with_range(
697 max_opt(config.start_time(), start),
698 min_opt(config.end_time(), end),
699 )
700 .with_where_clause(config.filter_expr().map(str::to_string));
701 let mut params = Params::new();
702 params.insert(
703 "optimize_file_loading".to_string(),
704 config.optimize_file_loading().into(),
705 );
706 query.params = Some(params);
707 let mut session = catalog.query_batch_session(&query, None)?;
708 let mut failed = false;
709 Ok(std::iter::from_fn(move || {
710 if failed {
711 return None;
712 }
713
714 loop {
715 match session.next_batch() {
716 Ok(Some(batch)) if batch.is_empty() => {}
717 Ok(batch) => return batch.map(Ok),
718 Err(e) => {
719 failed = true;
720 return Some(Err(e));
721 }
722 }
723 }
724 }))
725}
726
727fn max_opt(a: Option<UnixNanos>, b: Option<UnixNanos>) -> Option<UnixNanos> {
728 match (a, b) {
729 (Some(a), Some(b)) => Some(a.max(b)),
730 (Some(a), None) => Some(a),
731 (None, Some(b)) => Some(b),
732 (None, None) => None,
733 }
734}
735
736fn min_opt(a: Option<UnixNanos>, b: Option<UnixNanos>) -> Option<UnixNanos> {
737 match (a, b) {
738 (Some(a), Some(b)) => Some(a.min(b)),
739 (Some(a), None) => Some(a),
740 (None, Some(b)) => Some(b),
741 (None, None) => None,
742 }
743}
744
745struct BookDeltasBatcher<I> {
749 rows: I,
750 pending: AHashMap<InstrumentId, Vec<OrderBookDelta>>,
751 finished: bool,
752}
753
754impl<I> BookDeltasBatcher<I> {
755 fn new(rows: I) -> Self {
756 Self {
757 rows,
758 pending: AHashMap::new(),
759 finished: false,
760 }
761 }
762}
763
764impl<I: Iterator<Item = anyhow::Result<Data>>> Iterator for BookDeltasBatcher<I> {
765 type Item = anyhow::Result<Data>;
766
767 fn next(&mut self) -> Option<Self::Item> {
768 if self.finished {
769 return None;
770 }
771
772 loop {
773 match self.rows.next() {
774 Some(Ok(Data::BookDelta(delta))) => {
775 let group = self.pending.entry(delta.instrument_id).or_default();
776 group.push(delta);
777
778 if RecordFlag::F_LAST.matches(delta.flags) {
779 let deltas = mem::replace(group, Vec::with_capacity(group.len()));
780 let deltas = OrderBookDeltas::new_checked(delta.instrument_id, deltas);
781 return Some(deltas.map(|deltas| Data::BookDeltas(Box::new(deltas))));
782 }
783 }
784 Some(item) => {
785 if item.is_err() {
787 self.finished = true;
788 }
789 return Some(item);
790 }
791 None => {
792 self.finished = true;
793 return unterminated_deltas_error(&self.pending).map(Err);
794 }
795 }
796 }
797 }
798}
799
800fn unterminated_deltas_error(
801 pending: &AHashMap<InstrumentId, Vec<OrderBookDelta>>,
802) -> Option<anyhow::Error> {
803 let mut groups: Vec<_> = pending
804 .iter()
805 .filter_map(|(instrument_id, group)| {
806 let first = group.first()?.ts_init;
807 let last = group.last()?.ts_init;
808 Some((*instrument_id, group.len(), first, last))
809 })
810 .collect();
811
812 if groups.is_empty() {
813 return None;
814 }
815
816 groups.sort_unstable_by_key(|(instrument_id, ..)| *instrument_id);
817 let details = groups
818 .iter()
819 .map(|(instrument_id, count, first, last)| {
820 format!("{instrument_id} ({count} pending, ts_init {first} to {last})")
821 })
822 .collect::<Vec<_>>()
823 .join(", ");
824
825 Some(anyhow::anyhow!(
826 "Order book deltas end without an `F_LAST` delta for {details}; \
827 set `batch_deltas` to false to replay individual deltas"
828 ))
829}
830
831#[cfg(test)]
832mod tests {
833 use nautilus_common::component::Component;
834 use nautilus_execution::models::fee::{FeeModelAny, MakerTakerFeeModel};
835 #[cfg(feature = "defi")]
836 use nautilus_model::defi::{
837 DefiData,
838 data::block::BlockPosition,
839 pool_analysis::snapshot::{PoolAnalytics, PoolSnapshot, PoolState},
840 };
841 use nautilus_model::{
842 data::{BookOrder, QuoteTick, TradeTick},
843 enums::{AccountType, AggressorSide, BookAction, OmsType, OrderSide},
844 identifiers::{InstrumentId, TradeId},
845 types::{Price, Quantity},
846 };
847 #[cfg(feature = "python")]
848 use pyo3::{ffi::c_str, prelude::*, types::PyDict};
849 use rstest::rstest;
850
851 use super::*;
852 use crate::{
853 config::{
854 BacktestEngineConfig, BacktestVenueConfig, MAX_BACKTEST_CHUNK_SIZE,
855 SimulatedVenueConfig,
856 },
857 modules::{
858 AccountAdjustmentOutcome, ExchangeContext, SimulationModule, SimulationModuleHandle,
859 SimulationModuleResult,
860 },
861 };
862 #[cfg(feature = "python")]
863 use crate::{
864 modules::SimulationModuleAny,
865 python::modules::{PySimulationModule, PythonSimulationModule},
866 };
867
868 fn quote(ts_init: u64) -> Data {
869 Data::Quote(QuoteTick::new(
870 InstrumentId::from("EUR/USD.SIM"),
871 Price::from("1.0001"),
872 Price::from("1.0002"),
873 Quantity::from("100"),
874 Quantity::from("100"),
875 UnixNanos::from(ts_init),
876 UnixNanos::from(ts_init),
877 ))
878 }
879
880 fn trade(ts_init: u64) -> Data {
881 Data::Trade(TradeTick::new(
882 InstrumentId::from("EUR/USD.SIM"),
883 Price::from("1.0001"),
884 Quantity::from("100"),
885 AggressorSide::Buy,
886 TradeId::from("T-1"),
887 UnixNanos::from(ts_init),
888 UnixNanos::from(ts_init),
889 ))
890 }
891
892 fn stream_failure() -> anyhow::Error {
893 anyhow::anyhow!("injected stream failure")
894 }
895
896 fn row_batch(data: &Data) -> ReplayBatch {
897 let batch = match data {
898 Data::Quote(quote) => DataBatch::Quote(vec![*quote].into()),
899 Data::Trade(trade) => DataBatch::Trade(vec![*trade].into()),
900 _ => panic!("expected a quote or trade fixture"),
901 };
902
903 ReplayBatch::Typed(batch)
904 }
905
906 fn merge_rows<I: Iterator<Item = anyhow::Result<Data>>>(
907 streams: Vec<Peekable<I>>,
908 ) -> impl Iterator<Item = anyhow::Result<Data>> {
909 let batches = streams
910 .into_iter()
911 .map(|stream| stream.map(|item| item.map(|data| row_batch(&data))))
912 .collect();
913 merge_batches(batches).expect("initial batches must load")
914 }
915
916 fn merge_batches<I: Iterator<Item = anyhow::Result<ReplayBatch>>>(
917 streams: Vec<I>,
918 ) -> anyhow::Result<impl Iterator<Item = anyhow::Result<Data>>> {
919 merge_streams(
920 streams
921 .into_iter()
922 .enumerate()
923 .map(|(index, stream)| (index.to_string(), stream))
924 .collect(),
925 )
926 }
927
928 #[derive(Debug)]
929 struct FailingDiagnosticsModule;
930
931 impl SimulationModule for FailingDiagnosticsModule {
932 fn pre_process(&self, _data: &Data) -> anyhow::Result<()> {
933 Ok(())
934 }
935
936 fn process(
937 &self,
938 _ts_now: UnixNanos,
939 _ctx: &ExchangeContext,
940 ) -> anyhow::Result<SimulationModuleResult> {
941 Ok(SimulationModuleResult::NotReady)
942 }
943
944 fn acknowledge(&self, _outcomes: &[AccountAdjustmentOutcome]) -> anyhow::Result<()> {
945 Ok(())
946 }
947
948 fn log_diagnostics(&self) -> anyhow::Result<()> {
949 anyhow::bail!("diagnostics failure")
950 }
951
952 fn reset(&self) -> anyhow::Result<()> {
953 Ok(())
954 }
955 }
956
957 fn book_delta(instrument_id: &str, flags: u8, sequence: u64, ts_init: u64) -> OrderBookDelta {
958 OrderBookDelta::new(
959 InstrumentId::from(instrument_id),
960 BookAction::Add,
961 BookOrder::new(
962 OrderSide::Buy,
963 Price::from("1.0000"),
964 Quantity::from("100"),
965 sequence,
966 ),
967 flags,
968 sequence,
969 UnixNanos::from(ts_init - 1),
970 UnixNanos::from(ts_init),
971 )
972 }
973
974 fn batch_book_deltas(deltas: &[OrderBookDelta]) -> Vec<Data> {
975 BookDeltasBatcher::new(deltas.iter().map(|delta| Ok(Data::BookDelta(*delta))))
976 .collect::<anyhow::Result<_>>()
977 .expect("the batcher must not fail")
978 }
979
980 type BookDeltasFields = (
981 InstrumentId,
982 Vec<OrderBookDelta>,
983 u8,
984 u64,
985 UnixNanos,
986 UnixNanos,
987 );
988
989 fn book_deltas_fields(data: &Data) -> BookDeltasFields {
991 let Data::BookDeltas(deltas) = data else {
992 panic!("expected `OrderBookDeltas`, was {data:?}");
993 };
994 (
995 deltas.instrument_id,
996 deltas.deltas.clone(),
997 deltas.flags,
998 deltas.sequence,
999 deltas.ts_event,
1000 deltas.ts_init,
1001 )
1002 }
1003
1004 #[rstest]
1005 fn merge_streams_orders_items_across_streams_by_ts_init() {
1006 let streams = vec![
1007 vec![Ok(quote(1)), Ok(quote(3)), Ok(quote(3))]
1008 .into_iter()
1009 .peekable(),
1010 vec![Ok(trade(2)), Ok(trade(3))].into_iter().peekable(),
1011 vec![].into_iter().peekable(),
1012 ];
1013
1014 let merged: Vec<(u64, bool)> = merge_rows(streams)
1015 .map(|item| item.expect("the merged stream must not fail"))
1016 .map(|data| (data.ts_init().as_u64(), matches!(data, Data::Trade(_))))
1017 .collect();
1018
1019 assert_eq!(
1020 merged,
1021 vec![(1, false), (2, true), (3, false), (3, false), (3, true)]
1022 );
1023 }
1024
1025 #[rstest]
1026 fn merge_streams_leaves_its_streams_undrained() {
1027 let ok_quote: fn(u64) -> anyhow::Result<Data> = |ts_init| Ok(quote(ts_init));
1029 let evens = (0u64..).step_by(2).map(ok_quote);
1030 let odds = (1u64..).step_by(2).map(ok_quote);
1031
1032 let merged: Vec<u64> = merge_rows(vec![evens.peekable(), odds.peekable()])
1033 .take(4)
1034 .map(|item| item.expect("the merged stream must not fail").ts_init())
1035 .map(|ts_init| ts_init.as_u64())
1036 .collect();
1037
1038 assert_eq!(merged, vec![0, 1, 2, 3]);
1039 }
1040
1041 #[rstest]
1042 fn merge_streams_reports_a_stream_failure() {
1043 let streams = vec![
1044 vec![Ok(quote(1)), Err(stream_failure())]
1045 .into_iter()
1046 .peekable(),
1047 vec![Ok(quote(2))].into_iter().peekable(),
1048 ];
1049 let mut merged = merge_rows(streams);
1050
1051 let first = merged.next().expect("the first item must be present");
1052 let second = merged.next().expect("the failure must be yielded");
1053
1054 assert_eq!(
1055 first.expect("the first item must not fail").ts_init(),
1056 UnixNanos::from(1)
1057 );
1058 assert_eq!(
1059 second
1060 .expect_err("a failed stream must not read as exhaustion")
1061 .to_string(),
1062 "injected stream failure"
1063 );
1064 }
1065
1066 #[rstest]
1067 fn merge_streams_reports_initial_failure_before_consumption() {
1068 let streams = vec![vec![Err(stream_failure())].into_iter()];
1069
1070 let Err(failure) = merge_batches(streams) else {
1071 panic!("initial source failure must be reported");
1072 };
1073
1074 assert_eq!(failure.to_string(), "injected stream failure");
1075 }
1076
1077 #[rstest]
1078 fn merge_streams_keeps_config_order_across_typed_batch_refills() {
1079 let batch = |times: &[u64]| {
1080 let quotes = times
1081 .iter()
1082 .map(|&ts| QuoteTick {
1083 ts_init: UnixNanos::from(ts),
1084 ..QuoteTick::default()
1085 })
1086 .collect::<Vec<_>>();
1087
1088 Ok(ReplayBatch::Typed(DataBatch::Quote(quotes.into())))
1089 };
1090
1091 let streams = vec![
1092 vec![batch(&[1, 3]), batch(&[3, 4])].into_iter(),
1093 vec![Ok(row_batch(&trade(2))), Ok(row_batch(&trade(3)))].into_iter(),
1094 ];
1095
1096 let merged: Vec<_> = merge_batches(streams)
1097 .unwrap()
1098 .map(|item| {
1099 let data = item.unwrap();
1100 (data.ts_init().as_u64(), matches!(data, Data::Trade(_)))
1101 })
1102 .collect();
1103
1104 assert_eq!(
1105 merged,
1106 vec![
1107 (1, false),
1108 (2, true),
1109 (3, false),
1110 (3, false),
1111 (3, true),
1112 (4, false)
1113 ]
1114 );
1115 }
1116
1117 #[cfg(feature = "defi")]
1118 #[rstest]
1119 fn merge_streams_orders_full_width_defi_positions_before_config_priority() {
1120 let batch = |id: &str, block, transaction_index, log_index| {
1121 let position =
1122 BlockPosition::new(block, "0xabc".to_string(), transaction_index, log_index);
1123
1124 let snapshot = PoolSnapshot::new(
1125 InstrumentId::from(id),
1126 PoolState::default(),
1127 Vec::new(),
1128 Vec::new(),
1129 PoolAnalytics::default(),
1130 position,
1131 UnixNanos::from(10),
1132 UnixNanos::from(10),
1133 );
1134 let data = vec![DefiData::PoolSnapshot(snapshot)];
1135 ReplayBatch::Typed(DataBatch::Defi(data.into()))
1136 };
1137
1138 let streams = vec![
1139 vec![Ok(batch("A.B", u64::MAX, u32::MAX, u32::MAX))].into_iter(),
1140 vec![
1141 Ok(batch("C.D", u64::MAX - 1, u32::MAX, u32::MAX)),
1142 Ok(batch("C.D", u64::MAX, u32::MAX - 1, u32::MAX)),
1143 Ok(batch("C.D", u64::MAX, u32::MAX, u32::MAX - 1)),
1144 ]
1145 .into_iter(),
1146 ];
1147
1148 let merged: Vec<_> = merge_batches(streams)
1149 .unwrap()
1150 .map(|item| {
1151 let data = item.unwrap();
1152
1153 let Data::Defi(defi) = data else {
1154 panic!("expected DeFi data");
1155 };
1156
1157 (defi.instrument_id(), defi.ts_init(), defi.block_position())
1158 })
1159 .collect();
1160
1161 assert_eq!(
1162 merged,
1163 vec![
1164 (
1165 InstrumentId::from("C.D"),
1166 UnixNanos::from(10),
1167 (u64::MAX - 1, u32::MAX, u32::MAX)
1168 ),
1169 (
1170 InstrumentId::from("C.D"),
1171 UnixNanos::from(10),
1172 (u64::MAX, u32::MAX - 1, u32::MAX)
1173 ),
1174 (
1175 InstrumentId::from("C.D"),
1176 UnixNanos::from(10),
1177 (u64::MAX, u32::MAX, u32::MAX - 1)
1178 ),
1179 (
1180 InstrumentId::from("A.B"),
1181 UnixNanos::from(10),
1182 (u64::MAX, u32::MAX, u32::MAX)
1183 ),
1184 ]
1185 );
1186 }
1187
1188 #[rstest]
1189 fn merge_streams_returns_selected_row_before_a_refill_failure() {
1190 let streams = vec![vec![Ok(row_batch("e(1))), Err(stream_failure())].into_iter()];
1191 let mut merged = merge_batches(streams).unwrap();
1192 let first = merged.next().unwrap().unwrap();
1193 let failure = merged.next().unwrap().unwrap_err();
1194
1195 assert_eq!(first, quote(1));
1196 assert_eq!(failure.to_string(), "injected stream failure");
1197 assert!(merged.next().is_none());
1198 }
1199
1200 #[rstest]
1201 fn merge_streams_rejects_a_backward_source_before_emitting_other_streams() {
1202 let streams = vec![
1203 vec![Ok(row_batch("e(3))), Ok(row_batch("e(2)))].into_iter(),
1204 vec![Ok(row_batch(&trade(4)))].into_iter(),
1205 ];
1206 let mut merged = merge_batches(streams).unwrap();
1207 let first = merged.next().unwrap().unwrap();
1208 let failure = merged.next().unwrap().unwrap_err();
1209
1210 assert_eq!(first, quote(3));
1211 assert!(
1212 failure
1213 .to_string()
1214 .contains("moved backward across batches")
1215 );
1216 assert!(merged.next().is_none());
1217 }
1218
1219 #[rstest]
1220 #[case(false)]
1221 #[case(true)]
1222 fn merge_streams_reports_original_config_after_empty_sources(#[case] include_empty: bool) {
1223 let mut streams = Vec::new();
1224 if include_empty {
1225 streams.push(("config 0 (TradeTick)".to_string(), Vec::new().into_iter()));
1226 }
1227
1228 streams.push((
1229 "config 3 (QuoteTick)".to_string(),
1230 vec![Ok(row_batch("e(3))), Ok(row_batch("e(2)))].into_iter(),
1231 ));
1232 let mut merged = merge_streams(streams).unwrap();
1233 let first = merged.next().unwrap().unwrap();
1234 let failure = merged.next().unwrap().unwrap_err();
1235
1236 assert_eq!(first, quote(3));
1237 assert!(
1238 failure
1239 .to_string()
1240 .starts_with("Replay stream 'config 3 (QuoteTick)' moved backward across batches:")
1241 );
1242 assert!(merged.next().is_none());
1243 }
1244
1245 #[rstest]
1246 fn take_aligned_chunk_reports_a_stream_failure() {
1247 let mut iter = vec![Ok(quote(1)), Err(stream_failure())]
1248 .into_iter()
1249 .peekable();
1250
1251 let chunk = take_aligned_chunk(&mut iter, 4);
1252
1253 assert_eq!(
1254 chunk
1255 .expect_err("a failed stream must not read as a short chunk")
1256 .to_string(),
1257 "injected stream failure"
1258 );
1259 }
1260
1261 #[rstest]
1262 fn take_aligned_chunk_reports_a_failure_found_at_the_boundary() {
1263 let mut iter = vec![Ok(quote(1)), Err(stream_failure()), Ok(quote(1))]
1264 .into_iter()
1265 .peekable();
1266
1267 let first = take_aligned_chunk(&mut iter, 1).expect("the first chunk must be complete");
1268 let second = take_aligned_chunk(&mut iter, 1);
1269
1270 assert_eq!(first.len(), 1);
1271 assert_eq!(first[0].ts_init(), UnixNanos::from(1));
1272 assert_eq!(
1273 second
1274 .expect_err("the failure must survive the boundary extension")
1275 .to_string(),
1276 "injected stream failure"
1277 );
1278 }
1279
1280 #[rstest]
1281 fn take_aligned_chunk_extends_past_the_boundary_for_equal_timestamps() {
1282 let mut iter = vec![Ok(quote(1)), Ok(quote(1)), Ok(quote(2))]
1283 .into_iter()
1284 .peekable();
1285
1286 let chunk = take_aligned_chunk(&mut iter, 1).expect("the chunk must be complete");
1287
1288 assert_eq!(chunk.len(), 2);
1289 assert_eq!(chunk[0].ts_init(), UnixNanos::from(1));
1290 assert_eq!(chunk[1].ts_init(), UnixNanos::from(1));
1291 }
1292
1293 #[rstest]
1294 fn take_aligned_chunk_reserves_maximum_supported_capacity() {
1295 let mut iter = vec![Ok(quote(1))].into_iter().peekable();
1296
1297 let chunk = take_aligned_chunk(&mut iter, MAX_BACKTEST_CHUNK_SIZE).unwrap();
1298
1299 assert_eq!(chunk.len(), 1);
1300 assert!(chunk.capacity() >= MAX_BACKTEST_CHUNK_SIZE);
1301 assert_eq!(chunk[0].ts_init(), UnixNanos::from(1));
1302 }
1303
1304 #[rstest]
1305 fn book_deltas_batcher_closes_groups_only_on_f_last() {
1306 let last = RecordFlag::F_LAST as u8;
1307 let d1 = book_delta("EUR/USD.SIM", 0, 1, 10);
1308 let d2 = book_delta("EUR/USD.SIM", last, 2, 10);
1309 let d3 = book_delta("EUR/USD.SIM", last, 3, 10);
1310 let d4 = book_delta("EUR/USD.SIM", 0, 4, 20);
1311 let d5 = book_delta("EUR/USD.SIM", last, 5, 30);
1312
1313 let batches = batch_book_deltas(&[d1, d2, d3, d4, d5]);
1314
1315 let instrument_id = InstrumentId::from("EUR/USD.SIM");
1317 assert_eq!(
1318 batches.iter().map(book_deltas_fields).collect::<Vec<_>>(),
1319 vec![
1320 (
1321 instrument_id,
1322 vec![d1, d2],
1323 last,
1324 2,
1325 UnixNanos::from(9),
1326 UnixNanos::from(10),
1327 ),
1328 (
1329 instrument_id,
1330 vec![d3],
1331 last,
1332 3,
1333 UnixNanos::from(9),
1334 UnixNanos::from(10),
1335 ),
1336 (
1337 instrument_id,
1338 vec![d4, d5],
1339 last,
1340 5,
1341 UnixNanos::from(29),
1342 UnixNanos::from(30),
1343 ),
1344 ]
1345 );
1346 }
1347
1348 #[rstest]
1349 fn book_deltas_batcher_keeps_interleaved_instruments_apart() {
1350 let last = RecordFlag::F_LAST as u8;
1351 let aud1 = book_delta("AUD/USD.SIM", 0, 1, 10);
1352 let eur1 = book_delta("EUR/USD.SIM", 0, 2, 10);
1353 let eur2 = book_delta("EUR/USD.SIM", last, 3, 10);
1354 let aud2 = book_delta("AUD/USD.SIM", last, 4, 20);
1355
1356 let batches = batch_book_deltas(&[aud1, eur1, eur2, aud2]);
1357
1358 assert_eq!(
1360 batches.iter().map(book_deltas_fields).collect::<Vec<_>>(),
1361 vec![
1362 (
1363 InstrumentId::from("EUR/USD.SIM"),
1364 vec![eur1, eur2],
1365 last,
1366 3,
1367 UnixNanos::from(9),
1368 UnixNanos::from(10),
1369 ),
1370 (
1371 InstrumentId::from("AUD/USD.SIM"),
1372 vec![aud1, aud2],
1373 last,
1374 4,
1375 UnixNanos::from(19),
1376 UnixNanos::from(20),
1377 ),
1378 ]
1379 );
1380 }
1381
1382 #[rstest]
1383 fn book_deltas_batcher_takes_flags_from_closing_delta() {
1384 let instrument_id = InstrumentId::from("EUR/USD.SIM");
1385 let clear =
1386 OrderBookDelta::clear(instrument_id, 1, UnixNanos::from(9), UnixNanos::from(10));
1387 let add = book_delta("EUR/USD.SIM", RecordFlag::F_MBP as u8, 2, 10);
1388 let close = book_delta("EUR/USD.SIM", RecordFlag::F_LAST as u8, 3, 10);
1389
1390 let batches = batch_book_deltas(&[clear, add, close]);
1391
1392 assert_eq!(
1394 batches.iter().map(book_deltas_fields).collect::<Vec<_>>(),
1395 vec![(
1396 instrument_id,
1397 vec![clear, add, close],
1398 RecordFlag::F_LAST as u8,
1399 3,
1400 UnixNanos::from(9),
1401 UnixNanos::from(10),
1402 )]
1403 );
1404 }
1405
1406 #[rstest]
1407 fn book_deltas_batcher_yields_nothing_for_empty_input() {
1408 let batches = batch_book_deltas(&[]);
1409
1410 assert!(batches.is_empty());
1411 }
1412
1413 #[rstest]
1414 fn book_deltas_batcher_rejects_deltas_left_without_f_last() {
1415 let last = RecordFlag::F_LAST as u8;
1416 let d1 = book_delta("EUR/USD.SIM", last, 1, 10);
1417 let d2 = book_delta("EUR/USD.SIM", 0, 2, 20);
1418 let d3 = book_delta("EUR/USD.SIM", 0, 3, 30);
1419 let d4 = book_delta("AUD/USD.SIM", 0, 4, 40);
1420 let rows = [d1, d2, d3, d4].map(|delta| Ok(Data::BookDelta(delta)));
1421 let mut batcher = BookDeltasBatcher::new(rows.into_iter());
1422
1423 let first = batcher.next().expect("the complete event must be yielded");
1424 let second = batcher.next().expect("the pending deltas must be reported");
1425 let third = batcher.next();
1426
1427 assert_eq!(
1428 book_deltas_fields(&first.expect("the complete event must not fail")),
1429 (
1430 InstrumentId::from("EUR/USD.SIM"),
1431 vec![d1],
1432 last,
1433 1,
1434 UnixNanos::from(9),
1435 UnixNanos::from(10),
1436 )
1437 );
1438 assert_eq!(
1439 second
1440 .expect_err("pending deltas must not replay as an event")
1441 .to_string(),
1442 "Order book deltas end without an `F_LAST` delta for \
1443 AUD/USD.SIM (1 pending, ts_init 40 to 40), \
1444 EUR/USD.SIM (2 pending, ts_init 20 to 30); \
1445 set `batch_deltas` to false to replay individual deltas"
1446 );
1447 assert!(third.is_none());
1448 }
1449
1450 #[rstest]
1451 fn book_deltas_batcher_reports_a_query_failure_without_flushing() {
1452 let pending = book_delta("EUR/USD.SIM", 0, 1, 10);
1453 let after_failure = book_delta("EUR/USD.SIM", RecordFlag::F_LAST as u8, 2, 20);
1454 let rows = vec![
1455 Ok(Data::BookDelta(pending)),
1456 Err(stream_failure()),
1457 Ok(Data::BookDelta(after_failure)),
1458 ];
1459
1460 let items: Vec<anyhow::Result<Data>> = BookDeltasBatcher::new(rows.into_iter()).collect();
1461
1462 assert_eq!(items.len(), 1);
1463 assert_eq!(
1464 items[0]
1465 .as_ref()
1466 .expect_err("the query failure must be yielded")
1467 .to_string(),
1468 "injected stream failure"
1469 );
1470 }
1471
1472 #[rstest]
1473 fn end_after_load_failure_reports_the_load_failure_when_ending_fails() {
1474 let mut engine = BacktestEngine::new(BacktestEngineConfig::default()).unwrap();
1475 let venue = SimulatedVenueConfig::builder()
1476 .venue(Venue::from("SIM"))
1477 .oms_type(OmsType::Netting)
1478 .account_type(AccountType::Margin)
1479 .book_type(BookType::L1_MBP)
1480 .starting_balances(vec![Money::from("1_000_000 USD")])
1481 .modules(vec![SimulationModuleHandle::new(FailingDiagnosticsModule)])
1482 .fee_model(FeeModelAny::MakerTaker(MakerTakerFeeModel::zero()).into())
1483 .build()
1484 .unwrap();
1485 engine.add_venue(venue).unwrap();
1486 engine.run(None, None, None, true).unwrap();
1487
1488 let error = end_after_load_failure(&mut engine, true, stream_failure());
1489
1490 assert_eq!(error.to_string(), "injected stream failure");
1492 assert!(engine.kernel().trader.borrow().is_stopped());
1493 assert!(engine.get_result().run_finished.is_some());
1494 }
1495
1496 #[cfg(feature = "python")]
1497 #[rstest]
1498 fn build_engine_accepts_python_module_from_node_config() {
1499 Python::initialize();
1500
1501 Python::attach(|py| {
1502 let locals = PyDict::new(py);
1503 locals
1504 .set_item("SimulationModule", py.get_type::<PySimulationModule>())
1505 .unwrap();
1506 let module = py
1507 .eval(
1508 c_str!(
1509 "type('NodeSimulationModule', (SimulationModule,), {\
1510 'process': lambda self, ts_now, context: \
1511 (setattr(self, 'calls', self.calls + 1), [])[1]\
1512 })()"
1513 ),
1514 None,
1515 Some(&locals),
1516 )
1517 .unwrap();
1518 module.setattr("calls", 0).unwrap();
1519
1520 let venue = BacktestVenueConfig::builder()
1521 .name("SIM")
1522 .oms_type(OmsType::Netting)
1523 .account_type(AccountType::Margin)
1524 .book_type(BookType::L1_MBP)
1525 .starting_balances(vec!["1000 USD".to_string()])
1526 .fee_model(FeeModelAny::MakerTaker(MakerTakerFeeModel::zero()))
1527 .modules(vec![SimulationModuleAny::Python(
1528 PythonSimulationModule::new(module.clone().unbind()),
1529 )])
1530 .build()
1531 .unwrap();
1532 let config = BacktestRunConfig::builder()
1533 .venues(vec![venue])
1534 .data(Vec::new())
1535 .build()
1536 .unwrap();
1537 let mut engine = build_engine(&config).unwrap();
1538
1539 engine.run(None, None, None, false).unwrap();
1540
1541 assert_eq!(
1542 module.getattr("calls").unwrap().extract::<u32>().unwrap(),
1543 1
1544 );
1545 });
1546 }
1547
1548 #[rstest]
1549 fn test_build_engine_rejects_venue_without_fee_model() {
1550 let venue = BacktestVenueConfig::builder()
1551 .name("SIM")
1552 .oms_type(OmsType::Netting)
1553 .account_type(AccountType::Margin)
1554 .book_type(BookType::L1_MBP)
1555 .starting_balances(vec!["1_000_000 USD".to_string()])
1556 .build()
1557 .unwrap();
1558 let config = BacktestRunConfig::builder()
1559 .venues(vec![venue])
1560 .data(Vec::new())
1561 .build()
1562 .unwrap();
1563 let err = build_engine(&config).unwrap_err();
1564 assert!(
1565 err.to_string().contains("explicit fee_model"),
1566 "unexpected error: {err}"
1567 );
1568 }
1569}