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;
19
20use ahash::{AHashMap, AHashSet};
21use nautilus_core::UnixNanos;
22use nautilus_model::{
23    data::{
24        Bar, Data, FundingRateUpdate, HasTsInit, IndexPriceUpdate, InstrumentClose,
25        InstrumentStatus, MarkPriceUpdate, OptionGreeks, OrderBookDelta, OrderBookDepth10,
26        QuoteTick, TradeTick,
27    },
28    enums::{BookType, OtoTriggerMode},
29    identifiers::{InstrumentId, Venue},
30    types::Money,
31};
32use nautilus_persistence::backend::{catalog::ParquetDataCatalog, session::QueryResult};
33
34use crate::{
35    config::{BacktestDataConfig, BacktestRunConfig, NautilusDataType, SimulatedVenueConfig},
36    engine::BacktestEngine,
37    result::BacktestResult,
38};
39
40/// Orchestrates catalog-driven backtests from run configurations.
41///
42/// `BacktestNode` connects the [`ParquetDataCatalog`] with [`BacktestEngine`] to load
43/// historical data and run backtests. Supports both oneshot and streaming modes.
44#[derive(Debug)]
45#[cfg_attr(
46    feature = "python",
47    pyo3::pyclass(module = "nautilus_trader.backtest", unsendable)
48)]
49#[cfg_attr(
50    feature = "python",
51    pyo3_stub_gen::derive::gen_stub_pyclass(module = "nautilus_trader.backtest")
52)]
53pub struct BacktestNode {
54    configs: Vec<BacktestRunConfig>,
55    engines: AHashMap<String, BacktestEngine>,
56}
57
58impl BacktestNode {
59    /// Creates a new [`BacktestNode`] instance.
60    ///
61    /// Validates that configs are non-empty and internally consistent:
62    /// - All data config instrument venues must have a matching venue config.
63    /// - L2/L3 book types require order book data in the data configs.
64    /// - Data config time ranges must be valid (start <= end).
65    ///
66    /// # Errors
67    ///
68    /// Returns an error if `configs` is empty or validation fails.
69    pub fn new(configs: Vec<BacktestRunConfig>) -> anyhow::Result<Self> {
70        anyhow::ensure!(!configs.is_empty(), "At least one run config is required");
71        validate_configs(&configs)?;
72        Ok(Self {
73            configs,
74            engines: AHashMap::new(),
75        })
76    }
77
78    /// Returns the run configurations.
79    #[must_use]
80    pub fn configs(&self) -> &[BacktestRunConfig] {
81        &self.configs
82    }
83
84    /// Builds backtest engines from the run configurations.
85    ///
86    /// For each config, creates a [`BacktestEngine`], adds venues, and loads
87    /// instruments from the catalog. If building a config fails with
88    /// [`BacktestRunConfig::raise_exception`] disabled, logs the error and skips that config;
89    /// successful return does not guarantee an engine for every config.
90    ///
91    /// # Errors
92    ///
93    /// Returns an error if building an engine from a config fails and
94    /// [`BacktestRunConfig::raise_exception`] is enabled for that config.
95    pub fn build(&mut self) -> anyhow::Result<()> {
96        for config in &self.configs {
97            if self.engines.contains_key(config.id()) {
98                continue;
99            }
100
101            match build_engine(config) {
102                Ok(engine) => {
103                    self.engines.insert(config.id().to_string(), engine);
104                }
105                Err(e) if config.raise_exception() => return Err(e),
106                Err(e) => {
107                    log::error!("Error building backtest '{}': {e:#}", config.id());
108                }
109            }
110        }
111
112        Ok(())
113    }
114
115    /// Returns a mutable reference to the engine for the given run config ID.
116    #[must_use]
117    pub fn get_engine_mut(&mut self, id: &str) -> Option<&mut BacktestEngine> {
118        self.engines.get_mut(id)
119    }
120
121    /// Returns a reference to the engine for the given run config ID.
122    #[must_use]
123    pub fn get_engine(&self, id: &str) -> Option<&BacktestEngine> {
124        self.engines.get(id)
125    }
126
127    /// Returns all created backtest engines.
128    #[must_use]
129    pub fn get_engines(&self) -> Vec<&BacktestEngine> {
130        self.engines.values().collect()
131    }
132
133    /// Runs all configured backtests and returns results.
134    ///
135    /// Automatically calls [`build()`](Self::build) if engines have not been created yet.
136    /// For each run config, loads data from the catalog and runs the engine.
137    /// Supports both oneshot (`chunk_size = None`) and streaming modes.
138    /// Configs without a built engine are skipped. If a run fails with
139    /// [`BacktestRunConfig::raise_exception`] disabled, logs the error, clears its loaded data,
140    /// leaves the engine undisposed, and omits its result.
141    ///
142    /// # Errors
143    ///
144    /// Returns an error if building, data loading, or engine execution fails and
145    /// [`BacktestRunConfig::raise_exception`] is enabled for the run config.
146    pub fn run(&mut self) -> anyhow::Result<Vec<BacktestResult>> {
147        // Auto-build if not already done
148        if self.engines.is_empty() {
149            self.build()?;
150        }
151
152        let mut results = Vec::new();
153
154        for config in &self.configs {
155            let Some(engine) = self.engines.get_mut(config.id()) else {
156                continue;
157            };
158
159            let run_result = match config.chunk_size() {
160                None => run_oneshot(engine, config),
161                Some(chunk_size) => run_streaming(engine, config, chunk_size),
162            };
163
164            if let Err(e) = run_result {
165                if config.raise_exception() {
166                    return Err(e);
167                }
168
169                log::error!("Error running backtest '{}': {e:#}", config.id());
170                engine.clear_data();
171                continue;
172            }
173
174            results.push(engine.get_result());
175
176            if config.dispose_on_completion() {
177                engine.dispose();
178            } else {
179                engine.clear_data();
180            }
181        }
182
183        Ok(results)
184    }
185
186    /// Creates a [`ParquetDataCatalog`] from a data config.
187    ///
188    /// # Errors
189    ///
190    /// Returns an error if the catalog cannot be created from the URI.
191    pub fn load_catalog(config: &BacktestDataConfig) -> anyhow::Result<ParquetDataCatalog> {
192        create_catalog(config)
193    }
194
195    /// Loads data from the catalog for a specific data config.
196    ///
197    /// # Errors
198    ///
199    /// Returns an error if catalog creation or data querying fails.
200    pub fn load_data_config(
201        config: &BacktestDataConfig,
202        start: Option<UnixNanos>,
203        end: Option<UnixNanos>,
204    ) -> anyhow::Result<Vec<Data>> {
205        load_data(config, start, end)
206    }
207
208    /// Disposes all engines and releases resources.
209    pub fn dispose(&mut self) {
210        for engine in self.engines.values_mut() {
211            engine.dispose();
212        }
213        self.engines.clear();
214    }
215}
216
217fn build_engine(config: &BacktestRunConfig) -> anyhow::Result<BacktestEngine> {
218    let engine_config = config.engine().clone();
219    let mut engine = BacktestEngine::new(engine_config)?;
220
221    for venue_config in config.venues() {
222        let starting_balances: Vec<Money> = venue_config
223            .starting_balances()
224            .iter()
225            .map(|s| s.parse::<Money>())
226            .collect::<Result<Vec<_>, _>>()
227            .map_err(|e| anyhow::anyhow!("Invalid starting balance: {e}"))?;
228
229        let default_leverage = venue_config.default_leverage();
230        let leverages = venue_config.leverages().cloned().unwrap_or_default();
231        let margin_model = venue_config.margin_model().cloned().map(Into::into);
232        let modules = venue_config
233            .modules()
234            .iter()
235            .cloned()
236            .map(Into::into)
237            .collect();
238        let fill_model = venue_config
239            .fill_model()
240            .cloned()
241            .unwrap_or_default()
242            .into();
243        let fee_model = venue_config.fee_model().cloned().unwrap_or_default().into();
244        let latency_model = venue_config.latency_model().cloned().map(Into::into);
245        let sim_config = SimulatedVenueConfig::builder()
246            .venue(Venue::from(venue_config.name().as_str()))
247            .oms_type(venue_config.oms_type())
248            .account_type(venue_config.account_type())
249            .book_type(venue_config.book_type())
250            .starting_balances(starting_balances)
251            .maybe_base_currency(venue_config.base_currency())
252            .maybe_default_leverage(default_leverage)
253            .leverages(leverages)
254            .maybe_margin_model(margin_model)
255            .modules(modules)
256            .fill_model(fill_model)
257            .fee_model(fee_model)
258            .maybe_latency_model(latency_model)
259            .routing(venue_config.routing())
260            .reject_stop_orders(venue_config.reject_stop_orders())
261            .support_gtd_orders(venue_config.support_gtd_orders())
262            .support_contingent_orders(venue_config.support_contingent_orders())
263            .use_position_ids(venue_config.use_position_ids())
264            .use_random_ids(venue_config.use_random_ids())
265            .use_reduce_only(venue_config.use_reduce_only())
266            .use_market_order_acks(venue_config.use_market_order_acks())
267            .bar_execution(venue_config.bar_execution())
268            .bar_adaptive_high_low_ordering(venue_config.bar_adaptive_high_low_ordering())
269            .trade_execution(venue_config.trade_execution())
270            .liquidity_consumption(venue_config.liquidity_consumption())
271            .allow_cash_borrowing(venue_config.allow_cash_borrowing())
272            .frozen_account(venue_config.frozen_account())
273            .queue_position(venue_config.queue_position())
274            .oto_full_trigger(venue_config.oto_trigger_mode() == OtoTriggerMode::Full)
275            .price_protection_points(venue_config.price_protection_points())
276            .liquidation_enabled(venue_config.liquidation_enabled())
277            .liquidation_trigger_ratio(venue_config.liquidation_trigger_ratio())
278            .liquidation_cancel_open_orders(venue_config.liquidation_cancel_open_orders())
279            .build()?;
280        engine.add_venue(sim_config)?;
281    }
282
283    for data_config in config.data() {
284        let catalog = create_catalog(data_config)?;
285        let instr_ids: Vec<InstrumentId> = data_config.get_instrument_ids()?;
286        let filter: Option<Vec<String>> = if instr_ids.is_empty() {
287            None
288        } else {
289            Some(instr_ids.iter().map(ToString::to_string).collect())
290        };
291
292        let instruments = catalog.query_instruments(filter.as_deref())?;
293
294        if !instr_ids.is_empty() && instruments.is_empty() {
295            let ids: Vec<String> = instr_ids.iter().map(ToString::to_string).collect();
296            anyhow::bail!(
297                "No instruments found in catalog for requested IDs: [{}]",
298                ids.join(", ")
299            );
300        }
301
302        for instrument in instruments {
303            engine.add_instrument(&instrument)?;
304        }
305    }
306
307    Ok(engine)
308}
309
310fn validate_configs(configs: &[BacktestRunConfig]) -> anyhow::Result<()> {
311    // Kernel initialization sets a thread-local MessageBus that can only be
312    // initialized once per thread, so multiple engines cannot coexist
313    anyhow::ensure!(
314        configs.len() <= 1,
315        "Only one run config per BacktestNode is supported \
316         (kernel MessageBus is a thread-local singleton)"
317    );
318
319    let mut seen_ids = AHashSet::new();
320
321    for config in configs {
322        anyhow::ensure!(
323            seen_ids.insert(config.id()),
324            "Duplicate run config ID '{}'",
325            config.id()
326        );
327
328        let venue_names: Vec<String> = config
329            .venues()
330            .iter()
331            .map(|v| v.name().to_string())
332            .collect();
333
334        for data_config in config.data() {
335            if let (Some(start), Some(end)) = (data_config.start_time(), data_config.end_time()) {
336                anyhow::ensure!(
337                    start <= end,
338                    "Data config start_time ({start}) must be <= end_time ({end})"
339                );
340            }
341
342            for instrument_id in data_config.get_instrument_ids()? {
343                let venue = instrument_id.venue.to_string();
344                anyhow::ensure!(
345                    venue_names.contains(&venue),
346                    "No venue config found for venue '{venue}' (required by instrument {instrument_id})"
347                );
348            }
349        }
350
351        for venue_config in config.venues() {
352            let needs_book_data = matches!(
353                venue_config.book_type(),
354                BookType::L2_MBP | BookType::L3_MBO
355            );
356
357            if needs_book_data {
358                let venue_name = venue_config.name().to_string();
359                let has_book_data = config.data().iter().any(|dc| {
360                    let is_book_type = matches!(
361                        dc.data_type(),
362                        NautilusDataType::OrderBookDelta | NautilusDataType::OrderBookDepth10
363                    );
364
365                    if !is_book_type {
366                        return false;
367                    }
368
369                    // Unfiltered config (no instrument filter) covers all venues
370                    let ids = dc.get_instrument_ids().unwrap_or_default();
371                    ids.is_empty() || ids.iter().any(|id| id.venue.to_string() == venue_name)
372                });
373                anyhow::ensure!(
374                    has_book_data,
375                    "Venue '{venue_name}' has book_type {:?} but no order book data configured",
376                    venue_config.book_type()
377                );
378            }
379        }
380    }
381    Ok(())
382}
383
384fn run_oneshot(engine: &mut BacktestEngine, config: &BacktestRunConfig) -> anyhow::Result<()> {
385    for data_config in config.data() {
386        let data = load_data(data_config, config.start(), config.end())?;
387        if data.is_empty() {
388            log::warn!("No data found for config: {:?}", data_config.data_type());
389            continue;
390        }
391        engine.add_data(data, data_config.client_id(), false, false)?;
392    }
393
394    engine.sort_data();
395    engine.run(
396        config.start(),
397        config.end(),
398        Some(config.id().to_string()),
399        false,
400    )
401}
402
403fn run_streaming(
404    engine: &mut BacktestEngine,
405    config: &BacktestRunConfig,
406    chunk_size: usize,
407) -> anyhow::Result<()> {
408    let data_configs = config.data();
409
410    // Stream directly from the catalog iterators without materializing the full
411    // dataset, so memory stays bounded by chunk_size for any number of configs
412    let mut catalogs = data_configs
413        .iter()
414        .map(create_catalog)
415        .collect::<anyhow::Result<Vec<_>>>()?;
416    let mut streams = Vec::with_capacity(catalogs.len());
417
418    for (catalog, data_config) in catalogs.iter_mut().zip(data_configs) {
419        let result = dispatch_query(catalog, data_config, config.start(), config.end())?;
420        let mut stream = result
421            .map(|item| item.map_err(anyhow::Error::from))
422            .peekable();
423
424        match stream.peek() {
425            Some(Ok(_)) => streams.push(stream),
426            // Surface a failed query in config order, before opening later ones
427            Some(Err(_)) => {
428                stream.next().transpose()?;
429            }
430            None => log::warn!("No data found for config: {:?}", data_config.data_type()),
431        }
432    }
433
434    stream_chunks(
435        engine,
436        config,
437        merge_streams(streams).peekable(),
438        chunk_size,
439    )
440}
441
442// Merges the data streams of every config in ascending `ts_init` order, taking one
443// item at a time so the merge holds only a single item per config. Ties keep config
444// order, matching the stable sort the eager path applies.
445fn merge_streams<I: Iterator<Item = anyhow::Result<Data>>>(
446    mut streams: Vec<Peekable<I>>,
447) -> impl Iterator<Item = anyhow::Result<Data>> {
448    std::iter::from_fn(move || {
449        let mut next: Option<(usize, UnixNanos)> = None;
450
451        for (i, stream) in streams.iter_mut().enumerate() {
452            match stream.peek() {
453                Some(Ok(data)) => {
454                    let ts_init = data.ts_init();
455                    if next.is_none_or(|(_, ts)| ts_init < ts) {
456                        next = Some((i, ts_init));
457                    }
458                }
459                Some(Err(_)) => return stream.next(),
460                None => {}
461            }
462        }
463
464        streams[next?.0].next()
465    })
466}
467
468// Feeds data from an iterator to the engine in timestamp-aligned chunks.
469// Each chunk contains up to `chunk_size` events, extended to include all
470// events sharing the boundary timestamp so timers flush correctly.
471fn stream_chunks<I: Iterator<Item = anyhow::Result<Data>>>(
472    engine: &mut BacktestEngine,
473    config: &BacktestRunConfig,
474    mut iter: Peekable<I>,
475    chunk_size: usize,
476) -> anyhow::Result<()> {
477    if iter.peek().is_none() {
478        return engine.end();
479    }
480
481    let mut next_start = config.start();
482
483    loop {
484        let chunk = take_aligned_chunk(&mut iter, chunk_size)?;
485        if chunk.is_empty() {
486            break;
487        }
488
489        let is_last = iter.peek().is_none();
490        let end = if is_last {
491            config.end()
492        } else {
493            chunk.last().map(HasTsInit::ts_init)
494        };
495
496        engine.add_data(chunk, None, false, true)?;
497        engine.run(next_start, end, Some(config.id().to_string()), true)?;
498        engine.clear_data();
499
500        // A shutdown request during the chunk already triggered end() inside
501        // engine.run(); stop loading further chunks so later data is not processed
502        if engine.kernel().is_shutdown_requested() {
503            return Ok(());
504        }
505
506        // Carry forward the end timestamp so the next chunk's run_impl
507        // sets clocks contiguously and processes gap timers correctly
508        next_start = end;
509    }
510
511    engine.end()
512}
513
514// Takes up to `chunk_size` items, then extends to include all remaining
515// items sharing the boundary timestamp to avoid splitting same-ts events.
516fn take_aligned_chunk<I: Iterator<Item = anyhow::Result<Data>>>(
517    iter: &mut Peekable<I>,
518    chunk_size: usize,
519) -> anyhow::Result<Vec<Data>> {
520    let mut chunk = Vec::with_capacity(chunk_size);
521
522    for _ in 0..chunk_size {
523        match iter.next() {
524            Some(item) => chunk.push(item?),
525            None => return Ok(chunk),
526        }
527    }
528
529    if let Some(boundary_ts) = chunk.last().map(HasTsInit::ts_init) {
530        // A failing item ends the extension and surfaces on the next chunk
531        while let Some(item) = iter.next_if(|item| {
532            item.as_ref()
533                .is_ok_and(|data| data.ts_init() == boundary_ts)
534        }) {
535            chunk.push(item?);
536        }
537    }
538
539    Ok(chunk)
540}
541
542fn create_catalog(config: &BacktestDataConfig) -> anyhow::Result<ParquetDataCatalog> {
543    let uri = match config.catalog_fs_protocol() {
544        Some(protocol) => format!("{protocol}://{}", config.catalog_path()),
545        None => config.catalog_path().to_string(),
546    };
547    let storage_options = config
548        .catalog_fs_rust_storage_options()
549        .cloned()
550        .or_else(|| config.catalog_fs_storage_options().cloned());
551    ParquetDataCatalog::from_uri(&uri, storage_options, None, None, None)
552}
553
554fn load_data(
555    config: &BacktestDataConfig,
556    run_start: Option<UnixNanos>,
557    run_end: Option<UnixNanos>,
558) -> anyhow::Result<Vec<Data>> {
559    let mut catalog = create_catalog(config)?;
560    let result = dispatch_query(&mut catalog, config, run_start, run_end)?;
561    Ok(result.collect::<Result<Vec<_>, _>>()?)
562}
563
564fn dispatch_query(
565    catalog: &mut ParquetDataCatalog,
566    config: &BacktestDataConfig,
567    run_start: Option<UnixNanos>,
568    run_end: Option<UnixNanos>,
569) -> anyhow::Result<QueryResult> {
570    catalog.reset_session();
571
572    let identifiers = config.query_identifiers();
573    let start = max_opt(config.start_time(), run_start);
574    let end = min_opt(config.end_time(), run_end);
575    let filter = config.filter_expr();
576    let optimize = config.optimize_file_loading();
577
578    #[rustfmt::skip]
579    let result = match config.data_type() {
580        NautilusDataType::QuoteTick => catalog.query::<QuoteTick>(identifiers, start, end, filter, None, optimize),
581        NautilusDataType::TradeTick => catalog.query::<TradeTick>(identifiers, start, end, filter, None, optimize),
582        NautilusDataType::Bar => catalog.query::<Bar>(identifiers, start, end, filter, None, optimize),
583        NautilusDataType::OrderBookDelta => catalog.query::<OrderBookDelta>(identifiers, start, end, filter, None, optimize),
584        NautilusDataType::OrderBookDepth10 => catalog.query::<OrderBookDepth10>(identifiers, start, end, filter, None, optimize),
585        NautilusDataType::MarkPriceUpdate => catalog.query::<MarkPriceUpdate>(identifiers, start, end, filter, None, optimize),
586        NautilusDataType::IndexPriceUpdate => catalog.query::<IndexPriceUpdate>(identifiers, start, end, filter, None, optimize),
587        NautilusDataType::FundingRateUpdate => catalog.query::<FundingRateUpdate>(identifiers, start, end, filter, None, optimize),
588        NautilusDataType::InstrumentStatus => catalog.query::<InstrumentStatus>(identifiers, start, end, filter, None, optimize),
589        NautilusDataType::OptionGreeks => catalog.query::<OptionGreeks>(identifiers, start, end, filter, None, optimize),
590        NautilusDataType::InstrumentClose => catalog.query::<InstrumentClose>(identifiers, start, end, filter, None, optimize),
591    };
592    result
593}
594
595fn max_opt(a: Option<UnixNanos>, b: Option<UnixNanos>) -> Option<UnixNanos> {
596    match (a, b) {
597        (Some(a), Some(b)) => Some(a.max(b)),
598        (Some(a), None) => Some(a),
599        (None, Some(b)) => Some(b),
600        (None, None) => None,
601    }
602}
603
604fn min_opt(a: Option<UnixNanos>, b: Option<UnixNanos>) -> Option<UnixNanos> {
605    match (a, b) {
606        (Some(a), Some(b)) => Some(a.min(b)),
607        (Some(a), None) => Some(a),
608        (None, Some(b)) => Some(b),
609        (None, None) => None,
610    }
611}
612
613#[cfg(test)]
614mod tests {
615    #[cfg(feature = "python")]
616    use nautilus_model::enums::{AccountType, OmsType};
617    use nautilus_model::{
618        enums::AggressorSide,
619        identifiers::{InstrumentId, TradeId},
620        types::{Price, Quantity},
621    };
622    #[cfg(feature = "python")]
623    use pyo3::{ffi::c_str, prelude::*, types::PyDict};
624    use rstest::rstest;
625
626    use super::*;
627    use crate::config::MAX_BACKTEST_CHUNK_SIZE;
628    #[cfg(feature = "python")]
629    use crate::{
630        config::BacktestVenueConfig,
631        modules::SimulationModuleAny,
632        python::modules::{PySimulationModule, PythonSimulationModule},
633    };
634
635    fn quote(ts_init: u64) -> Data {
636        Data::Quote(QuoteTick::new(
637            InstrumentId::from("EUR/USD.SIM"),
638            Price::from("1.0001"),
639            Price::from("1.0002"),
640            Quantity::from("100"),
641            Quantity::from("100"),
642            UnixNanos::from(ts_init),
643            UnixNanos::from(ts_init),
644        ))
645    }
646
647    fn trade(ts_init: u64) -> Data {
648        Data::Trade(TradeTick::new(
649            InstrumentId::from("EUR/USD.SIM"),
650            Price::from("1.0001"),
651            Quantity::from("100"),
652            AggressorSide::Buy,
653            TradeId::from("T-1"),
654            UnixNanos::from(ts_init),
655            UnixNanos::from(ts_init),
656        ))
657    }
658
659    fn stream_failure() -> anyhow::Error {
660        anyhow::anyhow!("injected stream failure")
661    }
662
663    #[rstest]
664    fn merge_streams_orders_items_across_streams_by_ts_init() {
665        let streams = vec![
666            vec![Ok(quote(1)), Ok(quote(3)), Ok(quote(3))]
667                .into_iter()
668                .peekable(),
669            vec![Ok(trade(2)), Ok(trade(3))].into_iter().peekable(),
670            vec![].into_iter().peekable(),
671        ];
672
673        let merged: Vec<(u64, bool)> = merge_streams(streams)
674            .map(|item| item.expect("the merged stream must not fail"))
675            .map(|data| (data.ts_init().as_u64(), matches!(data, Data::Trade(_))))
676            .collect();
677
678        assert_eq!(
679            merged,
680            vec![(1, false), (2, true), (3, false), (3, false), (3, true)]
681        );
682    }
683
684    #[rstest]
685    fn merge_streams_leaves_its_streams_undrained() {
686        // Unbounded streams, so a merge that materialized its input would never return
687        let ok_quote: fn(u64) -> anyhow::Result<Data> = |ts_init| Ok(quote(ts_init));
688        let evens = (0u64..).step_by(2).map(ok_quote);
689        let odds = (1u64..).step_by(2).map(ok_quote);
690
691        let merged: Vec<u64> = merge_streams(vec![evens.peekable(), odds.peekable()])
692            .take(4)
693            .map(|item| item.expect("the merged stream must not fail").ts_init())
694            .map(|ts_init| ts_init.as_u64())
695            .collect();
696
697        assert_eq!(merged, vec![0, 1, 2, 3]);
698    }
699
700    #[rstest]
701    fn merge_streams_reports_a_stream_failure() {
702        let streams = vec![
703            vec![Ok(quote(1)), Err(stream_failure())]
704                .into_iter()
705                .peekable(),
706            vec![Ok(quote(2))].into_iter().peekable(),
707        ];
708        let mut merged = merge_streams(streams);
709
710        let first = merged.next().expect("the first item must be present");
711        let second = merged.next().expect("the failure must be yielded");
712
713        assert_eq!(
714            first.expect("the first item must not fail").ts_init(),
715            UnixNanos::from(1)
716        );
717        assert_eq!(
718            second
719                .expect_err("a failed stream must not read as exhaustion")
720                .to_string(),
721            "injected stream failure"
722        );
723    }
724
725    #[rstest]
726    fn take_aligned_chunk_reports_a_stream_failure() {
727        let mut iter = vec![Ok(quote(1)), Err(stream_failure())]
728            .into_iter()
729            .peekable();
730
731        let chunk = take_aligned_chunk(&mut iter, 4);
732
733        assert_eq!(
734            chunk
735                .expect_err("a failed stream must not read as a short chunk")
736                .to_string(),
737            "injected stream failure"
738        );
739    }
740
741    #[rstest]
742    fn take_aligned_chunk_reports_a_failure_found_at_the_boundary() {
743        let mut iter = vec![Ok(quote(1)), Err(stream_failure()), Ok(quote(1))]
744            .into_iter()
745            .peekable();
746
747        let first = take_aligned_chunk(&mut iter, 1).expect("the first chunk must be complete");
748        let second = take_aligned_chunk(&mut iter, 1);
749
750        assert_eq!(first.len(), 1);
751        assert_eq!(first[0].ts_init(), UnixNanos::from(1));
752        assert_eq!(
753            second
754                .expect_err("the failure must survive the boundary extension")
755                .to_string(),
756            "injected stream failure"
757        );
758    }
759
760    #[rstest]
761    fn take_aligned_chunk_extends_past_the_boundary_for_equal_timestamps() {
762        let mut iter = vec![Ok(quote(1)), Ok(quote(1)), Ok(quote(2))]
763            .into_iter()
764            .peekable();
765
766        let chunk = take_aligned_chunk(&mut iter, 1).expect("the chunk must be complete");
767
768        assert_eq!(chunk.len(), 2);
769        assert_eq!(chunk[0].ts_init(), UnixNanos::from(1));
770        assert_eq!(chunk[1].ts_init(), UnixNanos::from(1));
771    }
772
773    #[rstest]
774    fn take_aligned_chunk_reserves_maximum_supported_capacity() {
775        let mut iter = vec![Ok(quote(1))].into_iter().peekable();
776
777        let chunk = take_aligned_chunk(&mut iter, MAX_BACKTEST_CHUNK_SIZE).unwrap();
778
779        assert_eq!(chunk.len(), 1);
780        assert!(chunk.capacity() >= MAX_BACKTEST_CHUNK_SIZE);
781        assert_eq!(chunk[0].ts_init(), UnixNanos::from(1));
782    }
783
784    #[cfg(feature = "python")]
785    #[rstest]
786    fn build_engine_accepts_python_module_from_node_config() {
787        Python::initialize();
788
789        Python::attach(|py| {
790            let locals = PyDict::new(py);
791            locals
792                .set_item("SimulationModule", py.get_type::<PySimulationModule>())
793                .unwrap();
794            let module = py
795                .eval(
796                    c_str!(
797                        "type('NodeSimulationModule', (SimulationModule,), {\
798                            'process': lambda self, ts_now, context: \
799                                (setattr(self, 'calls', self.calls + 1), [])[1]\
800                        })()"
801                    ),
802                    None,
803                    Some(&locals),
804                )
805                .unwrap();
806            module.setattr("calls", 0).unwrap();
807
808            let venue = BacktestVenueConfig::builder()
809                .name("SIM")
810                .oms_type(OmsType::Netting)
811                .account_type(AccountType::Margin)
812                .book_type(BookType::L1_MBP)
813                .starting_balances(vec!["1000 USD".to_string()])
814                .modules(vec![SimulationModuleAny::Python(
815                    PythonSimulationModule::new(module.clone().unbind()),
816                )])
817                .build()
818                .unwrap();
819            let config = BacktestRunConfig::builder()
820                .venues(vec![venue])
821                .data(Vec::new())
822                .build()
823                .unwrap();
824            let mut engine = build_engine(&config).unwrap();
825
826            engine.run(None, None, None, false).unwrap();
827
828            assert_eq!(
829                module.getattr("calls").unwrap().extract::<u32>().unwrap(),
830                1
831            );
832        });
833    }
834}