Skip to main content

nautilus_databento/decode/
market_data.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
16use core::fmt::NumBuffer;
17use std::{collections::VecDeque, ffi::c_char, num::NonZeroUsize};
18
19use ahash::AHashMap;
20use databento::dbn;
21use nautilus_core::{UnixNanos, datetime::NANOSECONDS_IN_SECOND};
22use nautilus_model::{
23    data::{
24        Bar, BarSpecification, BarType, BookOrder, DEPTH10_LEN, Data, InstrumentStatus,
25        OrderBookDelta, OrderBookDepth10, QuoteTick, TradeTick,
26    },
27    enums::{AggregationSource, BarAggregation, FromU16, MarketStatusAction, OrderSide, PriceType},
28    identifiers::{InstrumentId, TradeId},
29};
30
31use super::primitives::{
32    decode_price_or_undef, decode_quantity, parse_aggressor_side, parse_book_action,
33    parse_optional_bool, parse_order_side, parse_status_reason, parse_status_trading_event,
34};
35
36const STEP_ONE: NonZeroUsize = NonZeroUsize::new(1).unwrap();
37
38const BAR_SPEC_1S: BarSpecification = BarSpecification {
39    step: STEP_ONE,
40    aggregation: BarAggregation::Second,
41    price_type: PriceType::Last,
42};
43const BAR_SPEC_1M: BarSpecification = BarSpecification {
44    step: STEP_ONE,
45    aggregation: BarAggregation::Minute,
46    price_type: PriceType::Last,
47};
48const BAR_SPEC_1H: BarSpecification = BarSpecification {
49    step: STEP_ONE,
50    aggregation: BarAggregation::Hour,
51    price_type: PriceType::Last,
52};
53const BAR_SPEC_1D: BarSpecification = BarSpecification {
54    step: STEP_ONE,
55    aggregation: BarAggregation::Day,
56    price_type: PriceType::Last,
57};
58
59pub(super) const BAR_CLOSE_ADJUSTMENT_1S: u64 = NANOSECONDS_IN_SECOND;
60pub(super) const BAR_CLOSE_ADJUSTMENT_1M: u64 = NANOSECONDS_IN_SECOND * 60;
61pub(super) const BAR_CLOSE_ADJUSTMENT_1H: u64 = NANOSECONDS_IN_SECOND * 60 * 60;
62pub(super) const BAR_CLOSE_ADJUSTMENT_1D: u64 = NANOSECONDS_IN_SECOND * 60 * 60 * 24;
63
64// FNV-1a 64-bit constants (see http://www.isthe.com/chongo/tech/comp/fnv/).
65const FNV_OFFSET_BASIS: u64 = 0xcbf2_9ce4_8422_2325;
66const FNV_PRIME: u64 = 0x0100_0000_01b3;
67
68fn fnv1a_mix_bytes(hash: &mut u64, bytes: &[u8]) {
69    for &byte in bytes {
70        *hash ^= u64::from(byte);
71        *hash = hash.wrapping_mul(FNV_PRIME);
72    }
73}
74
75fn fnv1a_finish_field(hash: &mut u64) {
76    *hash ^= 0xff;
77    *hash = hash.wrapping_mul(FNV_PRIME);
78}
79
80fn fnv1a_mix(hash: &mut u64, bytes: &[u8]) {
81    fnv1a_mix_bytes(hash, bytes);
82    fnv1a_finish_field(hash);
83}
84
85/// Derives a deterministic [`TradeId`] for Databento schemas that do not
86/// publish a native trade identifier (e.g. CMBP1, TCBBO).
87///
88/// The hash combines the instrument, timestamps, price, size, and aggressor
89/// side so that replayed data yields the same identifier across runs.
90pub(super) fn derive_cmbp_trade_id(
91    instrument_id: InstrumentId,
92    ts_event: u64,
93    ts_recv: u64,
94    price: i64,
95    size: u32,
96    side: c_char,
97) -> TradeId {
98    let mut hash: u64 = FNV_OFFSET_BASIS;
99    fnv1a_mix_bytes(&mut hash, instrument_id.symbol.as_str().as_bytes());
100    fnv1a_mix_bytes(&mut hash, b".");
101    fnv1a_mix_bytes(&mut hash, instrument_id.venue.as_str().as_bytes());
102    fnv1a_finish_field(&mut hash);
103    fnv1a_mix(&mut hash, &ts_event.to_le_bytes());
104    fnv1a_mix(&mut hash, &ts_recv.to_le_bytes());
105    fnv1a_mix(&mut hash, &price.to_le_bytes());
106    fnv1a_mix(&mut hash, &size.to_le_bytes());
107    fnv1a_mix(&mut hash, &[side as u8]);
108    trade_id_from_hash(hash)
109}
110
111fn trade_id_from_hash(hash: u64) -> TradeId {
112    const HEX: &[u8; 16] = b"0123456789abcdef";
113
114    let mut bytes = [0u8; 16];
115    let mut value = hash;
116    for byte in bytes.iter_mut().rev() {
117        *byte = HEX[(value & 0x0f) as usize];
118        value >>= 4;
119    }
120
121    TradeId::from_bytes(&bytes).expect("16 lowercase hex bytes are valid TradeId")
122}
123
124#[inline(always)]
125#[must_use]
126pub(super) fn is_trade_msg(action: c_char) -> bool {
127    action as u8 as char == 'T'
128}
129
130/// Returns `true` if both bid and ask prices are defined (not `i64::MAX`).
131///
132/// Databento uses `i64::MAX` as a sentinel value for undefined/null prices.
133/// A valid quote requires both sides to be defined.
134#[inline(always)]
135#[must_use]
136fn has_valid_bid_ask(bid_px: i64, ask_px: i64) -> bool {
137    bid_px != i64::MAX && ask_px != i64::MAX
138}
139
140/// Decodes a Databento MBO (Market by Order) message into an order book delta or trade.
141///
142/// Returns a tuple containing either an `OrderBookDelta` or a `TradeTick`, depending on
143/// whether the message represents an order book update or a trade execution.
144///
145/// # Errors
146///
147/// Returns an error if decoding the MBO message fails.
148pub fn decode_mbo_msg(
149    msg: &dbn::MboMsg,
150    instrument_id: InstrumentId,
151    price_precision: u8,
152    ts_init: Option<UnixNanos>,
153    include_trades: bool,
154) -> anyhow::Result<(Option<OrderBookDelta>, Option<TradeTick>)> {
155    let side = parse_order_side(msg.side);
156    if is_trade_msg(msg.action) {
157        if include_trades && msg.size > 0 {
158            let price = decode_price_or_undef(msg.price, price_precision);
159            let size = decode_quantity(msg.size as u64);
160            let aggressor_side = parse_aggressor_side(msg.side);
161            let trade_id = TradeId::new(msg.sequence.format_into(&mut NumBuffer::new()));
162            let ts_event = msg.ts_recv.into();
163            let ts_init = ts_init.unwrap_or(ts_event);
164
165            let trade = TradeTick::new(
166                instrument_id,
167                price,
168                size,
169                aggressor_side,
170                trade_id,
171                ts_event,
172                ts_init,
173            );
174            return Ok((None, Some(trade)));
175        }
176
177        return Ok((None, None));
178    }
179
180    // `Action::Fill` and `Action::None` never affect the book: a fill is
181    // attribution for a resting order, and its book impact arrives as the
182    // explicit Cancel/Modify records of the same match event (CME MDP3
183    // semantics as normalized by Databento). Decoding fills as deltas
184    // corrupts the book - an iceberg hidden-part fill carries an order ID
185    // that was never Added, so `BookAction::Update` materializes a phantom
186    // order which nothing ever deletes (observed as a crossed book on GLBX).
187    if matches!(msg.action(), Ok(dbn::Action::Fill | dbn::Action::None)) {
188        return Ok((None, None));
189    }
190
191    let action = parse_book_action(msg.action)?;
192    let price = decode_price_or_undef(msg.price, price_precision);
193    let size = decode_quantity(msg.size as u64);
194    let order = BookOrder::new(side, price, size, msg.order_id);
195
196    let ts_event = msg.ts_recv.into();
197    let ts_init = ts_init.unwrap_or(ts_event);
198
199    // A replayed snapshot can carry source packet sequences in non-monotonic order,
200    // so it must not advance the book's incremental high-water mark.
201    let sequence = if msg.flags.is_snapshot() {
202        0
203    } else {
204        msg.sequence
205    };
206
207    let delta = OrderBookDelta::new(
208        instrument_id,
209        action,
210        order,
211        msg.flags.raw(),
212        sequence.into(),
213        ts_event,
214        ts_init,
215    );
216
217    Ok((Some(delta), None))
218}
219
220#[derive(Debug)]
221struct QueuedMboDelta {
222    delta: OrderBookDelta,
223    ready: bool,
224}
225
226// TODO: Consider consolidating this boundary framing with the live client while keeping
227// source-order queuing specific to historical data.
228/// Preserves source order while retaining each instrument's unresolved tail until its raw
229/// `F_LAST` boundary can be attached.
230#[derive(Debug, Default)]
231pub(crate) struct MboDeltaBuffer {
232    queue: VecDeque<QueuedMboDelta>,
233    tail: Option<(InstrumentId, u64)>,
234    tails: AHashMap<InstrumentId, u64>,
235    head: u64,
236    next: u64,
237}
238
239impl MboDeltaBuffer {
240    pub(crate) fn push(
241        &mut self,
242        msg: &dbn::MboMsg,
243        instrument_id: InstrumentId,
244        delta: Option<OrderBookDelta>,
245    ) {
246        if let Some(delta) = delta {
247            self.release(instrument_id, 0);
248
249            let index = self.next;
250            self.next = self.next.checked_add(1).expect("MBO delta index overflow");
251            let ready = msg.flags.is_last();
252            self.queue.push_back(QueuedMboDelta { delta, ready });
253
254            if !ready
255                && let Some((tail_instrument_id, tail)) = self.tail.replace((instrument_id, index))
256            {
257                self.tails.insert(tail_instrument_id, tail);
258            }
259        } else if msg.flags.is_last() {
260            self.release(instrument_id, dbn::flags::LAST);
261        }
262    }
263
264    pub(crate) fn pop_ready(&mut self) -> Option<OrderBookDelta> {
265        if !self.queue.front().is_some_and(|queued| queued.ready) {
266            return None;
267        }
268
269        self.head = self.head.checked_add(1).expect("MBO delta index overflow");
270        self.queue.pop_front().map(|queued| queued.delta)
271    }
272
273    pub(crate) fn finish(&mut self) {
274        self.tail = None;
275        self.tails.clear();
276
277        for queued in &mut self.queue {
278            queued.ready = true;
279        }
280    }
281
282    fn release(&mut self, instrument_id: InstrumentId, flags: u8) {
283        let tail = match self.tail {
284            Some((tail_instrument_id, tail)) if tail_instrument_id == instrument_id => {
285                self.tail = None;
286                tail
287            }
288            _ => {
289                let Some(tail) = self.tails.remove(&instrument_id) else {
290                    return;
291                };
292                tail
293            }
294        };
295        let offset = tail
296            .checked_sub(self.head)
297            .and_then(|offset| usize::try_from(offset).ok())
298            .expect("MBO delta tail must be queued");
299        let queued = self
300            .queue
301            .get_mut(offset)
302            .expect("MBO delta tail must be queued");
303        queued.delta.flags |= flags;
304        queued.ready = true;
305    }
306}
307
308/// Decodes a Databento Trade message into a `TradeTick`.
309///
310/// # Errors
311///
312/// Returns an error if decoding the Trade message fails.
313pub fn decode_trade_msg(
314    msg: &dbn::TradeMsg,
315    instrument_id: InstrumentId,
316    price_precision: u8,
317    ts_init: Option<UnixNanos>,
318) -> anyhow::Result<TradeTick> {
319    let ts_event = msg.ts_recv.into();
320    let ts_init = ts_init.unwrap_or(ts_event);
321
322    let trade = TradeTick::new(
323        instrument_id,
324        decode_price_or_undef(msg.price, price_precision),
325        decode_quantity(msg.size as u64),
326        parse_aggressor_side(msg.side),
327        TradeId::new(msg.sequence.format_into(&mut NumBuffer::new())),
328        ts_event,
329        ts_init,
330    );
331
332    Ok(trade)
333}
334
335/// Decodes a Databento TBBO (Top of Book with Trade) message into quote and trade ticks.
336///
337/// Returns `None` for the quote if either bid or ask price is undefined (`i64::MAX`).
338/// The trade is always returned.
339///
340/// # Errors
341///
342/// Returns an error if decoding the TBBO message fails.
343pub fn decode_tbbo_msg(
344    msg: &dbn::TbboMsg,
345    instrument_id: InstrumentId,
346    price_precision: u8,
347    ts_init: Option<UnixNanos>,
348) -> anyhow::Result<(Option<QuoteTick>, TradeTick)> {
349    let top_level = &msg.levels[0];
350    let ts_event = msg.ts_recv.into();
351    let ts_init = ts_init.unwrap_or(ts_event);
352
353    let maybe_quote = if has_valid_bid_ask(top_level.bid_px, top_level.ask_px) {
354        Some(QuoteTick::new(
355            instrument_id,
356            decode_price_or_undef(top_level.bid_px, price_precision),
357            decode_price_or_undef(top_level.ask_px, price_precision),
358            decode_quantity(top_level.bid_sz as u64),
359            decode_quantity(top_level.ask_sz as u64),
360            ts_event,
361            ts_init,
362        ))
363    } else {
364        None
365    };
366
367    let trade = TradeTick::new(
368        instrument_id,
369        decode_price_or_undef(msg.price, price_precision),
370        decode_quantity(msg.size as u64),
371        parse_aggressor_side(msg.side),
372        TradeId::new(msg.sequence.format_into(&mut NumBuffer::new())),
373        ts_event,
374        ts_init,
375    );
376
377    Ok((maybe_quote, trade))
378}
379
380/// Decodes a Databento MBP1 (Market by Price Level 1) message into quote and optional trade ticks.
381///
382/// Returns `None` for the quote if either bid or ask price is undefined (`i64::MAX`).
383///
384/// # Errors
385///
386/// Returns an error if decoding the MBP1 message fails.
387pub fn decode_mbp1_msg(
388    msg: &dbn::Mbp1Msg,
389    instrument_id: InstrumentId,
390    price_precision: u8,
391    ts_init: Option<UnixNanos>,
392    include_trades: bool,
393) -> anyhow::Result<(Option<QuoteTick>, Option<TradeTick>)> {
394    let top_level = &msg.levels[0];
395    let ts_event = msg.ts_recv.into();
396    let ts_init = ts_init.unwrap_or(ts_event);
397
398    let maybe_quote = if has_valid_bid_ask(top_level.bid_px, top_level.ask_px) {
399        Some(QuoteTick::new(
400            instrument_id,
401            decode_price_or_undef(top_level.bid_px, price_precision),
402            decode_price_or_undef(top_level.ask_px, price_precision),
403            decode_quantity(top_level.bid_sz as u64),
404            decode_quantity(top_level.ask_sz as u64),
405            ts_event,
406            ts_init,
407        ))
408    } else {
409        None
410    };
411
412    let maybe_trade = if include_trades && is_trade_msg(msg.action) {
413        Some(TradeTick::new(
414            instrument_id,
415            decode_price_or_undef(msg.price, price_precision),
416            decode_quantity(msg.size as u64),
417            parse_aggressor_side(msg.side),
418            TradeId::new(msg.sequence.format_into(&mut NumBuffer::new())),
419            ts_event,
420            ts_init,
421        ))
422    } else {
423        None
424    };
425
426    Ok((maybe_quote, maybe_trade))
427}
428
429/// Decodes a Databento BBO (Best Bid and Offer) message into a `QuoteTick`.
430///
431/// Returns `None` if either bid or ask price is undefined (`i64::MAX`).
432///
433/// # Errors
434///
435/// Returns an error if decoding the BBO message fails.
436pub fn decode_bbo_msg(
437    msg: &dbn::BboMsg,
438    instrument_id: InstrumentId,
439    price_precision: u8,
440    ts_init: Option<UnixNanos>,
441) -> anyhow::Result<Option<QuoteTick>> {
442    let top_level = &msg.levels[0];
443    if !has_valid_bid_ask(top_level.bid_px, top_level.ask_px) {
444        return Ok(None);
445    }
446
447    let ts_event = msg.ts_recv.into();
448    let ts_init = ts_init.unwrap_or(ts_event);
449
450    let quote = QuoteTick::new(
451        instrument_id,
452        decode_price_or_undef(top_level.bid_px, price_precision),
453        decode_price_or_undef(top_level.ask_px, price_precision),
454        decode_quantity(top_level.bid_sz as u64),
455        decode_quantity(top_level.ask_sz as u64),
456        ts_event,
457        ts_init,
458    );
459
460    Ok(Some(quote))
461}
462
463/// Decodes a Databento MBP10 (Market by Price 10 levels) message into an `OrderBookDepth10`.
464///
465/// # Errors
466///
467/// This function does not currently return an error; the result type matches the common decode API.
468pub fn decode_mbp10_msg(
469    msg: &dbn::Mbp10Msg,
470    instrument_id: InstrumentId,
471    price_precision: u8,
472    ts_init: Option<UnixNanos>,
473) -> anyhow::Result<OrderBookDepth10> {
474    let mut bids = [BookOrder::default(); DEPTH10_LEN];
475    let mut asks = [BookOrder::default(); DEPTH10_LEN];
476    let mut bid_counts = [0u32; DEPTH10_LEN];
477    let mut ask_counts = [0u32; DEPTH10_LEN];
478
479    for (index, level) in msg.levels.iter().enumerate() {
480        // Empty Databento levels carry the i64::MAX sentinel; leave NULL_ORDER
481        // so the matching engine's precision check skips them.
482        if level.bid_px != i64::MAX {
483            bids[index] = BookOrder::new(
484                OrderSide::Buy,
485                decode_price_or_undef(level.bid_px, price_precision),
486                decode_quantity(level.bid_sz as u64),
487                0,
488            );
489        }
490
491        if level.ask_px != i64::MAX {
492            asks[index] = BookOrder::new(
493                OrderSide::Sell,
494                decode_price_or_undef(level.ask_px, price_precision),
495                decode_quantity(level.ask_sz as u64),
496                0,
497            );
498        }
499
500        bid_counts[index] = level.bid_ct;
501        ask_counts[index] = level.ask_ct;
502    }
503
504    let ts_event = msg.ts_recv.into();
505    let ts_init = ts_init.unwrap_or(ts_event);
506
507    let depth = OrderBookDepth10::new(
508        instrument_id,
509        bids,
510        asks,
511        bid_counts,
512        ask_counts,
513        msg.flags.raw(),
514        msg.sequence.into(),
515        ts_event,
516        ts_init,
517    );
518
519    Ok(depth)
520}
521
522/// Decodes a Databento CMBP1 (Consolidated Market by Price Level 1) message.
523///
524/// Returns a tuple containing an optional `QuoteTick` and an optional `TradeTick`.
525/// Returns `None` for the quote if either bid or ask price is undefined (`i64::MAX`).
526///
527/// # Errors
528///
529/// Returns an error if decoding the CMBP1 message fails.
530pub fn decode_cmbp1_msg(
531    msg: &dbn::Cmbp1Msg,
532    instrument_id: InstrumentId,
533    price_precision: u8,
534    ts_init: Option<UnixNanos>,
535    include_trades: bool,
536) -> anyhow::Result<(Option<QuoteTick>, Option<TradeTick>)> {
537    let top_level = &msg.levels[0];
538    let ts_event = msg.ts_recv.into();
539    let ts_init = ts_init.unwrap_or(ts_event);
540
541    let maybe_quote = if has_valid_bid_ask(top_level.bid_px, top_level.ask_px) {
542        Some(QuoteTick::new(
543            instrument_id,
544            decode_price_or_undef(top_level.bid_px, price_precision),
545            decode_price_or_undef(top_level.ask_px, price_precision),
546            decode_quantity(top_level.bid_sz as u64),
547            decode_quantity(top_level.ask_sz as u64),
548            ts_event,
549            ts_init,
550        ))
551    } else {
552        None
553    };
554
555    let maybe_trade = if include_trades && is_trade_msg(msg.action) {
556        // CMBP1 does not publish a native trade ID; derive a deterministic one
557        let trade_id = derive_cmbp_trade_id(
558            instrument_id,
559            msg.hd.ts_event,
560            msg.ts_recv,
561            msg.price,
562            msg.size,
563            msg.side,
564        );
565        Some(TradeTick::new(
566            instrument_id,
567            decode_price_or_undef(msg.price, price_precision),
568            decode_quantity(msg.size as u64),
569            parse_aggressor_side(msg.side),
570            trade_id,
571            ts_event,
572            ts_init,
573        ))
574    } else {
575        None
576    };
577
578    Ok((maybe_quote, maybe_trade))
579}
580
581/// Decodes a Databento CBBO (Consolidated Best Bid and Offer) message.
582///
583/// Returns `None` if either bid or ask price is undefined (`i64::MAX`).
584///
585/// # Errors
586///
587/// Returns an error if decoding the CBBO message fails.
588pub fn decode_cbbo_msg(
589    msg: &dbn::CbboMsg,
590    instrument_id: InstrumentId,
591    price_precision: u8,
592    ts_init: Option<UnixNanos>,
593) -> anyhow::Result<Option<QuoteTick>> {
594    let top_level = &msg.levels[0];
595    if !has_valid_bid_ask(top_level.bid_px, top_level.ask_px) {
596        return Ok(None);
597    }
598
599    let ts_event = msg.ts_recv.into();
600    let ts_init = ts_init.unwrap_or(ts_event);
601
602    let quote = QuoteTick::new(
603        instrument_id,
604        decode_price_or_undef(top_level.bid_px, price_precision),
605        decode_price_or_undef(top_level.ask_px, price_precision),
606        decode_quantity(top_level.bid_sz as u64),
607        decode_quantity(top_level.ask_sz as u64),
608        ts_event,
609        ts_init,
610    );
611
612    Ok(Some(quote))
613}
614
615/// Decodes a Databento TCBBO (Consolidated Top of Book with Trade) message.
616///
617/// Returns `None` for the quote if either bid or ask price is undefined (`i64::MAX`).
618/// The trade is always returned.
619///
620/// # Errors
621///
622/// Returns an error if decoding the TCBBO message fails.
623pub fn decode_tcbbo_msg(
624    msg: &dbn::TcbboMsg,
625    instrument_id: InstrumentId,
626    price_precision: u8,
627    ts_init: Option<UnixNanos>,
628) -> anyhow::Result<(Option<QuoteTick>, TradeTick)> {
629    let (maybe_quote, maybe_trade) =
630        decode_cmbp1_msg(msg, instrument_id, price_precision, ts_init, true)?;
631    let trade = maybe_trade.ok_or_else(|| {
632        anyhow::anyhow!(
633            "Invalid `TcbboMsg`: expected trade action, was {}",
634            msg.action as u8 as char
635        )
636    })?;
637
638    Ok((maybe_quote, trade))
639}
640
641/// # Errors
642///
643/// Returns an error if `rtype` is not a supported bar aggregation.
644pub fn decode_bar_type(
645    msg: &dbn::OhlcvMsg,
646    instrument_id: InstrumentId,
647) -> anyhow::Result<BarType> {
648    let bar_type = match msg.hd.rtype {
649        32 => {
650            // ohlcv-1s
651            BarType::new(instrument_id, BAR_SPEC_1S, AggregationSource::External)
652        }
653        33 => {
654            // ohlcv-1m
655            BarType::new(instrument_id, BAR_SPEC_1M, AggregationSource::External)
656        }
657        34 => {
658            // ohlcv-1h
659            BarType::new(instrument_id, BAR_SPEC_1H, AggregationSource::External)
660        }
661        35 => {
662            // ohlcv-1d
663            BarType::new(instrument_id, BAR_SPEC_1D, AggregationSource::External)
664        }
665        36 => {
666            // ohlcv-eod
667            BarType::new(instrument_id, BAR_SPEC_1D, AggregationSource::External)
668        }
669        _ => anyhow::bail!(
670            "`rtype` is not a supported bar aggregation, was {}",
671            msg.hd.rtype
672        ),
673    };
674
675    Ok(bar_type)
676}
677
678/// # Errors
679///
680/// Returns an error if `rtype` is not a supported bar aggregation.
681pub fn decode_ts_event_adjustment(msg: &dbn::OhlcvMsg) -> anyhow::Result<UnixNanos> {
682    let adjustment = match msg.hd.rtype {
683        32 => {
684            // ohlcv-1s
685            BAR_CLOSE_ADJUSTMENT_1S
686        }
687        33 => {
688            // ohlcv-1m
689            BAR_CLOSE_ADJUSTMENT_1M
690        }
691        34 => {
692            // ohlcv-1h
693            BAR_CLOSE_ADJUSTMENT_1H
694        }
695        35 | 36 => {
696            // ohlcv-1d and ohlcv-eod
697            BAR_CLOSE_ADJUSTMENT_1D
698        }
699        _ => anyhow::bail!(
700            "`rtype` is not a supported bar aggregation, was {}",
701            msg.hd.rtype
702        ),
703    };
704
705    Ok(adjustment.into())
706}
707
708/// # Errors
709///
710/// Returns an error if decoding the OHLCV message fails.
711pub fn decode_ohlcv_msg(
712    msg: &dbn::OhlcvMsg,
713    instrument_id: InstrumentId,
714    price_precision: u8,
715    ts_init: Option<UnixNanos>,
716    timestamp_on_close: bool,
717) -> anyhow::Result<Bar> {
718    let bar_type = decode_bar_type(msg, instrument_id)?;
719    let ts_event_adjustment = decode_ts_event_adjustment(msg)?;
720
721    let ts_event_raw = msg.hd.ts_event.into();
722    let ts_close = ts_event_raw + ts_event_adjustment;
723    let ts_init = ts_init.unwrap_or(ts_close); // received time or close time
724
725    let ts_event = if timestamp_on_close {
726        ts_close
727    } else {
728        ts_event_raw
729    };
730
731    let bar = Bar::new(
732        bar_type,
733        decode_price_or_undef(msg.open, price_precision),
734        decode_price_or_undef(msg.high, price_precision),
735        decode_price_or_undef(msg.low, price_precision),
736        decode_price_or_undef(msg.close, price_precision),
737        decode_quantity(msg.volume),
738        ts_event,
739        ts_init,
740    );
741
742    Ok(bar)
743}
744
745/// Decodes a Databento status message into an `InstrumentStatus` event.
746///
747/// # Errors
748///
749/// Returns an error if decoding the status message fails or if `msg.action` is not a valid `MarketStatusAction`.
750pub fn decode_status_msg(
751    msg: &dbn::StatusMsg,
752    instrument_id: InstrumentId,
753    ts_init: Option<UnixNanos>,
754) -> anyhow::Result<InstrumentStatus> {
755    let ts_event = msg.hd.ts_event.into();
756    let ts_init = ts_init.unwrap_or(ts_event);
757
758    let action = MarketStatusAction::from_u16(msg.action)
759        .ok_or_else(|| anyhow::anyhow!("Invalid `MarketStatusAction` value: {}", msg.action))?;
760
761    let status = InstrumentStatus::new(
762        instrument_id,
763        action,
764        ts_event,
765        ts_init,
766        parse_status_reason(msg.reason)?,
767        parse_status_trading_event(msg.trading_event)?,
768        parse_optional_bool(msg.is_trading),
769        parse_optional_bool(msg.is_quoting),
770        parse_optional_bool(msg.is_short_sell_restricted),
771    );
772
773    Ok(status)
774}
775
776/// # Errors
777///
778/// Returns an error if decoding the record type fails or encounters unsupported message.
779pub fn decode_record(
780    record: &dbn::RecordRef,
781    instrument_id: InstrumentId,
782    price_precision: u8,
783    ts_init: Option<UnixNanos>,
784    include_trades: bool,
785    bars_timestamp_on_close: bool,
786) -> anyhow::Result<(Option<Data>, Option<Data>)> {
787    let result = if let Some(msg) = record.get::<dbn::MboMsg>() {
788        let ts_init = determine_timestamp(ts_init, msg.ts_recv.into());
789        let result = decode_mbo_msg(
790            msg,
791            instrument_id,
792            price_precision,
793            Some(ts_init),
794            include_trades,
795        )?;
796
797        match result {
798            (Some(delta), None) => (Some(Data::Delta(delta)), None),
799            (None, Some(trade)) => (Some(Data::Trade(trade)), None),
800            (None, None) => (None, None),
801            _ => anyhow::bail!("Invalid `MboMsg` parsing combination"),
802        }
803    } else if let Some(msg) = record.get::<dbn::TradeMsg>() {
804        let ts_init = determine_timestamp(ts_init, msg.ts_recv.into());
805        let trade = decode_trade_msg(msg, instrument_id, price_precision, Some(ts_init))?;
806        (Some(Data::Trade(trade)), None)
807    } else if let Some(msg) = record.get::<dbn::Mbp1Msg>() {
808        let ts_init = determine_timestamp(ts_init, msg.ts_recv.into());
809        let (maybe_quote, maybe_trade) = decode_mbp1_msg(
810            msg,
811            instrument_id,
812            price_precision,
813            Some(ts_init),
814            include_trades,
815        )?;
816        (maybe_quote.map(Data::Quote), maybe_trade.map(Data::Trade))
817    } else if let Some(msg) = record.get::<dbn::Bbo1SMsg>() {
818        let ts_init = determine_timestamp(ts_init, msg.ts_recv.into());
819        let maybe_quote = decode_bbo_msg(msg, instrument_id, price_precision, Some(ts_init))?;
820        (maybe_quote.map(Data::Quote), None)
821    } else if let Some(msg) = record.get::<dbn::Bbo1MMsg>() {
822        let ts_init = determine_timestamp(ts_init, msg.ts_recv.into());
823        let maybe_quote = decode_bbo_msg(msg, instrument_id, price_precision, Some(ts_init))?;
824        (maybe_quote.map(Data::Quote), None)
825    } else if let Some(msg) = record.get::<dbn::Mbp10Msg>() {
826        let ts_init = determine_timestamp(ts_init, msg.ts_recv.into());
827        let depth = decode_mbp10_msg(msg, instrument_id, price_precision, Some(ts_init))?;
828        (Some(Data::from(depth)), None)
829    } else if let Some(msg) = record.get::<dbn::OhlcvMsg>() {
830        // if ts_init is None (like with historical data) we don't want it to be equal to ts_event
831        // it will be set correctly in decode_ohlcv_msg instead
832        let bar = decode_ohlcv_msg(
833            msg,
834            instrument_id,
835            price_precision,
836            ts_init,
837            bars_timestamp_on_close,
838        )?;
839        (Some(Data::Bar(bar)), None)
840    } else if let Some(msg) = record.get::<dbn::Cmbp1Msg>() {
841        let ts_init = determine_timestamp(ts_init, msg.ts_recv.into());
842        if msg.hd.rtype == dbn::enums::rtype::TCBBO {
843            let (maybe_quote, trade) =
844                decode_tcbbo_msg(msg, instrument_id, price_precision, Some(ts_init))?;
845            (maybe_quote.map(Data::Quote), Some(Data::Trade(trade)))
846        } else {
847            let (maybe_quote, maybe_trade) = decode_cmbp1_msg(
848                msg,
849                instrument_id,
850                price_precision,
851                Some(ts_init),
852                include_trades,
853            )?;
854            (maybe_quote.map(Data::Quote), maybe_trade.map(Data::Trade))
855        }
856    } else if let Some(msg) = record.get::<dbn::TbboMsg>() {
857        // TBBO always has a trade, quote may be skipped if prices undefined
858        let ts_init = determine_timestamp(ts_init, msg.ts_recv.into());
859        let (maybe_quote, trade) =
860            decode_tbbo_msg(msg, instrument_id, price_precision, Some(ts_init))?;
861        (maybe_quote.map(Data::Quote), Some(Data::Trade(trade)))
862    } else if let Some(msg) = record.get::<dbn::CbboMsg>() {
863        let ts_init = determine_timestamp(ts_init, msg.ts_recv.into());
864        let maybe_quote = decode_cbbo_msg(msg, instrument_id, price_precision, Some(ts_init))?;
865        (maybe_quote.map(Data::Quote), None)
866    } else {
867        anyhow::bail!("DBN message type is not currently supported")
868    };
869
870    Ok(result)
871}
872
873const fn determine_timestamp(ts_init: Option<UnixNanos>, msg_timestamp: UnixNanos) -> UnixNanos {
874    match ts_init {
875        Some(ts_init) => ts_init,
876        None => msg_timestamp,
877    }
878}