Skip to main content

nautilus_databento/
live.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//! Databento live feed handler.
17//!
18//! The feed handler runs a single async task per dataset. It receives
19//! [`HandlerCommand`] messages over an unbounded channel and streams decoded
20//! market data back as [`DatabentoMessage`]s on an unbounded tokio channel.
21//!
22//! The inner loop uses `tokio::select!` to concurrently await the next record
23//! from the Databento gateway and the next command from the engine, giving
24//! near-zero idle CPU and immediate command responsiveness.
25//!
26//! Heartbeat detection is delegated to the upstream `databento` client, which
27//! returns `Error::HeartbeatTimeout` when no data arrives within
28//! `heartbeat_interval + 5 s` (default 35 s). The handler treats this as a
29//! connection error and enters the reconnection backoff loop.
30
31use std::{
32    fmt::{Debug, Display},
33    path::PathBuf,
34    str::FromStr,
35    sync::Arc,
36    time::Duration,
37};
38
39use ahash::{AHashMap, HashSet, HashSetExt};
40use databento::{
41    dbn::{self, PitSymbolMap, SymbolIndex},
42    live::Subscription,
43};
44use indexmap::IndexMap;
45use nautilus_core::{
46    AtomicMap, UnixNanos, consts::NAUTILUS_USER_AGENT, string::secret::SecretString,
47    time::get_atomic_clock_realtime,
48};
49use nautilus_model::{
50    data::{Data, InstrumentStatus, OrderBookDelta, OrderBookDeltas},
51    enums::RecordFlag,
52    identifiers::{InstrumentId, Symbol, Venue},
53    instruments::{Instrument, InstrumentAny},
54    types::Currency,
55};
56use nautilus_network::backoff::ExponentialBackoff;
57use time::OffsetDateTime;
58
59use super::{
60    decode::{
61        decode_imbalance_msg, decode_statistics_msg, decode_status_msg, is_supported_stat_type,
62    },
63    types::{DatabentoImbalance, DatabentoStatistics, SubscriptionAckEvent},
64};
65use crate::{
66    common::{Credential, build_publisher_venue_map, load_publishers},
67    decode::{decode_instrument_def_msg, decode_record},
68    symbology::{check_consistent_symbology, infer_symbology_type},
69    types::PublisherId,
70};
71
72#[derive(Debug)]
73pub enum HandlerCommand {
74    Subscribe(Subscription),
75    SetPricePrecision(Symbol, u8),
76    Start,
77    Close,
78}
79
80#[derive(Debug)]
81pub enum DatabentoMessage {
82    Data(Data),
83    Instrument(Box<InstrumentAny>),
84    Status(InstrumentStatus),
85    Imbalance(DatabentoImbalance),
86    Statistics(DatabentoStatistics),
87    SubscriptionAck(SubscriptionAckEvent),
88    Error(anyhow::Error),
89    Close,
90}
91
92#[cfg_attr(
93    feature = "python",
94    pyo3::pyclass(module = "nautilus_trader.adapters.databento")
95)]
96#[cfg_attr(
97    feature = "python",
98    pyo3_stub_gen::derive::gen_stub_pyclass(module = "nautilus_trader.adapters.databento")
99)]
100pub struct DatabentoLiveClient {
101    credential: Credential,
102    pub dataset: String,
103    is_running: bool,
104    is_closed: bool,
105    cmd_tx: tokio::sync::mpsc::UnboundedSender<HandlerCommand>,
106    cmd_rx: Option<tokio::sync::mpsc::UnboundedReceiver<HandlerCommand>>,
107    publisher_venue_map: IndexMap<PublisherId, Venue>,
108    symbol_venue_map: Arc<AtomicMap<Symbol, Venue>>,
109    use_exchange_as_venue: bool,
110    bars_timestamp_on_close: bool,
111    reconnect_timeout_mins: Option<u64>,
112}
113
114impl Debug for DatabentoLiveClient {
115    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
116        f.debug_struct(stringify!(DatabentoLiveClient))
117            .field("credential", &self.credential)
118            .field("dataset", &self.dataset)
119            .field("is_running", &self.is_running)
120            .field("is_closed", &self.is_closed)
121            .finish()
122    }
123}
124
125impl DatabentoLiveClient {
126    /// Creates a new [`DatabentoLiveClient`] instance.
127    ///
128    /// # Errors
129    ///
130    /// Returns an error if reading or parsing the publishers file fails.
131    pub fn new(
132        key: String,
133        dataset: String,
134        publishers_filepath: PathBuf,
135        use_exchange_as_venue: bool,
136        bars_timestamp_on_close: Option<bool>,
137        reconnect_timeout_mins: Option<i64>,
138    ) -> anyhow::Result<Self> {
139        let publishers = load_publishers(publishers_filepath)?;
140        let publisher_venue_map = build_publisher_venue_map(&publishers);
141
142        let (cmd_tx, cmd_rx) = tokio::sync::mpsc::unbounded_channel::<HandlerCommand>();
143
144        let reconnect_timeout_mins = reconnect_timeout_mins
145            .and_then(|mins| if mins >= 0 { Some(mins as u64) } else { None });
146
147        Ok(Self {
148            credential: Credential::new(key),
149            dataset,
150            cmd_tx,
151            cmd_rx: Some(cmd_rx),
152            is_running: false,
153            is_closed: false,
154            publisher_venue_map,
155            symbol_venue_map: Arc::new(AtomicMap::new()),
156            use_exchange_as_venue,
157            bars_timestamp_on_close: bars_timestamp_on_close.unwrap_or(true),
158            reconnect_timeout_mins,
159        })
160    }
161
162    #[must_use]
163    pub const fn is_running(&self) -> bool {
164        self.is_running
165    }
166
167    #[must_use]
168    pub const fn is_closed(&self) -> bool {
169        self.is_closed
170    }
171
172    /// Subscribes to Databento live data for the requested instruments.
173    ///
174    /// # Errors
175    ///
176    /// Returns an error if symbology, schema, timestamp, or precision inputs are invalid,
177    /// or if the command cannot be sent to the feed handler.
178    #[expect(clippy::needless_pass_by_value)]
179    pub fn subscribe(
180        &mut self,
181        schema: String,
182        instrument_ids: Vec<InstrumentId>,
183        start: Option<u64>,
184        snapshot: Option<bool>,
185        price_precisions: Option<Vec<Option<u8>>>,
186        stype_in: Option<String>,
187    ) -> anyhow::Result<()> {
188        if let Some(precisions) = &price_precisions
189            && precisions.len() != instrument_ids.len()
190        {
191            anyhow::bail!(
192                "`price_precisions` length ({}) must match `instrument_ids` length ({})",
193                precisions.len(),
194                instrument_ids.len()
195            );
196        }
197
198        let symbols: Vec<String> = instrument_ids
199            .iter()
200            .map(|id| id.symbol.to_string())
201            .collect();
202        let first_symbol = symbols
203            .first()
204            .ok_or_else(|| anyhow::anyhow!("No symbols provided"))?;
205        let stype_in = match stype_in {
206            Some(stype_in) => dbn::SType::from_str(&stype_in)?,
207            None => infer_symbology_type(first_symbol),
208        };
209        let symbols: Vec<&str> = symbols.iter().map(String::as_str).collect();
210        check_consistent_symbology(symbols.as_slice())?;
211        let mut sub = Subscription::builder()
212            .symbols(symbols)
213            .schema(dbn::Schema::from_str(&schema)?)
214            .stype_in(stype_in)
215            .build();
216
217        if let Some(start) = start {
218            sub.start = Some(OffsetDateTime::from_unix_timestamp_nanos(i128::from(
219                start,
220            ))?);
221        }
222        sub.use_snapshot = snapshot.unwrap_or(false);
223
224        self.symbol_venue_map.rcu(|m| {
225            for id in &instrument_ids {
226                m.entry(id.symbol).or_insert(id.venue);
227            }
228        });
229
230        if let Some(precisions) = price_precisions {
231            for (instrument_id, precision) in instrument_ids.iter().zip(precisions) {
232                if let Some(precision) = precision {
233                    self.send_command(HandlerCommand::SetPricePrecision(
234                        instrument_id.symbol,
235                        precision,
236                    ))?;
237                }
238            }
239        }
240
241        self.send_command(HandlerCommand::Subscribe(sub))
242    }
243
244    /// Starts the live feed handler and returns its message receiver.
245    ///
246    /// # Errors
247    ///
248    /// Returns an error if the client is already closed, already running, or cannot start.
249    pub fn start(
250        &mut self,
251    ) -> anyhow::Result<(
252        DatabentoFeedHandler,
253        tokio::sync::mpsc::UnboundedReceiver<DatabentoMessage>,
254    )> {
255        if self.is_closed {
256            anyhow::bail!("Client already closed");
257        }
258
259        if self.is_running {
260            anyhow::bail!("Client already running");
261        }
262
263        log::debug!("Starting client");
264
265        let (msg_tx, msg_rx) = tokio::sync::mpsc::unbounded_channel::<DatabentoMessage>();
266        let cmd_rx = self
267            .cmd_rx
268            .take()
269            .ok_or_else(|| anyhow::anyhow!("Command receiver already taken"))?;
270
271        let feed_handler = DatabentoFeedHandler::new(
272            self.credential.clone(),
273            self.dataset.clone(),
274            cmd_rx,
275            msg_tx,
276            self.publisher_venue_map.clone(),
277            self.symbol_venue_map.clone(),
278            self.use_exchange_as_venue,
279            self.bars_timestamp_on_close,
280            self.reconnect_timeout_mins,
281        );
282
283        self.send_command(HandlerCommand::Start)?;
284        self.is_running = true;
285
286        Ok((feed_handler, msg_rx))
287    }
288
289    /// Closes the live client.
290    ///
291    /// # Errors
292    ///
293    /// Returns an error if the client was never started, is already closed, or cannot send
294    /// the close command to the feed handler.
295    pub fn close(&mut self) -> anyhow::Result<()> {
296        if !self.is_running {
297            anyhow::bail!("Client never started");
298        }
299
300        if self.is_closed {
301            anyhow::bail!("Client already closed");
302        }
303
304        log::debug!("Closing client");
305
306        if !self.cmd_tx.is_closed() {
307            self.send_command(HandlerCommand::Close)?;
308        }
309
310        self.is_running = false;
311        self.is_closed = true;
312
313        Ok(())
314    }
315
316    fn send_command(&self, cmd: HandlerCommand) -> anyhow::Result<()> {
317        self.cmd_tx.send(cmd).map_err(|e| {
318            anyhow::Error::new(CommandSendError {
319                details: e.to_string(),
320            })
321        })
322    }
323}
324
325#[cfg(any(test, feature = "python"))]
326pub(crate) fn is_command_send_error(error: &anyhow::Error) -> bool {
327    error.is::<CommandSendError>()
328}
329
330#[derive(Debug)]
331struct CommandSendError {
332    details: String,
333}
334
335impl Display for CommandSendError {
336    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
337        write!(
338            f,
339            "Failed to send command to Databento feed handler: {}",
340            self.details
341        )
342    }
343}
344
345impl std::error::Error for CommandSendError {}
346
347/// Handles a raw TCP data feed from the Databento LSG for a single dataset.
348///
349/// [`HandlerCommand`] messages are received synchronously across a channel,
350/// decoded records are sent asynchronously on a tokio channel as [`DatabentoMessage`]s
351/// back to a message processing task.
352///
353/// # Crash Policy
354///
355/// This handler intentionally avoids applying downstream backpressure to the
356/// live feed. If decoded output cannot be drained, memory pressure is the hard
357/// failure mode instead of arbitrary queue limits or delayed market data.
358pub struct DatabentoFeedHandler {
359    credential: Credential,
360    dataset: String,
361    cmd_rx: tokio::sync::mpsc::UnboundedReceiver<HandlerCommand>,
362    msg_tx: tokio::sync::mpsc::UnboundedSender<DatabentoMessage>,
363    publisher_venue_map: IndexMap<PublisherId, Venue>,
364    symbol_venue_map: Arc<AtomicMap<Symbol, Venue>>,
365    replay: bool,
366    use_exchange_as_venue: bool,
367    bars_timestamp_on_close: bool,
368    reconnect_timeout_mins: Option<u64>,
369    backoff: ExponentialBackoff,
370    subscriptions: Vec<Subscription>,
371    buffered_commands: Vec<HandlerCommand>,
372    price_precision_overrides: AHashMap<Symbol, u8>,
373    gateway_addr: Option<String>,
374    success_threshold: Duration,
375}
376
377impl Debug for DatabentoFeedHandler {
378    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
379        f.debug_struct(stringify!(DatabentoFeedHandler))
380            .field("credential", &self.credential)
381            .field("dataset", &self.dataset)
382            .field("replay", &self.replay)
383            .field("reconnect_timeout_mins", &self.reconnect_timeout_mins)
384            .field("subscriptions", &self.subscriptions.len())
385            .finish()
386    }
387}
388
389impl DatabentoFeedHandler {
390    /// Creates a new [`DatabentoFeedHandler`] instance.
391    ///
392    /// # Panics
393    ///
394    /// Panics if exponential backoff creation fails (should never happen with valid hardcoded parameters).
395    #[must_use]
396    #[expect(clippy::too_many_arguments)]
397    pub fn new(
398        credential: Credential,
399        dataset: String,
400        rx: tokio::sync::mpsc::UnboundedReceiver<HandlerCommand>,
401        tx: tokio::sync::mpsc::UnboundedSender<DatabentoMessage>,
402        publisher_venue_map: IndexMap<PublisherId, Venue>,
403        symbol_venue_map: Arc<AtomicMap<Symbol, Venue>>,
404        use_exchange_as_venue: bool,
405        bars_timestamp_on_close: bool,
406        reconnect_timeout_mins: Option<u64>,
407    ) -> Self {
408        // Choose max delay based on timeout configuration:
409        // - With timeout: 60s max (quick recovery to reconnect within window)
410        // - Without timeout (None): 600s max (patient recovery, respectful of infrastructure)
411        let delay_max = if reconnect_timeout_mins.is_some() {
412            Duration::from_secs(60)
413        } else {
414            Duration::from_secs(600)
415        };
416
417        let backoff = ExponentialBackoff::new(Duration::from_secs(1), delay_max, 2.0, 1000, false)
418            .expect("hardcoded backoff parameters are valid");
419
420        Self {
421            credential,
422            dataset,
423            cmd_rx: rx,
424            msg_tx: tx,
425            publisher_venue_map,
426            symbol_venue_map,
427            replay: false,
428            use_exchange_as_venue,
429            bars_timestamp_on_close,
430            reconnect_timeout_mins,
431            backoff,
432            subscriptions: Vec::new(),
433            buffered_commands: Vec::new(),
434            price_precision_overrides: AHashMap::new(),
435            gateway_addr: None,
436            success_threshold: Duration::from_secs(60),
437        }
438    }
439
440    /// Sets a custom gateway address, overriding the default Databento LSG endpoint.
441    #[must_use]
442    pub fn with_gateway_addr(mut self, addr: String) -> Self {
443        self.gateway_addr = Some(addr);
444        self
445    }
446
447    /// Sets the duration a session must run before it counts as successful.
448    ///
449    /// A successful session resets the reconnection backoff cycle.
450    /// Defaults to 60 seconds.
451    #[must_use]
452    pub fn with_success_threshold(mut self, threshold: Duration) -> Self {
453        self.success_threshold = threshold;
454        self
455    }
456
457    /// Runs the feed handler main loop, processing commands and streaming market data.
458    ///
459    /// Establishes a connection to the Databento LSG, subscribes to requested data feeds,
460    /// and continuously processes incoming market data messages until shutdown.
461    ///
462    /// Implements automatic reconnection with exponential backoff (1s to 60s with jitter).
463    /// Each successful session resets the reconnection cycle, giving the next disconnect
464    /// a fresh timeout window. Gives up after `reconnect_timeout_mins` if configured.
465    ///
466    /// # Errors
467    ///
468    /// Returns an error if any client operation or message handling fails.
469    pub async fn run(&mut self) -> anyhow::Result<()> {
470        log::debug!("Running feed handler");
471
472        let mut reconnect_start: Option<tokio::time::Instant> = None;
473        let mut attempt = 0;
474
475        loop {
476            attempt += 1;
477
478            match self.run_session(attempt).await {
479                Ok(ran_successfully) => {
480                    if ran_successfully {
481                        log::info!("Resetting reconnection cycle after successful session");
482                        reconnect_start = None;
483                        attempt = 0;
484                        self.backoff.reset();
485                    } else {
486                        log::debug!("Session ended normally");
487                        break Ok(());
488                    }
489                }
490                Err(e) => {
491                    let cycle_start = reconnect_start.get_or_insert_with(tokio::time::Instant::now);
492
493                    if let Some(timeout_mins) = self.reconnect_timeout_mins {
494                        let elapsed = cycle_start.elapsed();
495                        let timeout = Duration::from_mins(timeout_mins);
496
497                        if elapsed >= timeout {
498                            log::error!("Giving up reconnection after {timeout_mins} minutes");
499                            self.send_msg(DatabentoMessage::Error(anyhow::anyhow!(
500                                "Reconnection timeout after {timeout_mins} minutes: {e}"
501                            )));
502                            break Err(e);
503                        }
504                    }
505
506                    let delay = self.backoff.next_duration();
507
508                    log::warn!(
509                        "Connection lost (attempt {}): {}. Reconnecting in {}s...",
510                        attempt,
511                        e,
512                        delay.as_secs()
513                    );
514
515                    let sleep = tokio::time::sleep(delay);
516                    tokio::pin!(sleep);
517
518                    loop {
519                        tokio::select! {
520                            () = &mut sleep => break,
521                            cmd = self.cmd_rx.recv() => {
522                                match cmd {
523                                    Some(HandlerCommand::Close) => {
524                                        log::debug!("Close received during backoff");
525                                        return Ok(());
526                                    }
527                                    None => {
528                                        log::debug!("Command channel closed during backoff");
529                                        return Ok(());
530                                    }
531                                    Some(cmd) => {
532                                        log::debug!("Buffering command received during backoff: {cmd:?}");
533                                        self.buffered_commands.push(cmd);
534                                    }
535                                }
536                            }
537                        }
538                    }
539                }
540            }
541        }
542    }
543
544    /// Runs a single session, handling connection, subscriptions, and data streaming.
545    ///
546    /// Returns `Ok(bool)` where the bool indicates if the session ran successfully
547    /// for a meaningful duration (true) or was intentionally closed (false).
548    ///
549    /// # Errors
550    ///
551    /// Returns an error if connection fails, subscription fails, or data streaming encounters an error.
552    async fn run_session(&mut self, attempt: usize) -> anyhow::Result<bool> {
553        if attempt > 1 {
554            log::info!("Reconnecting (attempt {attempt})...");
555        }
556
557        let session_start = tokio::time::Instant::now();
558        let clock = get_atomic_clock_realtime();
559        let mut symbol_map = PitSymbolMap::new();
560        let mut instrument_id_map: AHashMap<u32, InstrumentId> = AHashMap::new();
561        let mut instrument_def_price_precision_map: AHashMap<u32, u8> = AHashMap::new();
562        let mut subscription_price_precision_map: AHashMap<u32, u8> = AHashMap::new();
563
564        let mut buffering_start = None;
565        let mut buffered_deltas: AHashMap<InstrumentId, Vec<OrderBookDelta>> = AHashMap::new();
566        let mut initialized_books = HashSet::new();
567        let mut trade_sources: AHashMap<InstrumentId, u8> = AHashMap::new();
568        let timeout = Duration::from_secs(5); // Hardcoded timeout for now
569
570        let gateway_addr = self.gateway_addr.clone();
571        let api_key = SecretString::from(self.credential.api_key().to_owned());
572        let dataset = self.dataset.clone();
573
574        let result = tokio::time::timeout(timeout, async move {
575            let base = databento::LiveClient::builder();
576            let base = if let Some(addr) = gateway_addr {
577                base.addr(addr).await?
578            } else {
579                base
580            };
581            base.user_agent_extension(NAUTILUS_USER_AGENT.into())
582                .key(api_key.expose_secret().to_owned())?
583                .dataset(dataset)
584                .build()
585                .await
586        })
587        .await?;
588
589        let mut client = match result {
590            Ok(client) => {
591                if attempt > 1 {
592                    log::info!("Reconnected successfully");
593                } else {
594                    log::info!("Connected");
595                }
596                client
597            }
598            Err(e) => {
599                anyhow::bail!("Failed to connect to Databento LSG: {e}");
600            }
601        };
602
603        // Process any commands buffered during reconnection backoff
604        let mut start_buffered = false;
605
606        if !self.buffered_commands.is_empty() {
607            log::debug!(
608                "Processing {} buffered commands",
609                self.buffered_commands.len()
610            );
611
612            for cmd in self.buffered_commands.drain(..) {
613                match cmd {
614                    HandlerCommand::Subscribe(sub) => {
615                        if !self.replay && sub.start.is_some() {
616                            self.replay = true;
617                        }
618                        self.subscriptions.push(sub);
619                    }
620                    HandlerCommand::SetPricePrecision(symbol, precision) => {
621                        self.price_precision_overrides.insert(symbol, precision);
622                    }
623                    HandlerCommand::Start => {
624                        start_buffered = true;
625                    }
626                    HandlerCommand::Close => {
627                        log::warn!("Close command was buffered, shutting down");
628                        return Ok(false);
629                    }
630                }
631            }
632        }
633
634        let mut running = false;
635
636        if !self.subscriptions.is_empty() {
637            log::info!(
638                "Resubscribing to {} subscriptions",
639                self.subscriptions.len()
640            );
641
642            // Anchors remain only when the first connection failed, so this session replays
643            let replay = self.subscriptions.iter().any(|sub| sub.start.is_some());
644
645            for sub in self.subscriptions.clone() {
646                client.subscribe(sub).await?;
647            }
648            // Strip start timestamps after successful subscription to avoid replaying history on future reconnects
649            for sub in &mut self.subscriptions {
650                sub.start = None;
651            }
652
653            buffering_start = replay.then(|| clock.get_time_ns());
654            client.start().await?;
655            running = true;
656            log::info!("Resubscription complete");
657        } else if start_buffered {
658            log::debug!("Starting session from buffered Start command");
659            buffering_start = if self.replay {
660                Some(clock.get_time_ns())
661            } else {
662                None
663            };
664            client.start().await?;
665            running = true;
666        }
667
668        loop {
669            if self.msg_tx.is_closed() {
670                log::debug!("Message channel was closed: stopping");
671                return Ok(false);
672            }
673
674            // Wait for either a command or a record. When the session has not
675            // started yet (`!running`), only commands are awaited. Once running,
676            // `next_record` is cancel-safe so `tokio::select!` can safely
677            // race both futures.
678            if !running {
679                match self.cmd_rx.recv().await {
680                    Some(HandlerCommand::Subscribe(sub)) => {
681                        log::debug!("Received command: Subscribe");
682
683                        if !self.replay && sub.start.is_some() {
684                            self.replay = true;
685                        }
686                        client.subscribe(sub.clone()).await?;
687                        let mut sub_for_reconnect = sub;
688                        sub_for_reconnect.start = None;
689                        self.subscriptions.push(sub_for_reconnect);
690                        continue;
691                    }
692                    Some(HandlerCommand::SetPricePrecision(symbol, precision)) => {
693                        log::debug!(
694                            "Received command: SetPricePrecision for {symbol} to {precision}"
695                        );
696                        self.price_precision_overrides.insert(symbol, precision);
697                        continue;
698                    }
699                    Some(HandlerCommand::Start) => {
700                        log::debug!("Received command: Start");
701                        buffering_start = if self.replay {
702                            Some(clock.get_time_ns())
703                        } else {
704                            None
705                        };
706                        client.start().await?;
707                        running = true;
708                        continue;
709                    }
710                    Some(HandlerCommand::Close) => {
711                        self.send_close_msg();
712                        return Ok(false);
713                    }
714                    None => {
715                        log::debug!("Command channel disconnected");
716                        return Ok(false);
717                    }
718                }
719            }
720
721            let record_opt = tokio::select! {
722                cmd = self.cmd_rx.recv() =>
723                match cmd {
724                    Some(HandlerCommand::Subscribe(sub)) => {
725                        log::debug!("Received command: Subscribe");
726
727                        if sub.start.is_some() {
728                            self.replay = true;
729                            log::error!(
730                                "Ignoring `start` on {} subscribe, session already running, Databento drops replay anchors sent after session start",
731                                self.dataset,
732                            );
733                        }
734                        client.subscribe(sub.clone()).await?;
735                        let mut sub_for_reconnect = sub;
736                        sub_for_reconnect.start = None;
737                        self.subscriptions.push(sub_for_reconnect);
738                        continue;
739                    }
740                    Some(HandlerCommand::SetPricePrecision(symbol, precision)) => {
741                        log::debug!(
742                            "Received command: SetPricePrecision for {symbol} to {precision}"
743                        );
744                        self.price_precision_overrides.insert(symbol, precision);
745                        continue;
746                    }
747                    Some(HandlerCommand::Start) => {
748                        log::warn!("Received Start command but session already running");
749                        continue;
750                    }
751                    Some(HandlerCommand::Close) => {
752                        self.send_close_msg();
753                        client.close().await?;
754                        log::debug!("Closed inner client");
755                        return Ok(false);
756                    }
757                    None => {
758                        log::debug!("Command channel disconnected");
759                        return Ok(false);
760                    }
761                },
762                result = client.next_record() => result,
763            };
764
765            let record = match record_opt {
766                Ok(Some(record)) => record,
767                Ok(None) => {
768                    if session_start.elapsed() >= self.success_threshold {
769                        log::debug!("Session ended after successful run");
770                        return Ok(true);
771                    }
772                    anyhow::bail!("Session ended by gateway");
773                }
774                Err(e) => {
775                    if session_start.elapsed() >= self.success_threshold {
776                        log::debug!("Connection error after successful run: {e}");
777                        return Ok(true);
778                    }
779                    anyhow::bail!("Connection error: {e}");
780                }
781            };
782
783            let ts_init = clock.get_time_ns();
784
785            // Decode record
786            if let Some(msg) = record.get::<dbn::ErrorMsg>() {
787                handle_error_msg(msg);
788            } else if let Some(msg) = record.get::<dbn::SystemMsg>() {
789                if let Some(ack) = handle_system_msg(msg, ts_init) {
790                    self.send_msg(DatabentoMessage::SubscriptionAck(ack));
791                }
792            } else if let Some(msg) = record.get::<dbn::SymbolMappingMsg>() {
793                // Remove instrument ID index as the raw symbol may have changed
794                instrument_id_map.remove(&msg.hd.instrument_id);
795                instrument_def_price_precision_map.remove(&msg.hd.instrument_id);
796                update_price_precision_map_with_symbol_mapping_msg(
797                    msg,
798                    &self.price_precision_overrides,
799                    &mut subscription_price_precision_map,
800                )?;
801                handle_symbol_mapping_msg(msg, &mut symbol_map, &mut instrument_id_map)?;
802            } else if let Some(msg) = record.get::<dbn::InstrumentDefMsg>() {
803                if self.use_exchange_as_venue {
804                    let exchange = msg.exchange()?;
805                    if !exchange.is_empty() {
806                        update_instrument_id_map_with_exchange(
807                            &symbol_map,
808                            &self.symbol_venue_map,
809                            &mut instrument_id_map,
810                            msg.hd.instrument_id,
811                            exchange,
812                        )?;
813                    }
814                }
815                let maybe_data = handle_instrument_def_msg(
816                    msg,
817                    &record,
818                    &symbol_map,
819                    &self.publisher_venue_map,
820                    &self.symbol_venue_map,
821                    &mut instrument_id_map,
822                    ts_init,
823                )?;
824
825                if let Some(data) = maybe_data {
826                    instrument_def_price_precision_map
827                        .insert(msg.hd.instrument_id, data.price_precision());
828                    self.send_msg(DatabentoMessage::Instrument(Box::new(data)));
829                }
830            } else if let Some(msg) = record.get::<dbn::StatusMsg>() {
831                let data = handle_status_msg(
832                    msg,
833                    &record,
834                    &symbol_map,
835                    &self.publisher_venue_map,
836                    &self.symbol_venue_map,
837                    &mut instrument_id_map,
838                    ts_init,
839                )?;
840                self.send_msg(DatabentoMessage::Status(data));
841            } else if let Some(msg) = record.get::<dbn::ImbalanceMsg>() {
842                let data = handle_imbalance_msg(
843                    msg,
844                    &record,
845                    &symbol_map,
846                    &self.publisher_venue_map,
847                    &self.symbol_venue_map,
848                    &mut instrument_id_map,
849                    &instrument_def_price_precision_map,
850                    &subscription_price_precision_map,
851                    &self.price_precision_overrides,
852                    ts_init,
853                )?;
854                self.send_msg(DatabentoMessage::Imbalance(data));
855            } else if let Some(msg) = record.get::<dbn::StatMsg>() {
856                let maybe_data = handle_statistics_msg(
857                    msg,
858                    &record,
859                    &symbol_map,
860                    &self.publisher_venue_map,
861                    &self.symbol_venue_map,
862                    &mut instrument_id_map,
863                    &instrument_def_price_precision_map,
864                    &subscription_price_precision_map,
865                    &self.price_precision_overrides,
866                    ts_init,
867                )?;
868
869                if let Some(data) = maybe_data {
870                    self.send_msg(DatabentoMessage::Statistics(data));
871                }
872            } else {
873                // Decode a generic record with possible errors
874                let res = handle_record(
875                    record,
876                    &symbol_map,
877                    &self.publisher_venue_map,
878                    &self.symbol_venue_map,
879                    &mut instrument_id_map,
880                    &instrument_def_price_precision_map,
881                    &subscription_price_precision_map,
882                    &self.price_precision_overrides,
883                    ts_init,
884                    &initialized_books,
885                    self.bars_timestamp_on_close,
886                );
887                let (mut data1, data2) = match res {
888                    Ok(decoded) => decoded,
889                    Err(e) => {
890                        log::error!("Error decoding record: {e}");
891                        continue;
892                    }
893                };
894
895                if let Some(msg) = record.get::<dbn::MboMsg>() {
896                    if let Some(Data::BookDelta(delta)) = &data1 {
897                        initialized_books.insert(delta.instrument_id);
898
899                        log::trace!(
900                            "Buffering delta: {} {buffering_start:?} flags={}",
901                            delta.ts_event,
902                            msg.flags.raw(),
903                        );
904
905                        match process_mbo_delta(
906                            *delta,
907                            msg.flags.raw(),
908                            &mut buffering_start,
909                            &mut buffered_deltas,
910                        ) {
911                            Some(deltas) => data1 = Some(Data::BookDeltas(Box::new(deltas))),
912                            None => continue,
913                        }
914                    } else {
915                        // Records that decode to no delta (`Action::Fill`
916                        // attribution, `Action::None` status, or trades) may
917                        // still carry the match-event boundary: honor the raw
918                        // `F_LAST` flag or a buffered partial event is
919                        // stranded and merged into the next event (e.g. a
920                        // non-terminal 'C' followed by 'N' | F_LAST).
921                        let instrument_id = update_instrument_id_map(
922                            &record,
923                            &symbol_map,
924                            &self.publisher_venue_map,
925                            &self.symbol_venue_map,
926                            &mut instrument_id_map,
927                        )?;
928
929                        if let Some(deltas) = flush_mbo_event_boundary(
930                            instrument_id,
931                            msg.ts_recv.into(),
932                            msg.flags.raw(),
933                            &mut buffering_start,
934                            &mut buffered_deltas,
935                        ) {
936                            self.send_msg(DatabentoMessage::Data(Data::BookDeltas(Box::new(
937                                deltas,
938                            ))));
939                        }
940
941                        // Replayed trades stay out of the live stream
942                        if buffering_start.is_some_and(|start| msg.ts_recv <= start) {
943                            continue;
944                        }
945                    }
946                }
947
948                let rtype = record.header().rtype;
949
950                for data in [data1, data2].into_iter().flatten() {
951                    if claim_trade_source(&data, rtype, &mut trade_sources) {
952                        self.send_msg(DatabentoMessage::Data(data));
953                    }
954                }
955            }
956        }
957    }
958
959    /// Sends a message to the message processing task.
960    fn send_msg(&self, msg: DatabentoMessage) {
961        log::trace!("Sending {msg:?}");
962        match self.msg_tx.send(msg) {
963            Ok(()) => {}
964            Err(e) => log::debug!("Error sending message: {e}"),
965        }
966    }
967
968    fn send_close_msg(&self) {
969        if let Err(e) = self.msg_tx.send(DatabentoMessage::Close) {
970            log::debug!("Could not send close message: {e}");
971        }
972    }
973}
974
975/// Handles Databento error messages by logging them.
976fn handle_error_msg(msg: &dbn::ErrorMsg) {
977    log::error!("{msg:?}");
978}
979
980/// Handles Databento system messages, returning a subscription ack event if applicable.
981fn handle_system_msg(msg: &dbn::SystemMsg, ts_received: UnixNanos) -> Option<SubscriptionAckEvent> {
982    match msg.code() {
983        Ok(dbn::SystemCode::SubscriptionAck) => {
984            let message = msg.msg().unwrap_or("<invalid utf-8>");
985            log::debug!("Subscription acknowledged: {message}");
986
987            let schema = parse_ack_message(message);
988
989            Some(SubscriptionAckEvent {
990                schema,
991                message: message.to_string(),
992                ts_received,
993            })
994        }
995        Ok(dbn::SystemCode::Heartbeat) => {
996            log::trace!("Heartbeat received");
997            None
998        }
999        Ok(dbn::SystemCode::SlowReaderWarning) => {
1000            let message = msg.msg().unwrap_or("<invalid utf-8>");
1001            log::warn!("Slow reader warning: {message}");
1002            None
1003        }
1004        Ok(dbn::SystemCode::ReplayCompleted) => {
1005            let message = msg.msg().unwrap_or("<invalid utf-8>");
1006            log::debug!("Replay completed: {message}");
1007            None
1008        }
1009        _ => {
1010            log::debug!("{msg:?}");
1011            None
1012        }
1013    }
1014}
1015
1016/// Parses a subscription ack message to extract the schema.
1017fn parse_ack_message(message: &str) -> String {
1018    // Format: "Subscription request N for <schema> data succeeded"
1019    message
1020        .strip_circumfix("Subscription request ", " data succeeded")
1021        .and_then(|rest| rest.split_once(" for "))
1022        .map(|(_, schema)| schema.trim().to_string())
1023        .unwrap_or_default()
1024}
1025
1026/// Handles symbol mapping messages and updates the instrument ID map.
1027///
1028/// # Errors
1029///
1030/// Returns an error if symbol mapping fails.
1031fn handle_symbol_mapping_msg(
1032    msg: &dbn::SymbolMappingMsg,
1033    symbol_map: &mut PitSymbolMap,
1034    instrument_id_map: &mut AHashMap<u32, InstrumentId>,
1035) -> anyhow::Result<()> {
1036    symbol_map
1037        .on_symbol_mapping(msg)
1038        .map_err(|e| anyhow::anyhow!("on_symbol_mapping failed for {msg:?}: {e}"))?;
1039    instrument_id_map.remove(&msg.hd.instrument_id);
1040    Ok(())
1041}
1042
1043fn update_price_precision_map_with_symbol_mapping_msg(
1044    msg: &dbn::SymbolMappingMsg,
1045    price_precision_overrides: &AHashMap<Symbol, u8>,
1046    subscription_price_precision_map: &mut AHashMap<u32, u8>,
1047) -> anyhow::Result<()> {
1048    subscription_price_precision_map.remove(&msg.hd.instrument_id);
1049
1050    if price_precision_overrides.is_empty() {
1051        return Ok(());
1052    }
1053
1054    let stype_in_symbol = msg
1055        .stype_in_symbol()
1056        .map_err(|e| anyhow::anyhow!("Error decoding `stype_in_symbol`: {e}"))?;
1057    let stype_out_symbol = msg
1058        .stype_out_symbol()
1059        .map_err(|e| anyhow::anyhow!("Error decoding `stype_out_symbol`: {e}"))?;
1060
1061    let price_precision = [stype_in_symbol, stype_out_symbol]
1062        .into_iter()
1063        .find_map(|symbol| {
1064            price_precision_overrides
1065                .get(&Symbol::from_str_unchecked(symbol))
1066                .copied()
1067        });
1068
1069    if let Some(price_precision) = price_precision {
1070        subscription_price_precision_map.insert(msg.hd.instrument_id, price_precision);
1071    }
1072
1073    Ok(())
1074}
1075
1076/// Updates the instrument ID map using exchange information from the symbol map.
1077fn update_instrument_id_map_with_exchange(
1078    symbol_map: &PitSymbolMap,
1079    symbol_venue_map: &AtomicMap<Symbol, Venue>,
1080    instrument_id_map: &mut AHashMap<u32, InstrumentId>,
1081    raw_instrument_id: u32,
1082    exchange: &str,
1083) -> anyhow::Result<InstrumentId> {
1084    let raw_symbol = symbol_map.get(raw_instrument_id).ok_or_else(|| {
1085        anyhow::anyhow!("Cannot resolve raw_symbol for instrument_id {raw_instrument_id}")
1086    })?;
1087    let symbol = Symbol::from(raw_symbol.as_str());
1088    let venue = Venue::from_code(exchange)
1089        .map_err(|e| anyhow::anyhow!("Invalid venue code '{exchange}': {e}"))?;
1090    let instrument_id = InstrumentId::new(symbol, venue);
1091    symbol_venue_map.rcu(|m| {
1092        m.entry(symbol).or_insert(venue);
1093    });
1094    instrument_id_map.insert(raw_instrument_id, instrument_id);
1095    Ok(instrument_id)
1096}
1097
1098fn update_instrument_id_map(
1099    record: &dbn::RecordRef,
1100    symbol_map: &PitSymbolMap,
1101    publisher_venue_map: &IndexMap<PublisherId, Venue>,
1102    symbol_venue_map: &AtomicMap<Symbol, Venue>,
1103    instrument_id_map: &mut AHashMap<u32, InstrumentId>,
1104) -> anyhow::Result<InstrumentId> {
1105    let header = record.header();
1106
1107    // Check if instrument ID is already in the map
1108    if let Some(&instrument_id) = instrument_id_map.get(&header.instrument_id) {
1109        return Ok(instrument_id);
1110    }
1111
1112    let raw_symbol = symbol_map.get_for_rec(record).ok_or_else(|| {
1113        anyhow::anyhow!(
1114            "Cannot resolve `raw_symbol` from `symbol_map` for instrument_id {}",
1115            header.instrument_id
1116        )
1117    })?;
1118
1119    let symbol = Symbol::from_str_unchecked(raw_symbol);
1120
1121    let publisher_id = header.publisher_id;
1122    let venue = if let Some(venue) = symbol_venue_map.get_cloned(&symbol) {
1123        venue
1124    } else {
1125        let venue = publisher_venue_map
1126            .get(&publisher_id)
1127            .ok_or_else(|| anyhow::anyhow!("No venue found for `publisher_id` {publisher_id}"))?;
1128        *venue
1129    };
1130    let instrument_id = InstrumentId::new(symbol, venue);
1131
1132    instrument_id_map.insert(header.instrument_id, instrument_id);
1133    Ok(instrument_id)
1134}
1135
1136/// Handles instrument definition messages and decodes them into Nautilus instruments.
1137///
1138/// # Errors
1139///
1140/// Returns an error if instrument decoding fails.
1141fn handle_instrument_def_msg(
1142    msg: &dbn::InstrumentDefMsg,
1143    record: &dbn::RecordRef,
1144    symbol_map: &PitSymbolMap,
1145    publisher_venue_map: &IndexMap<PublisherId, Venue>,
1146    symbol_venue_map: &AtomicMap<Symbol, Venue>,
1147    instrument_id_map: &mut AHashMap<u32, InstrumentId>,
1148    ts_init: UnixNanos,
1149) -> anyhow::Result<Option<InstrumentAny>> {
1150    let instrument_id = update_instrument_id_map(
1151        record,
1152        symbol_map,
1153        publisher_venue_map,
1154        symbol_venue_map,
1155        instrument_id_map,
1156    )?;
1157
1158    decode_instrument_def_msg(msg, instrument_id, Some(ts_init), None)
1159}
1160
1161fn handle_status_msg(
1162    msg: &dbn::StatusMsg,
1163    record: &dbn::RecordRef,
1164    symbol_map: &PitSymbolMap,
1165    publisher_venue_map: &IndexMap<PublisherId, Venue>,
1166    symbol_venue_map: &AtomicMap<Symbol, Venue>,
1167    instrument_id_map: &mut AHashMap<u32, InstrumentId>,
1168    ts_init: UnixNanos,
1169) -> anyhow::Result<InstrumentStatus> {
1170    let instrument_id = update_instrument_id_map(
1171        record,
1172        symbol_map,
1173        publisher_venue_map,
1174        symbol_venue_map,
1175        instrument_id_map,
1176    )?;
1177
1178    decode_status_msg(msg, instrument_id, Some(ts_init))
1179}
1180
1181#[expect(clippy::too_many_arguments)]
1182fn handle_imbalance_msg(
1183    msg: &dbn::ImbalanceMsg,
1184    record: &dbn::RecordRef,
1185    symbol_map: &PitSymbolMap,
1186    publisher_venue_map: &IndexMap<PublisherId, Venue>,
1187    symbol_venue_map: &AtomicMap<Symbol, Venue>,
1188    instrument_id_map: &mut AHashMap<u32, InstrumentId>,
1189    instrument_def_price_precision_map: &AHashMap<u32, u8>,
1190    subscription_price_precision_map: &AHashMap<u32, u8>,
1191    price_precision_overrides: &AHashMap<Symbol, u8>,
1192    ts_init: UnixNanos,
1193) -> anyhow::Result<DatabentoImbalance> {
1194    let instrument_id = update_instrument_id_map(
1195        record,
1196        symbol_map,
1197        publisher_venue_map,
1198        symbol_venue_map,
1199        instrument_id_map,
1200    )?;
1201
1202    let price_precision = resolve_price_precision(
1203        msg.hd.instrument_id,
1204        instrument_id,
1205        instrument_def_price_precision_map,
1206        subscription_price_precision_map,
1207        price_precision_overrides,
1208    );
1209
1210    decode_imbalance_msg(msg, instrument_id, price_precision, Some(ts_init))
1211}
1212
1213#[expect(clippy::too_many_arguments)]
1214fn handle_statistics_msg(
1215    msg: &dbn::StatMsg,
1216    record: &dbn::RecordRef,
1217    symbol_map: &PitSymbolMap,
1218    publisher_venue_map: &IndexMap<PublisherId, Venue>,
1219    symbol_venue_map: &AtomicMap<Symbol, Venue>,
1220    instrument_id_map: &mut AHashMap<u32, InstrumentId>,
1221    instrument_def_price_precision_map: &AHashMap<u32, u8>,
1222    subscription_price_precision_map: &AHashMap<u32, u8>,
1223    price_precision_overrides: &AHashMap<Symbol, u8>,
1224    ts_init: UnixNanos,
1225) -> anyhow::Result<Option<DatabentoStatistics>> {
1226    // Precheck before symbol resolution so unmodeled types skip cleanly
1227    if !is_supported_stat_type(msg.stat_type) {
1228        log::warn!("Skipping unsupported `stat_type` {}", msg.stat_type);
1229        return Ok(None);
1230    }
1231
1232    let instrument_id = update_instrument_id_map(
1233        record,
1234        symbol_map,
1235        publisher_venue_map,
1236        symbol_venue_map,
1237        instrument_id_map,
1238    )?;
1239
1240    let price_precision = resolve_price_precision(
1241        msg.hd.instrument_id,
1242        instrument_id,
1243        instrument_def_price_precision_map,
1244        subscription_price_precision_map,
1245        price_precision_overrides,
1246    );
1247
1248    decode_statistics_msg(msg, instrument_id, price_precision, Some(ts_init))
1249}
1250
1251#[expect(clippy::too_many_arguments)]
1252fn handle_record(
1253    record: dbn::RecordRef,
1254    symbol_map: &PitSymbolMap,
1255    publisher_venue_map: &IndexMap<PublisherId, Venue>,
1256    symbol_venue_map: &AtomicMap<Symbol, Venue>,
1257    instrument_id_map: &mut AHashMap<u32, InstrumentId>,
1258    instrument_def_price_precision_map: &AHashMap<u32, u8>,
1259    subscription_price_precision_map: &AHashMap<u32, u8>,
1260    price_precision_overrides: &AHashMap<Symbol, u8>,
1261    ts_init: UnixNanos,
1262    initialized_books: &HashSet<InstrumentId>,
1263    bars_timestamp_on_close: bool,
1264) -> anyhow::Result<(Option<Data>, Option<Data>)> {
1265    let instrument_id = update_instrument_id_map(
1266        &record,
1267        symbol_map,
1268        publisher_venue_map,
1269        symbol_venue_map,
1270        instrument_id_map,
1271    )?;
1272
1273    let price_precision = resolve_price_precision(
1274        record.header().instrument_id,
1275        instrument_id,
1276        instrument_def_price_precision_map,
1277        subscription_price_precision_map,
1278        price_precision_overrides,
1279    );
1280
1281    // For MBP-1 and quote-based schemas, always include trades since they're integral to the data
1282    // For MBO, only include trades after the book is initialized to maintain consistency
1283    let include_trades = if record.get::<dbn::Mbp1Msg>().is_some()
1284        || record.get::<dbn::TbboMsg>().is_some()
1285        || record.get::<dbn::Cmbp1Msg>().is_some()
1286    {
1287        true // These schemas include trade information directly
1288    } else {
1289        initialized_books.contains(&instrument_id) // MBO requires initialized book
1290    };
1291
1292    decode_record(
1293        &record,
1294        instrument_id,
1295        price_precision,
1296        Some(ts_init),
1297        include_trades,
1298        bars_timestamp_on_close,
1299    )
1300}
1301
1302/// Claims the instrument's trade source for a trade decoded from a record of `rtype`, returning
1303/// whether `data` should be sent.
1304///
1305/// The `trades`, `mbp-1`, `tbbo`, `cmbp-1`, `tcbbo`, and `mbo` schemas all carry the same venue
1306/// trades, so an instrument subscribed on several of them would receive each trade once per schema.
1307/// The first record type to deliver a trade for an instrument carries its trades for the session,
1308/// and trades decoded from any other record type are dropped. A later subscription never takes
1309/// over, since its stream starts at an unknown point and a handover could drop or repeat trades.
1310/// Data other than trades always passes.
1311fn claim_trade_source(
1312    data: &Data,
1313    rtype: u8,
1314    trade_sources: &mut AHashMap<InstrumentId, u8>,
1315) -> bool {
1316    match data {
1317        Data::Trade(trade) => *trade_sources.entry(trade.instrument_id).or_insert(rtype) == rtype,
1318        _ => true,
1319    }
1320}
1321
1322fn resolve_price_precision(
1323    record_instrument_id: u32,
1324    instrument_id: InstrumentId,
1325    instrument_def_price_precision_map: &AHashMap<u32, u8>,
1326    subscription_price_precision_map: &AHashMap<u32, u8>,
1327    price_precision_overrides: &AHashMap<Symbol, u8>,
1328) -> u8 {
1329    instrument_def_price_precision_map
1330        .get(&record_instrument_id)
1331        .copied()
1332        .or_else(|| {
1333            subscription_price_precision_map
1334                .get(&record_instrument_id)
1335                .copied()
1336        })
1337        .or_else(|| {
1338            price_precision_overrides
1339                .get(&instrument_id.symbol)
1340                .copied()
1341        })
1342        .unwrap_or(Currency::USD().precision)
1343}
1344
1345/// Processes an MBO delta through the buffering state machine.
1346///
1347/// Returns `Some(deltas)` when a complete batch is ready to emit (non-snapshot
1348/// F_LAST with replay buffering complete), or `None` when still accumulating.
1349fn process_mbo_delta(
1350    delta: OrderBookDelta,
1351    flags: u8,
1352    buffering_start: &mut Option<UnixNanos>,
1353    buffered_deltas: &mut AHashMap<InstrumentId, Vec<OrderBookDelta>>,
1354) -> Option<OrderBookDeltas> {
1355    let is_last = RecordFlag::F_LAST.matches(flags);
1356    let is_snapshot = RecordFlag::F_SNAPSHOT.matches(flags);
1357
1358    // Most live MBO events are single non-snapshot deltas, avoid map churn on that path
1359    if is_last
1360        && !is_snapshot
1361        && buffering_start.is_none()
1362        && !buffered_deltas.contains_key(&delta.instrument_id)
1363    {
1364        let deltas = OrderBookDeltas::new(delta.instrument_id, vec![delta]);
1365        return Some(deltas);
1366    }
1367
1368    let buffer = buffered_deltas.entry(delta.instrument_id).or_default();
1369    buffer.push(delta);
1370
1371    if is_snapshot {
1372        return None;
1373    }
1374
1375    flush_mbo_event_boundary(
1376        delta.instrument_id,
1377        delta.ts_event,
1378        flags,
1379        buffering_start,
1380        buffered_deltas,
1381    )
1382}
1383
1384/// Flushes the buffered deltas for `instrument_id` when `flags` marks a
1385/// non-snapshot match-event boundary (`F_LAST`).
1386///
1387/// Databento documents that records which decode to no delta (`Action::Fill`,
1388/// `Action::None`) may carry `F_LAST`, so the boundary must be processed
1389/// independently of the decoded payload or a buffered partial event is
1390/// stranded (follow-up to #4445).
1391fn flush_mbo_event_boundary(
1392    instrument_id: InstrumentId,
1393    ts_event: UnixNanos,
1394    flags: u8,
1395    buffering_start: &mut Option<UnixNanos>,
1396    buffered_deltas: &mut AHashMap<InstrumentId, Vec<OrderBookDelta>>,
1397) -> Option<OrderBookDeltas> {
1398    if !RecordFlag::F_LAST.matches(flags) || RecordFlag::F_SNAPSHOT.matches(flags) {
1399        return None;
1400    }
1401
1402    let buffer = buffered_deltas.get(&instrument_id)?;
1403
1404    if buffer.is_empty() {
1405        return None;
1406    }
1407
1408    if let Some(start_ns) = *buffering_start {
1409        if ts_event <= start_ns {
1410            return None;
1411        }
1412        *buffering_start = None;
1413    }
1414
1415    let buffer = buffered_deltas.remove(&instrument_id)?;
1416    let deltas = OrderBookDeltas::new(instrument_id, buffer);
1417    Some(deltas)
1418}
1419
1420#[cfg(test)]
1421mod tests {
1422    use std::path::PathBuf;
1423
1424    use databento::{dbn::enums::rtype, live::Subscription};
1425    use indexmap::IndexMap;
1426    use rstest::*;
1427    use time::macros::datetime;
1428
1429    use super::*;
1430
1431    fn stub_delta(instrument_id: InstrumentId, ts: u64) -> OrderBookDelta {
1432        use nautilus_model::{
1433            data::BookOrder,
1434            enums::{BookAction, OrderSide},
1435            types::{Price, Quantity},
1436        };
1437
1438        OrderBookDelta::new(
1439            instrument_id,
1440            BookAction::Delete,
1441            BookOrder::new(
1442                OrderSide::Sell,
1443                Price::from("100.00"),
1444                Quantity::from("5"),
1445                42,
1446            ),
1447            0, // non-terminal: no F_LAST
1448            1,
1449            ts.into(),
1450            ts.into(),
1451        )
1452    }
1453
1454    #[rstest]
1455    fn test_boundary_flag_on_recordless_message_flushes_buffered_event() {
1456        // A non-terminal 'C' delta buffers; the event terminates on an 'N'
1457        // record carrying F_LAST which decodes to no delta - the raw flag
1458        // must still flush the buffer (follow-up to #4445).
1459        let instrument_id = InstrumentId::from("TEST.GLBX");
1460        let mut buffering_start = None;
1461        let mut buffered = AHashMap::new();
1462
1463        let buffered_result = process_mbo_delta(
1464            stub_delta(instrument_id, 1),
1465            0,
1466            &mut buffering_start,
1467            &mut buffered,
1468        );
1469        assert!(buffered_result.is_none());
1470
1471        let flushed = flush_mbo_event_boundary(
1472            instrument_id,
1473            2.into(),
1474            RecordFlag::F_LAST as u8,
1475            &mut buffering_start,
1476            &mut buffered,
1477        );
1478        assert!(flushed.is_some());
1479        assert!(buffered.is_empty());
1480    }
1481
1482    #[rstest]
1483    fn test_boundary_flag_with_empty_buffer_is_noop() {
1484        let instrument_id = InstrumentId::from("TEST.GLBX");
1485        let start = UnixNanos::from(1);
1486        let mut buffering_start = Some(start);
1487        let mut buffered: AHashMap<InstrumentId, Vec<OrderBookDelta>> = AHashMap::new();
1488        buffered.insert(instrument_id, Vec::new());
1489
1490        let flushed = flush_mbo_event_boundary(
1491            instrument_id,
1492            2.into(),
1493            RecordFlag::F_LAST as u8,
1494            &mut buffering_start,
1495            &mut buffered,
1496        );
1497
1498        assert!(flushed.is_none());
1499        assert_eq!(buffering_start, Some(start));
1500    }
1501
1502    #[rstest]
1503    fn test_empty_boundary_preserves_replay_gate_for_buffered_instrument() {
1504        let empty_id = InstrumentId::from("EMPTY.GLBX");
1505        let buffered_id = InstrumentId::from("BUFFERED.GLBX");
1506        let start = UnixNanos::from(10);
1507        let mut buffering_start = Some(start);
1508        let mut buffered = AHashMap::new();
1509        let _ = process_mbo_delta(
1510            stub_delta(buffered_id, 1),
1511            0,
1512            &mut buffering_start,
1513            &mut buffered,
1514        );
1515
1516        let empty_boundary = flush_mbo_event_boundary(
1517            empty_id,
1518            11.into(),
1519            RecordFlag::F_LAST as u8,
1520            &mut buffering_start,
1521            &mut buffered,
1522        );
1523        let buffered_boundary = flush_mbo_event_boundary(
1524            buffered_id,
1525            5.into(),
1526            RecordFlag::F_LAST as u8,
1527            &mut buffering_start,
1528            &mut buffered,
1529        );
1530
1531        assert!(empty_boundary.is_none());
1532        assert!(buffered_boundary.is_none());
1533        assert_eq!(buffering_start, Some(start));
1534        assert!(buffered.contains_key(&buffered_id));
1535    }
1536
1537    #[rstest]
1538    fn test_boundary_flag_respects_replay_buffering_gate() {
1539        let instrument_id = InstrumentId::from("TEST.GLBX");
1540        let mut buffering_start = Some(UnixNanos::from(10));
1541        let mut buffered = AHashMap::new();
1542        let _ = process_mbo_delta(
1543            stub_delta(instrument_id, 1),
1544            0,
1545            &mut buffering_start,
1546            &mut buffered,
1547        );
1548
1549        // Boundary inside the replay gate: keep buffering
1550        let flushed = flush_mbo_event_boundary(
1551            instrument_id,
1552            5.into(),
1553            RecordFlag::F_LAST as u8,
1554            &mut buffering_start,
1555            &mut buffered,
1556        );
1557        assert!(flushed.is_none());
1558        assert!(!buffered.is_empty());
1559
1560        // Boundary past the gate: flush and clear the gate
1561        let flushed = flush_mbo_event_boundary(
1562            instrument_id,
1563            11.into(),
1564            RecordFlag::F_LAST as u8,
1565            &mut buffering_start,
1566            &mut buffered,
1567        );
1568        assert!(flushed.is_some());
1569        assert!(buffering_start.is_none());
1570    }
1571
1572    #[rstest]
1573    fn test_boundary_snapshot_flag_never_flushes() {
1574        let instrument_id = InstrumentId::from("TEST.GLBX");
1575        let mut buffering_start = None;
1576        let mut buffered = AHashMap::new();
1577        let _ = process_mbo_delta(
1578            stub_delta(instrument_id, 1),
1579            0,
1580            &mut buffering_start,
1581            &mut buffered,
1582        );
1583
1584        let flags = RecordFlag::F_LAST as u8 | RecordFlag::F_SNAPSHOT as u8;
1585        let flushed = flush_mbo_event_boundary(
1586            instrument_id,
1587            2.into(),
1588            flags,
1589            &mut buffering_start,
1590            &mut buffered,
1591        );
1592        assert!(flushed.is_none());
1593    }
1594
1595    fn stub_trade(instrument_id: InstrumentId) -> Data {
1596        use nautilus_model::{
1597            data::TradeTick,
1598            enums::AggressorSide,
1599            identifiers::TradeId,
1600            types::{Price, Quantity},
1601        };
1602
1603        Data::Trade(TradeTick::new(
1604            instrument_id,
1605            Price::from("100.00"),
1606            Quantity::from("5"),
1607            AggressorSide::Buy,
1608            TradeId::new("1"),
1609            1.into(),
1610            1.into(),
1611        ))
1612    }
1613
1614    #[rstest]
1615    fn test_trade_source_is_first_record_type_to_deliver_a_trade() {
1616        let trade = stub_trade(InstrumentId::from("TEST.GLBX"));
1617        let mut trade_sources = AHashMap::new();
1618
1619        let sent: Vec<bool> = [rtype::MBP_1, rtype::MBP_0, rtype::MBO, rtype::MBP_1]
1620            .into_iter()
1621            .map(|rtype| claim_trade_source(&trade, rtype, &mut trade_sources))
1622            .collect();
1623
1624        assert_eq!(sent, [true, false, false, true]);
1625    }
1626
1627    #[rstest]
1628    fn test_trade_source_is_kept_per_instrument() {
1629        let es_trade = stub_trade(InstrumentId::from("ESM4.GLBX"));
1630        let nq_trade = stub_trade(InstrumentId::from("NQM4.GLBX"));
1631        let mut trade_sources = AHashMap::new();
1632
1633        let sent: Vec<bool> = [
1634            (&es_trade, rtype::MBP_0),
1635            (&nq_trade, rtype::MBO),
1636            (&es_trade, rtype::MBO),
1637            (&nq_trade, rtype::MBP_0),
1638        ]
1639        .into_iter()
1640        .map(|(trade, rtype)| claim_trade_source(trade, rtype, &mut trade_sources))
1641        .collect();
1642
1643        assert_eq!(sent, [true, true, false, false]);
1644    }
1645
1646    #[rstest]
1647    fn test_trade_source_passes_data_other_than_trades() {
1648        let instrument_id = InstrumentId::from("TEST.GLBX");
1649        let mut trade_sources = AHashMap::new();
1650        let _ = claim_trade_source(&stub_trade(instrument_id), rtype::MBP_0, &mut trade_sources);
1651        let delta = Data::BookDelta(stub_delta(instrument_id, 1));
1652
1653        let sent = claim_trade_source(&delta, rtype::MBO, &mut trade_sources);
1654
1655        assert!(sent);
1656    }
1657
1658    fn create_test_handler(reconnect_timeout_mins: Option<u64>) -> DatabentoFeedHandler {
1659        let (_cmd_tx, cmd_rx) = tokio::sync::mpsc::unbounded_channel();
1660        let (msg_tx, _msg_rx) = tokio::sync::mpsc::unbounded_channel();
1661
1662        DatabentoFeedHandler::new(
1663            Credential::new("test_key"),
1664            "GLBX.MDP3".to_string(),
1665            cmd_rx,
1666            msg_tx,
1667            IndexMap::new(),
1668            Arc::new(AtomicMap::new()),
1669            false,
1670            false,
1671            reconnect_timeout_mins,
1672        )
1673    }
1674
1675    fn create_test_client() -> DatabentoLiveClient {
1676        DatabentoLiveClient::new(
1677            "test-api-key".to_string(),
1678            "GLBX.MDP3".to_string(),
1679            PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("publishers.json"),
1680            true,
1681            None,
1682            None,
1683        )
1684        .unwrap()
1685    }
1686
1687    #[rstest]
1688    #[case(Some(10))]
1689    #[case(None)]
1690    fn test_backoff_initialization(#[case] reconnect_timeout_mins: Option<u64>) {
1691        let handler = create_test_handler(reconnect_timeout_mins);
1692
1693        assert_eq!(handler.reconnect_timeout_mins, reconnect_timeout_mins);
1694        assert!(handler.subscriptions.is_empty());
1695        assert!(handler.buffered_commands.is_empty());
1696    }
1697
1698    #[rstest]
1699    fn test_subscription_with_and_without_start() {
1700        let start_time = datetime!(2024-01-01 00:00:00 UTC);
1701        let sub_with_start = Subscription::builder()
1702            .symbols("ES.FUT")
1703            .schema(databento::dbn::Schema::Mbp1)
1704            .start(start_time)
1705            .build();
1706
1707        let mut sub_without_start = sub_with_start.clone();
1708        sub_without_start.start = None;
1709
1710        assert!(sub_with_start.start.is_some());
1711        assert!(sub_without_start.start.is_none());
1712        assert_eq!(sub_with_start.schema, sub_without_start.schema);
1713        assert_eq!(sub_with_start.symbols, sub_without_start.symbols);
1714    }
1715
1716    #[rstest]
1717    fn test_handler_initialization_state() {
1718        let handler = create_test_handler(Some(10));
1719
1720        assert!(!handler.replay);
1721        assert_eq!(handler.dataset, "GLBX.MDP3");
1722        assert_eq!(handler.credential.api_key(), "test_key");
1723        assert!(handler.subscriptions.is_empty());
1724        assert!(handler.buffered_commands.is_empty());
1725    }
1726
1727    #[rstest]
1728    fn test_handler_with_no_timeout() {
1729        let handler = create_test_handler(None);
1730
1731        assert_eq!(handler.reconnect_timeout_mins, None);
1732        assert!(!handler.replay);
1733    }
1734
1735    #[rstest]
1736    fn test_handler_with_zero_timeout() {
1737        let handler = create_test_handler(Some(0));
1738
1739        assert_eq!(handler.reconnect_timeout_mins, Some(0));
1740        assert!(!handler.replay);
1741    }
1742
1743    #[rstest]
1744    fn test_subscribe_uses_explicit_parent_stype() {
1745        let mut client = create_test_client();
1746
1747        client
1748            .subscribe(
1749                "definition".to_string(),
1750                vec![InstrumentId::from("ES.FUT.GLBX")],
1751                None,
1752                None,
1753                None,
1754                Some("parent".to_string()),
1755            )
1756            .unwrap();
1757
1758        let command = client.cmd_rx.as_mut().unwrap().try_recv().unwrap();
1759        match command {
1760            HandlerCommand::Subscribe(sub) => {
1761                assert_eq!(sub.schema, dbn::Schema::Definition);
1762                assert_eq!(sub.stype_in, dbn::SType::Parent);
1763                assert_eq!(sub.symbols.to_api_string(), "ES.FUT");
1764            }
1765            other => panic!("expected HandlerCommand::Subscribe, was {other:?}"),
1766        }
1767    }
1768
1769    #[rstest]
1770    fn test_subscribe_rejects_invalid_stype() {
1771        let mut client = create_test_client();
1772
1773        let err = client
1774            .subscribe(
1775                "definition".to_string(),
1776                vec![InstrumentId::from("ES.FUT.GLBX")],
1777                None,
1778                None,
1779                None,
1780                Some("not-a-stype".to_string()),
1781            )
1782            .unwrap_err();
1783
1784        assert!(err.to_string().contains("not-a-stype"));
1785        assert!(!is_command_send_error(&err));
1786        assert!(matches!(
1787            client.cmd_rx.as_mut().unwrap().try_recv(),
1788            Err(tokio::sync::mpsc::error::TryRecvError::Empty)
1789        ));
1790    }
1791
1792    #[rstest]
1793    fn test_subscribe_classifies_command_send_errors() {
1794        let mut client = create_test_client();
1795        client.cmd_rx = None;
1796
1797        let err = client
1798            .subscribe(
1799                "definition".to_string(),
1800                vec![InstrumentId::from("ES.FUT.GLBX")],
1801                None,
1802                None,
1803                None,
1804                Some("parent".to_string()),
1805            )
1806            .unwrap_err();
1807
1808        assert!(is_command_send_error(&err));
1809    }
1810
1811    #[rstest]
1812    fn test_close_after_handler_exit_marks_closed() {
1813        let mut client = create_test_client();
1814        let (handler, _msg_rx) = client.start().unwrap();
1815        drop(handler);
1816
1817        client.close().unwrap();
1818
1819        assert!(!client.is_running());
1820        assert!(client.is_closed());
1821    }
1822
1823    fn test_delta(instrument_id: InstrumentId, ts_event: u64) -> OrderBookDelta {
1824        OrderBookDelta::clear(instrument_id, 0, ts_event.into(), 0.into())
1825    }
1826
1827    #[rstest]
1828    fn test_mbo_delta_without_f_last_buffers() {
1829        let instrument_id = InstrumentId::from("ESM4.GLBX");
1830        let delta = test_delta(instrument_id, 1_000_000_000);
1831        let mut buffering_start = None;
1832        let mut buffered = AHashMap::new();
1833
1834        let result = process_mbo_delta(delta, 0, &mut buffering_start, &mut buffered);
1835
1836        assert!(result.is_none());
1837        assert_eq!(buffered[&instrument_id].len(), 1);
1838    }
1839
1840    #[rstest]
1841    fn test_mbo_single_f_last_emits_without_buffering() {
1842        let instrument_id = InstrumentId::from("ESM4.GLBX");
1843        let ts_event = 1_000_000_000;
1844        let mut delta = test_delta(instrument_id, ts_event);
1845        delta.flags = 128;
1846        delta.sequence = 42;
1847        let mut buffering_start = None;
1848        let mut buffered = AHashMap::new();
1849
1850        let result = process_mbo_delta(delta, delta.flags, &mut buffering_start, &mut buffered);
1851
1852        let emitted = result.expect("single F_LAST delta should emit");
1853        assert_eq!(emitted.instrument_id, instrument_id);
1854        assert_eq!(emitted.deltas.len(), 1);
1855        assert_eq!(emitted.flags, 128);
1856        assert_eq!(emitted.sequence, 42);
1857        assert_eq!(emitted.ts_event, UnixNanos::from(ts_event));
1858        assert_eq!(emitted.deltas[0].instrument_id, instrument_id);
1859        assert_eq!(emitted.deltas[0].flags, 128);
1860        assert_eq!(emitted.deltas[0].sequence, 42);
1861        assert_eq!(emitted.deltas[0].ts_event, UnixNanos::from(ts_event));
1862        assert!(buffering_start.is_none());
1863        assert!(buffered.is_empty());
1864    }
1865
1866    #[rstest]
1867    fn test_mbo_delta_with_f_last_emits() {
1868        let instrument_id = InstrumentId::from("ESM4.GLBX");
1869        let mut buffering_start = None;
1870        let mut buffered = AHashMap::new();
1871
1872        let _ = process_mbo_delta(
1873            test_delta(instrument_id, 1_000_000_000),
1874            0,
1875            &mut buffering_start,
1876            &mut buffered,
1877        );
1878
1879        let result = process_mbo_delta(
1880            test_delta(instrument_id, 2_000_000_000),
1881            128, // F_LAST
1882            &mut buffering_start,
1883            &mut buffered,
1884        );
1885
1886        assert!(result.is_some());
1887        assert_eq!(result.unwrap().deltas.len(), 2);
1888        assert!(buffered.is_empty());
1889    }
1890
1891    #[rstest]
1892    fn test_mbo_snapshot_with_f_last_buffers() {
1893        let instrument_id = InstrumentId::from("ESM4.GLBX");
1894        let mut buffering_start = None;
1895        let mut buffered = AHashMap::new();
1896
1897        let result = process_mbo_delta(
1898            test_delta(instrument_id, 1_000_000_000),
1899            128 | 32, // F_LAST | F_SNAPSHOT
1900            &mut buffering_start,
1901            &mut buffered,
1902        );
1903
1904        assert!(result.is_none());
1905        assert_eq!(buffered[&instrument_id].len(), 1);
1906    }
1907
1908    #[rstest]
1909    fn test_mbo_replay_buffers_until_past_start() {
1910        let instrument_id = InstrumentId::from("ESM4.GLBX");
1911        let start_ns = 5_000_000_000u64;
1912        let mut buffering_start = Some(start_ns.into());
1913        let mut buffered = AHashMap::new();
1914
1915        let result = process_mbo_delta(
1916            test_delta(instrument_id, 4_000_000_000),
1917            128, // F_LAST
1918            &mut buffering_start,
1919            &mut buffered,
1920        );
1921        assert!(result.is_none());
1922
1923        let result = process_mbo_delta(
1924            test_delta(instrument_id, 5_000_000_000),
1925            128,
1926            &mut buffering_start,
1927            &mut buffered,
1928        );
1929        assert!(result.is_none());
1930
1931        // Delta past start: emits and clears buffering_start
1932        let result = process_mbo_delta(
1933            test_delta(instrument_id, 6_000_000_000),
1934            128,
1935            &mut buffering_start,
1936            &mut buffered,
1937        );
1938        assert!(result.is_some());
1939        assert!(buffering_start.is_none());
1940    }
1941
1942    #[rstest]
1943    fn test_mbo_multiple_deltas_accumulated() {
1944        let instrument_id = InstrumentId::from("ESM4.GLBX");
1945        let mut buffering_start = None;
1946        let mut buffered = AHashMap::new();
1947
1948        for i in 0..5 {
1949            process_mbo_delta(
1950                test_delta(instrument_id, 1_000_000_000 + i),
1951                0,
1952                &mut buffering_start,
1953                &mut buffered,
1954            );
1955        }
1956
1957        let result = process_mbo_delta(
1958            test_delta(instrument_id, 2_000_000_000),
1959            128,
1960            &mut buffering_start,
1961            &mut buffered,
1962        );
1963
1964        assert!(result.is_some());
1965        assert_eq!(result.unwrap().deltas.len(), 6);
1966    }
1967
1968    #[rstest]
1969    fn test_mbo_multi_instrument_isolation() {
1970        let id_a = InstrumentId::from("ESM4.GLBX");
1971        let id_b = InstrumentId::from("NQM4.GLBX");
1972        let mut buffering_start = None;
1973        let mut buffered = AHashMap::new();
1974
1975        process_mbo_delta(
1976            test_delta(id_a, 1_000_000_000),
1977            0,
1978            &mut buffering_start,
1979            &mut buffered,
1980        );
1981        process_mbo_delta(
1982            test_delta(id_b, 1_000_000_000),
1983            0,
1984            &mut buffering_start,
1985            &mut buffered,
1986        );
1987
1988        // F_LAST for A: only A's deltas emitted, B remains
1989        let result = process_mbo_delta(
1990            test_delta(id_a, 2_000_000_000),
1991            128,
1992            &mut buffering_start,
1993            &mut buffered,
1994        );
1995
1996        assert!(result.is_some());
1997        assert_eq!(result.unwrap().instrument_id, id_a);
1998        assert!(buffered.contains_key(&id_b));
1999        assert!(!buffered.contains_key(&id_a));
2000    }
2001
2002    mod property_tests {
2003        use proptest::prelude::*;
2004        use rstest::rstest;
2005
2006        use super::*;
2007
2008        proptest! {
2009            #[rstest]
2010            fn mbo_buffering_conserves_deltas(
2011                num_non_last in 0usize..=20,
2012            ) {
2013                let instrument_id = InstrumentId::from("ESM4.GLBX");
2014                let mut buffering_start = None;
2015                let mut buffered = AHashMap::new();
2016                let total = num_non_last + 1;
2017
2018                for i in 0..num_non_last {
2019                    let result = process_mbo_delta(
2020                        test_delta(instrument_id, 1_000_000_000 + i as u64),
2021                        0, // No F_LAST
2022                        &mut buffering_start,
2023                        &mut buffered,
2024                    );
2025                    prop_assert!(result.is_none());
2026                }
2027
2028                let result = process_mbo_delta(
2029                    test_delta(instrument_id, 2_000_000_000),
2030                    128, // F_LAST
2031                    &mut buffering_start,
2032                    &mut buffered,
2033                );
2034
2035                prop_assert!(result.is_some());
2036                let emitted = result.unwrap();
2037                prop_assert_eq!(emitted.deltas.len(), total);
2038                prop_assert!(buffered.is_empty());
2039            }
2040
2041            #[rstest]
2042            fn mbo_snapshots_never_emit(
2043                num_snapshots in 1usize..=20,
2044            ) {
2045                let instrument_id = InstrumentId::from("ESM4.GLBX");
2046                let mut buffering_start = None;
2047                let mut buffered = AHashMap::new();
2048
2049                for i in 0..num_snapshots {
2050                    let result = process_mbo_delta(
2051                        test_delta(instrument_id, 1_000_000_000 + i as u64),
2052                        128 | 32, // F_LAST | F_SNAPSHOT
2053                        &mut buffering_start,
2054                        &mut buffered,
2055                    );
2056                    prop_assert!(result.is_none());
2057                }
2058
2059                prop_assert_eq!(buffered[&instrument_id].len(), num_snapshots);
2060            }
2061
2062            #[rstest]
2063            fn mbo_replay_delays_emission(
2064                start_offset in 1u64..=100,
2065                num_before in 1usize..=10,
2066            ) {
2067                let instrument_id = InstrumentId::from("ESM4.GLBX");
2068                let start_ns = 1_000_000_000u64 * start_offset;
2069                let mut buffering_start = Some(start_ns.into());
2070                let mut buffered = AHashMap::new();
2071
2072                for i in 0..num_before {
2073                    let ts = start_ns - (num_before as u64 - i as u64);
2074                    let result = process_mbo_delta(
2075                        test_delta(instrument_id, ts),
2076                        128, // F_LAST
2077                        &mut buffering_start,
2078                        &mut buffered,
2079                    );
2080                    prop_assert!(result.is_none());
2081                }
2082
2083                let result = process_mbo_delta(
2084                    test_delta(instrument_id, start_ns + 1),
2085                    128,
2086                    &mut buffering_start,
2087                    &mut buffered,
2088                );
2089
2090                prop_assert!(result.is_some());
2091                prop_assert!(buffering_start.is_none());
2092                let total = num_before + 1;
2093                prop_assert_eq!(result.unwrap().deltas.len(), total);
2094            }
2095        }
2096    }
2097}