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