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