Skip to main content

nautilus_backtest/
node.rs

1// -------------------------------------------------------------------------------------------------
2//  Copyright (C) 2015-2026 Nautech Systems Pty Ltd. All rights reserved.
3//  https://nautechsystems.io
4//
5//  Licensed under the GNU Lesser General Public License Version 3.0 (the "License");
6//  You may not use this file except in compliance with the License.
7//  You may obtain a copy of the License at https://www.gnu.org/licenses/lgpl-3.0.en.html
8//
9//  Unless required by applicable law or agreed to in writing, software
10//  distributed under the License is distributed on an "AS IS" BASIS,
11//  WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12//  See the License for the specific language governing permissions and
13//  limitations under the License.
14// -------------------------------------------------------------------------------------------------
15
16//! Provides a [`BacktestNode`] that orchestrates catalog-driven backtests.
17
18use 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/// Orchestrates catalog-driven backtests from run configurations.
44///
45/// `BacktestNode` connects the catalog with [`BacktestEngine`] to load
46/// historical data and run backtests. Supports both oneshot and streaming modes.
47#[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    /// Creates a new [`BacktestNode`] instance.
64    ///
65    /// Validates that configs are non-empty and internally consistent:
66    /// - All data config instrument venues must have a matching venue config.
67    /// - L2/L3 book types require order book data in the data configs.
68    /// - Data config time ranges must be valid (start <= end).
69    ///
70    /// # Errors
71    ///
72    /// Returns an error if `configs` is empty or validation fails.
73    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    /// Returns the run configurations.
84    #[must_use]
85    pub fn configs(&self) -> &[BacktestRunConfig] {
86        &self.configs
87    }
88
89    /// Builds backtest engines from the run configurations.
90    ///
91    /// For each config, creates a [`BacktestEngine`], adds venues, and loads
92    /// instruments from the catalog. If building a config fails with
93    /// [`BacktestRunConfig::raise_exception`] disabled, logs the error and skips that config;
94    /// successful return does not guarantee an engine for every config.
95    /// A disposed node cannot be built again; create a new node instead.
96    ///
97    /// # Errors
98    ///
99    /// Returns an error if:
100    /// - This node has been disposed.
101    /// - Building an engine from a config fails and
102    ///   [`BacktestRunConfig::raise_exception`] is enabled for that config.
103    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    /// Returns a mutable reference to the engine for the given run config ID.
126    #[must_use]
127    pub fn get_engine_mut(&mut self, id: &str) -> Option<&mut BacktestEngine> {
128        self.engines.get_mut(id)
129    }
130
131    /// Returns a reference to the engine for the given run config ID.
132    #[must_use]
133    pub fn get_engine(&self, id: &str) -> Option<&BacktestEngine> {
134        self.engines.get(id)
135    }
136
137    /// Returns all created backtest engines.
138    #[must_use]
139    pub fn get_engines(&self) -> Vec<&BacktestEngine> {
140        self.engines.values().collect()
141    }
142
143    /// Runs all configured backtests and returns results.
144    ///
145    /// Automatically calls [`build()`](Self::build) if engines have not been created yet.
146    /// For each run config, loads data from the catalog and runs the engine.
147    /// Supports both oneshot (`chunk_size = None`) and streaming modes.
148    /// Configs without a built engine are skipped. If a run fails with
149    /// [`BacktestRunConfig::raise_exception`] disabled, logs the error, clears its loaded data,
150    /// leaves the engine undisposed, and omits its result. A streaming run that fails to load
151    /// data after replaying earlier chunks ends its engine first, stopping the trader and engines.
152    /// A node disposed by a completed run or by [`dispose()`](Self::dispose)
153    /// cannot run again; create a new node instead.
154    ///
155    /// # Errors
156    ///
157    /// Returns an error if:
158    /// - This node has been disposed.
159    /// - Building, data loading, or engine execution fails and
160    ///   [`BacktestRunConfig::raise_exception`] is enabled for the run config.
161    pub fn run(&mut self) -> anyhow::Result<Vec<BacktestResult>> {
162        self.ensure_not_disposed()?;
163
164        // Auto-build if not already done
165        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    /// Creates a catalog from a data config.
205    ///
206    /// # Errors
207    ///
208    /// Returns an error if the catalog cannot be created from the URI.
209    pub fn load_catalog(config: &BacktestDataConfig) -> anyhow::Result<DataCatalog> {
210        create_catalog(config)
211    }
212
213    /// Loads data from the catalog for a specific data config.
214    ///
215    /// # Errors
216    ///
217    /// Returns an error if catalog creation or data querying fails.
218    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    /// Disposes all engines and releases resources.
227    /// Subsequent calls to [`run()`](Self::run) or [`build()`](Self::build)
228    /// return an error; create a new node to run again.
229    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    // Kernel initialization sets a thread-local MessageBus that can only be
357    // initialized once per thread, so multiple engines cannot coexist
358    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                    // Unfiltered config (no instrument filter) covers all venues
415                    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    // Equal timestamps and incomplete book frames can extend beyond the target chunk size
456    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            // Surface a failed query in config order, before opening later ones
488            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
545// Feeds data from an iterator to the engine in timestamp-aligned chunks.
546// Each chunk contains up to `chunk_size` events, extended to include all
547// events sharing the boundary timestamp so timers flush correctly.
548fn 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        // A shutdown request during the chunk already triggered end() inside
583        // engine.run(); stop loading further chunks so later data is not processed
584        if engine.kernel().is_shutdown_requested() {
585            return Ok(());
586        }
587
588        // Carry forward the end timestamp so the next chunk's run_impl
589        // sets clocks contiguously and processes gap timers correctly
590        next_start = end;
591    }
592
593    engine.end()
594}
595
596// Ends an engine that earlier chunks started, so a failure to load later data still stops the
597// trader and engines; the load failure stays the reported error
598fn 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
609// Takes up to `chunk_size` items, then extends to include all remaining
610// items sharing the boundary timestamp to avoid splitting same-ts events.
611fn 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        // A failing item ends the extension and surfaces on the next chunk
626        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
739// Regroups flat catalog deltas into `OrderBookDeltas` closed by `F_LAST`, so the engine applies
740// each book event at once. Each instrument keeps its own pending group because a multi-instrument
741// query interleaves rows by `ts_init`; a group replays at its closing delta, preserving that order.
742struct 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                    // A failed query must not flush its pending groups as complete events
780                    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    // Lists every field because `OrderBookDeltas` equality compares only instrument and sequence
984    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        // Unbounded streams, so a merge that materialized its input would never return
1022        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(&quote(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(&quote(3))), Ok(row_batch(&quote(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(&quote(3))), Ok(row_batch(&quote(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        // Same-timestamp events stay apart, and an event spanning timestamps replays at its close
1310        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        // The completed EUR/USD event is not held behind the pending AUD/USD event
1353        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        // A snapshot clear does not close the event, and earlier flags are not merged in
1387        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        // Ending fails on the module diagnostics after the trader stops
1485        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}