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