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    if data_configs.len() == 1 {
411        // Single config: stream directly from catalog iterator without
412        // materializing the full dataset, bounded by chunk_size
413        let data_config = &data_configs[0];
414        let mut catalog = create_catalog(data_config)?;
415        let result = dispatch_query(&mut catalog, data_config, config.start(), config.end())?;
416        let data = result.map(|item| item.map_err(anyhow::Error::from));
417        stream_chunks(engine, config, data.peekable(), chunk_size)?;
418    } else {
419        // Multiple configs require loading all data to merge-sort across types
420        let all_data = load_and_merge_data(config)?;
421        stream_chunks(
422            engine,
423            config,
424            all_data.into_iter().map(Ok).peekable(),
425            chunk_size,
426        )?;
427    }
428
429    Ok(())
430}
431
432// Feeds data from an iterator to the engine in timestamp-aligned chunks.
433// Each chunk contains up to `chunk_size` events, extended to include all
434// events sharing the boundary timestamp so timers flush correctly.
435fn stream_chunks<I: Iterator<Item = anyhow::Result<Data>>>(
436    engine: &mut BacktestEngine,
437    config: &BacktestRunConfig,
438    mut iter: Peekable<I>,
439    chunk_size: usize,
440) -> anyhow::Result<()> {
441    if iter.peek().is_none() {
442        return engine.end();
443    }
444
445    let mut next_start = config.start();
446
447    loop {
448        let chunk = take_aligned_chunk(&mut iter, chunk_size)?;
449        if chunk.is_empty() {
450            break;
451        }
452
453        let is_last = iter.peek().is_none();
454        let end = if is_last {
455            config.end()
456        } else {
457            chunk.last().map(HasTsInit::ts_init)
458        };
459
460        engine.add_data(chunk, None, false, true)?;
461        engine.run(next_start, end, Some(config.id().to_string()), true)?;
462        engine.clear_data();
463
464        // A shutdown request during the chunk already triggered end() inside
465        // engine.run(); stop loading further chunks so later data is not processed
466        if engine.kernel().is_shutdown_requested() {
467            return Ok(());
468        }
469
470        // Carry forward the end timestamp so the next chunk's run_impl
471        // sets clocks contiguously and processes gap timers correctly
472        next_start = end;
473    }
474
475    engine.end()
476}
477
478// Takes up to `chunk_size` items, then extends to include all remaining
479// items sharing the boundary timestamp to avoid splitting same-ts events.
480fn take_aligned_chunk<I: Iterator<Item = anyhow::Result<Data>>>(
481    iter: &mut Peekable<I>,
482    chunk_size: usize,
483) -> anyhow::Result<Vec<Data>> {
484    let mut chunk = Vec::with_capacity(chunk_size);
485
486    for _ in 0..chunk_size {
487        match iter.next() {
488            Some(item) => chunk.push(item?),
489            None => return Ok(chunk),
490        }
491    }
492
493    if let Some(boundary_ts) = chunk.last().map(HasTsInit::ts_init) {
494        // A failing item ends the extension and surfaces on the next chunk
495        while let Some(item) = iter.next_if(|item| {
496            item.as_ref()
497                .is_ok_and(|data| data.ts_init() == boundary_ts)
498        }) {
499            chunk.push(item?);
500        }
501    }
502
503    Ok(chunk)
504}
505
506fn load_and_merge_data(config: &BacktestRunConfig) -> anyhow::Result<Vec<Data>> {
507    let mut all_data = Vec::new();
508
509    for data_config in config.data() {
510        let data = load_data(data_config, config.start(), config.end())?;
511        if data.is_empty() {
512            log::warn!("No data found for config: {:?}", data_config.data_type());
513            continue;
514        }
515        all_data.extend(data);
516    }
517    all_data.sort_by_key(HasTsInit::ts_init);
518    Ok(all_data)
519}
520
521fn create_catalog(config: &BacktestDataConfig) -> anyhow::Result<ParquetDataCatalog> {
522    let uri = match config.catalog_fs_protocol() {
523        Some(protocol) => format!("{protocol}://{}", config.catalog_path()),
524        None => config.catalog_path().to_string(),
525    };
526    let storage_options = config
527        .catalog_fs_rust_storage_options()
528        .cloned()
529        .or_else(|| config.catalog_fs_storage_options().cloned());
530    ParquetDataCatalog::from_uri(&uri, storage_options, None, None, None)
531}
532
533fn load_data(
534    config: &BacktestDataConfig,
535    run_start: Option<UnixNanos>,
536    run_end: Option<UnixNanos>,
537) -> anyhow::Result<Vec<Data>> {
538    let mut catalog = create_catalog(config)?;
539    let result = dispatch_query(&mut catalog, config, run_start, run_end)?;
540    Ok(result.collect::<Result<Vec<_>, _>>()?)
541}
542
543fn dispatch_query(
544    catalog: &mut ParquetDataCatalog,
545    config: &BacktestDataConfig,
546    run_start: Option<UnixNanos>,
547    run_end: Option<UnixNanos>,
548) -> anyhow::Result<QueryResult> {
549    catalog.reset_session();
550
551    let identifiers = config.query_identifiers();
552    let start = max_opt(config.start_time(), run_start);
553    let end = min_opt(config.end_time(), run_end);
554    let filter = config.filter_expr();
555    let optimize = config.optimize_file_loading();
556
557    match config.data_type() {
558        NautilusDataType::QuoteTick => {
559            catalog.query::<QuoteTick>(identifiers, start, end, filter, None, optimize)
560        }
561        NautilusDataType::TradeTick => {
562            catalog.query::<TradeTick>(identifiers, start, end, filter, None, optimize)
563        }
564        NautilusDataType::Bar => {
565            catalog.query::<Bar>(identifiers, start, end, filter, None, optimize)
566        }
567        NautilusDataType::OrderBookDelta => {
568            catalog.query::<OrderBookDelta>(identifiers, start, end, filter, None, optimize)
569        }
570        NautilusDataType::OrderBookDepth10 => {
571            catalog.query::<OrderBookDepth10>(identifiers, start, end, filter, None, optimize)
572        }
573        NautilusDataType::MarkPriceUpdate => {
574            catalog.query::<MarkPriceUpdate>(identifiers, start, end, filter, None, optimize)
575        }
576        NautilusDataType::IndexPriceUpdate => {
577            catalog.query::<IndexPriceUpdate>(identifiers, start, end, filter, None, optimize)
578        }
579        NautilusDataType::FundingRateUpdate => {
580            catalog.query::<FundingRateUpdate>(identifiers, start, end, filter, None, optimize)
581        }
582        NautilusDataType::InstrumentStatus => {
583            catalog.query::<InstrumentStatus>(identifiers, start, end, filter, None, optimize)
584        }
585        NautilusDataType::OptionGreeks => {
586            catalog.query::<OptionGreeks>(identifiers, start, end, filter, None, optimize)
587        }
588        NautilusDataType::InstrumentClose => {
589            catalog.query::<InstrumentClose>(identifiers, start, end, filter, None, optimize)
590        }
591    }
592}
593
594fn max_opt(a: Option<UnixNanos>, b: Option<UnixNanos>) -> Option<UnixNanos> {
595    match (a, b) {
596        (Some(a), Some(b)) => Some(a.max(b)),
597        (Some(a), None) => Some(a),
598        (None, Some(b)) => Some(b),
599        (None, None) => None,
600    }
601}
602
603fn min_opt(a: Option<UnixNanos>, b: Option<UnixNanos>) -> Option<UnixNanos> {
604    match (a, b) {
605        (Some(a), Some(b)) => Some(a.min(b)),
606        (Some(a), None) => Some(a),
607        (None, Some(b)) => Some(b),
608        (None, None) => None,
609    }
610}
611
612#[cfg(test)]
613mod tests {
614    #[cfg(feature = "python")]
615    use nautilus_model::enums::{AccountType, OmsType};
616    use nautilus_model::{
617        identifiers::InstrumentId,
618        types::{Price, Quantity},
619    };
620    #[cfg(feature = "python")]
621    use pyo3::{ffi::c_str, prelude::*, types::PyDict};
622    use rstest::rstest;
623
624    use super::*;
625    #[cfg(feature = "python")]
626    use crate::{
627        config::BacktestVenueConfig,
628        modules::SimulationModuleAny,
629        python::modules::{PySimulationModule, PythonSimulationModule},
630    };
631
632    fn quote(ts_init: u64) -> Data {
633        Data::Quote(QuoteTick::new(
634            InstrumentId::from("EUR/USD.SIM"),
635            Price::from("1.0001"),
636            Price::from("1.0002"),
637            Quantity::from("100"),
638            Quantity::from("100"),
639            UnixNanos::from(ts_init),
640            UnixNanos::from(ts_init),
641        ))
642    }
643
644    fn stream_failure() -> anyhow::Error {
645        anyhow::anyhow!("injected stream failure")
646    }
647
648    #[rstest]
649    fn take_aligned_chunk_reports_a_stream_failure() {
650        let mut iter = vec![Ok(quote(1)), Err(stream_failure())]
651            .into_iter()
652            .peekable();
653
654        let chunk = take_aligned_chunk(&mut iter, 4);
655
656        assert_eq!(
657            chunk
658                .expect_err("a failed stream must not read as a short chunk")
659                .to_string(),
660            "injected stream failure"
661        );
662    }
663
664    #[rstest]
665    fn take_aligned_chunk_reports_a_failure_found_at_the_boundary() {
666        let mut iter = vec![Ok(quote(1)), Err(stream_failure()), Ok(quote(1))]
667            .into_iter()
668            .peekable();
669
670        let first = take_aligned_chunk(&mut iter, 1).expect("the first chunk must be complete");
671        let second = take_aligned_chunk(&mut iter, 1);
672
673        assert_eq!(first.len(), 1);
674        assert_eq!(first[0].ts_init(), UnixNanos::from(1));
675        assert_eq!(
676            second
677                .expect_err("the failure must survive the boundary extension")
678                .to_string(),
679            "injected stream failure"
680        );
681    }
682
683    #[rstest]
684    fn take_aligned_chunk_extends_past_the_boundary_for_equal_timestamps() {
685        let mut iter = vec![Ok(quote(1)), Ok(quote(1)), Ok(quote(2))]
686            .into_iter()
687            .peekable();
688
689        let chunk = take_aligned_chunk(&mut iter, 1).expect("the chunk must be complete");
690
691        assert_eq!(chunk.len(), 2);
692        assert_eq!(chunk[0].ts_init(), UnixNanos::from(1));
693        assert_eq!(chunk[1].ts_init(), UnixNanos::from(1));
694    }
695
696    #[cfg(feature = "python")]
697    #[rstest]
698    fn build_engine_accepts_python_module_from_node_config() {
699        Python::initialize();
700
701        Python::attach(|py| {
702            let locals = PyDict::new(py);
703            locals
704                .set_item("SimulationModule", py.get_type::<PySimulationModule>())
705                .unwrap();
706            let module = py
707                .eval(
708                    c_str!(
709                        "type('NodeSimulationModule', (SimulationModule,), {\
710                            'process': lambda self, ts_now, context: \
711                                (setattr(self, 'calls', self.calls + 1), [])[1]\
712                        })()"
713                    ),
714                    None,
715                    Some(&locals),
716                )
717                .unwrap();
718            module.setattr("calls", 0).unwrap();
719
720            let venue = BacktestVenueConfig::builder()
721                .name("SIM")
722                .oms_type(OmsType::Netting)
723                .account_type(AccountType::Margin)
724                .book_type(BookType::L1_MBP)
725                .starting_balances(vec!["1000 USD".to_string()])
726                .modules(vec![SimulationModuleAny::Python(
727                    PythonSimulationModule::new(module.clone().unbind()),
728                )])
729                .build()
730                .unwrap();
731            let config = BacktestRunConfig::builder()
732                .venues(vec![venue])
733                .data(Vec::new())
734                .build()
735                .unwrap();
736            let mut engine = build_engine(&config).unwrap();
737
738            engine.run(None, None, None, false).unwrap();
739
740            assert_eq!(
741                module.getattr("calls").unwrap().extract::<u32>().unwrap(),
742                1
743            );
744        });
745    }
746}