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