Skip to main content

nautilus_databento/decode/
primitives.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 std::ffi::c_char;
17
18use databento::dbn;
19use nautilus_core::UnixNanos;
20use nautilus_model::{
21    enums::{AggressorSide, AssetClass, BookAction, InstrumentClass, OptionKind, OrderSide},
22    identifiers::Symbol,
23    types::{
24        Currency, Price, Quantity,
25        price::{PRICE_UNDEF, decode_raw_price_i64},
26    },
27};
28use ustr::Ustr;
29
30#[must_use]
31pub const fn parse_optional_bool(c: c_char) -> Option<bool> {
32    match c as u8 as char {
33        'Y' => Some(true),
34        'N' => Some(false),
35        _ => None,
36    }
37}
38
39#[must_use]
40pub const fn parse_order_side(c: c_char) -> Option<OrderSide> {
41    match c as u8 as char {
42        'A' => Some(OrderSide::Sell),
43        'B' => Some(OrderSide::Buy),
44        _ => None,
45    }
46}
47
48#[must_use]
49pub const fn parse_aggressor_side(c: c_char) -> AggressorSide {
50    match c as u8 as char {
51        'A' => AggressorSide::Sell,
52        'B' => AggressorSide::Buy,
53        _ => AggressorSide::NoAggressor,
54    }
55}
56
57/// Parses a Databento book action character into a `BookAction` enum.
58///
59/// # Errors
60///
61/// Returns an error if `c` is not a valid `BookAction` character.
62pub fn parse_book_action(c: c_char) -> anyhow::Result<BookAction> {
63    match c as u8 as char {
64        'A' => Ok(BookAction::Add),
65        'C' => Ok(BookAction::Delete),
66        'M' => Ok(BookAction::Update),
67        'R' => Ok(BookAction::Clear),
68        // 'F' (Fill) and 'N' (None) are deliberately NOT book actions: fills
69        // are attribution records whose book impact arrives as the explicit
70        // Cancel/Modify of the same match event (`decode_mbo_msg` filters
71        // them out before calling this).
72        invalid => anyhow::bail!("Invalid `BookAction`, was '{invalid}'"),
73    }
74}
75
76/// Parses a Databento option kind character into an `OptionKind` enum.
77///
78/// # Errors
79///
80/// Returns an error if `c` is not a valid `OptionKind` character.
81pub fn parse_option_kind(c: c_char) -> anyhow::Result<OptionKind> {
82    match c as u8 as char {
83        'C' => Ok(OptionKind::Call),
84        'P' => Ok(OptionKind::Put),
85        invalid => anyhow::bail!("Invalid `OptionKind`, was '{invalid}'"),
86    }
87}
88
89pub(super) fn parse_currency_or_usd_default(
90    value: Result<&str, impl std::error::Error>,
91) -> Currency {
92    match value {
93        Ok(value) if !value.is_empty() => Currency::try_from_str(value).unwrap_or_else(|| {
94            log::warn!("Unknown currency code '{value}', defaulting to USD");
95            Currency::USD()
96        }),
97        Ok(_) => Currency::USD(),
98        Err(e) => {
99            log::warn!("Error parsing currency: {e}");
100            Currency::USD()
101        }
102    }
103}
104
105/// Parses a CFI (Classification of Financial Instruments) code to extract asset and instrument classes.
106///
107/// Returns `(None, None)` if `value` has fewer than 3 characters.
108#[must_use]
109pub fn parse_cfi_iso10926(value: &str) -> (Option<AssetClass>, Option<InstrumentClass>) {
110    let chars: Vec<char> = value.chars().collect();
111    if chars.len() < 3 {
112        return (None, None);
113    }
114
115    // TODO: A proper CFI parser would be useful: https://en.wikipedia.org/wiki/ISO_10962
116    let cfi_category = chars[0];
117    let cfi_group = chars[1];
118    let cfi_attribute1 = chars[2];
119    // let cfi_attribute2 = value[3];
120    // let cfi_attribute3 = value[4];
121    // let cfi_attribute4 = value[5];
122
123    let mut asset_class = match cfi_category {
124        'D' => Some(AssetClass::Debt),
125        'E' => Some(AssetClass::Equity),
126        'S' => None,
127        _ => None,
128    };
129
130    let instrument_class = match cfi_group {
131        'I' => Some(InstrumentClass::Future),
132        _ => None,
133    };
134
135    if cfi_attribute1 == 'I' {
136        asset_class = Some(AssetClass::Index);
137    }
138
139    (asset_class, instrument_class)
140}
141
142pub(super) fn decode_underlying(underlying_str: &str, symbol: &Symbol) -> Ustr {
143    if underlying_str.is_empty() {
144        // Fall back to first whitespace-separated token from symbol
145        symbol
146            .as_str()
147            .split_whitespace()
148            .next()
149            .map_or_else(|| symbol.inner(), Ustr::from)
150    } else {
151        Ustr::from(underlying_str)
152    }
153}
154
155/// Parses a Databento status reason code into a human-readable string.
156///
157/// See: <https://databento.com/docs/schemas-and-data-formats/status#types-of-status-reasons>
158///
159/// # Errors
160///
161/// Returns an error if `value` is an invalid status reason code.
162pub fn parse_status_reason(value: u16) -> anyhow::Result<Option<Ustr>> {
163    let value_str = match value {
164        0 => return Ok(None),
165        1 => "Scheduled",
166        2 => "Surveillance intervention",
167        3 => "Market event",
168        4 => "Instrument activation",
169        5 => "Instrument expiration",
170        6 => "Recovery in process",
171        10 => "Regulatory",
172        11 => "Administrative",
173        12 => "Non-compliance",
174        13 => "Filings not current",
175        14 => "SEC trading suspension",
176        15 => "New issue",
177        16 => "Issue available",
178        17 => "Issues reviewed",
179        18 => "Filing requirements satisfied",
180        30 => "News pending",
181        31 => "News released",
182        32 => "News and resumption times",
183        33 => "News not forthcoming",
184        40 => "Order imbalance",
185        50 => "LULD pause",
186        60 => "Operational",
187        70 => "Additional information requested",
188        80 => "Merger effective",
189        90 => "ETF",
190        100 => "Corporate action",
191        110 => "New Security offering",
192        120 => "Market wide halt level 1",
193        121 => "Market wide halt level 2",
194        122 => "Market wide halt level 3",
195        123 => "Market wide halt carryover",
196        124 => "Market wide halt resumption",
197        130 => "Quotation not available",
198        invalid => anyhow::bail!("Invalid `StatusMsg` reason, was '{invalid}'"),
199    };
200
201    Ok(Some(Ustr::from(value_str)))
202}
203
204/// Parses a Databento status trading event code into a human-readable string.
205///
206/// # Errors
207///
208/// Returns an error if `value` is an invalid status trading event code.
209pub fn parse_status_trading_event(value: u16) -> anyhow::Result<Option<Ustr>> {
210    let value_str = match value {
211        0 => return Ok(None),
212        1 => "No cancel",
213        2 => "Change trading session",
214        3 => "Implied matching on",
215        4 => "Implied matching off",
216        _ => anyhow::bail!("Invalid `StatusMsg` trading_event, was '{value}'"),
217    };
218
219    Ok(Some(Ustr::from(value_str)))
220}
221
222/// Decodes a price, returning an error if undefined.
223///
224/// Databento uses `i64::MAX` as a sentinel value for unset/null prices (see
225/// [`UNDEF_PRICE`](https://docs.rs/dbn/latest/dbn/constant.UNDEF_PRICE.html)).
226///
227/// # Errors
228///
229/// Returns an error if `value` is `i64::MAX` (undefined).
230#[inline(always)]
231pub fn decode_price(value: i64, precision: u8, field_name: &str) -> anyhow::Result<Price> {
232    if value == i64::MAX {
233        anyhow::bail!("Missing required price for `{field_name}`")
234    } else {
235        Ok(Price::from_raw(decode_raw_price_i64(value), precision))
236    }
237}
238
239/// Decodes a price from the given optional value, expressed in units of 1e-9.
240///
241/// Databento uses `i64::MAX` as a sentinel value for unset/null prices (see
242/// [`UNDEF_PRICE`](https://docs.rs/dbn/latest/dbn/constant.UNDEF_PRICE.html)).
243#[inline(always)]
244#[must_use]
245pub fn decode_optional_price(value: i64, precision: u8) -> Option<Price> {
246    if value == i64::MAX {
247        None
248    } else {
249        Some(Price::from_raw(decode_raw_price_i64(value), precision))
250    }
251}
252
253/// Decodes a price, returning `PRICE_UNDEF` if the value is undefined.
254///
255/// This is used for market data where undefined prices should pass through
256/// as `PRICE_UNDEF` rather than causing an error.
257#[inline(always)]
258#[must_use]
259pub fn decode_price_or_undef(value: i64, precision: u8) -> Price {
260    if value == i64::MAX {
261        Price::from_raw(PRICE_UNDEF, 0)
262    } else {
263        Price::from_raw(decode_raw_price_i64(value), precision)
264    }
265}
266
267/// Computes the minimum decimal precision needed to represent a raw price value
268/// expressed in units of 1e-9, by counting trailing decimal zeros.
269///
270/// For example, a raw value of `3_906_250` (representing 0.00390625) has 1 trailing
271/// zero, so the precision is `9 - 1 = 8`.
272#[inline(always)]
273#[must_use]
274pub fn precision_from_raw(value: i64) -> u8 {
275    let mut v = value.unsigned_abs();
276    if v == 0 {
277        return 0;
278    }
279    let mut trailing = 0u8;
280    while trailing < 9 && v.is_multiple_of(10) {
281        v /= 10;
282        trailing += 1;
283    }
284    9 - trailing
285}
286
287/// Decodes a minimum price increment from the given value, expressed in units of 1e-9.
288///
289/// The precision is derived from the actual tick value to avoid truncation of
290/// fractional tick sizes (e.g., treasury futures with 1/256 or 1/32 ticks).
291/// The derived precision is floored at `precision` (typically the currency precision).
292///
293/// # Panics
294///
295/// Panics if `precision` exceeds the supported `Price` precision.
296#[inline(always)]
297#[must_use]
298pub fn decode_price_increment(value: i64, precision: u8) -> Price {
299    match value {
300        0 | i64::MAX => {
301            let exponent = i8::try_from(precision).expect("precision exceeded i8 range");
302            Price::from_mantissa_exponent(1, -exponent, precision)
303        }
304        _ => {
305            let derived = precision_from_raw(value).max(precision);
306            Price::from_raw(decode_raw_price_i64(value), derived)
307        }
308    }
309}
310
311/// Decodes a quantity from the given value, expressed in standard whole-number units.
312#[inline(always)]
313#[must_use]
314pub fn decode_quantity(value: u64) -> Quantity {
315    quantity_from_whole(value)
316}
317
318/// Decodes a quantity from the given optional value, where `i64::MAX` indicates missing data.
319///
320/// # Errors
321///
322/// Returns an error if the quantity is negative.
323#[inline(always)]
324pub fn decode_optional_quantity(value: i64) -> anyhow::Result<Option<Quantity>> {
325    match value {
326        i64::MAX => Ok(None),
327        value if value >= 0 => Ok(Some(quantity_from_whole(value as u64))),
328        value => anyhow::bail!("Invalid negative quantity: {value}"),
329    }
330}
331
332/// Decodes a timestamp, returning an error if undefined.
333///
334/// Databento uses `u64::MAX` as `UNDEF_TIMESTAMP` sentinel for null timestamps.
335///
336/// # Errors
337///
338/// Returns an error if `value` is `u64::MAX` (undefined).
339#[inline(always)]
340pub fn decode_timestamp(value: u64, field_name: &str) -> anyhow::Result<UnixNanos> {
341    if value == dbn::UNDEF_TIMESTAMP {
342        anyhow::bail!("Missing required timestamp for `{field_name}`")
343    } else {
344        Ok(UnixNanos::from(value))
345    }
346}
347
348/// Decodes a timestamp from the given optional value.
349///
350/// Databento uses `u64::MAX` as `UNDEF_TIMESTAMP` sentinel for null timestamps.
351#[inline(always)]
352#[must_use]
353pub fn decode_optional_timestamp(value: u64) -> Option<UnixNanos> {
354    if value == dbn::UNDEF_TIMESTAMP {
355        None
356    } else {
357        Some(UnixNanos::from(value))
358    }
359}
360
361/// Decodes a multiplier from the given value, expressed in units of 1e-9.
362/// Uses exact integer arithmetic to avoid precision loss in financial calculations.
363///
364/// # Errors
365///
366/// Returns an error if value is negative (invalid multiplier).
367pub fn decode_multiplier(value: i64) -> anyhow::Result<Quantity> {
368    const SCALE: u64 = 1_000_000_000;
369
370    match value {
371        0 | i64::MAX => Ok(quantity_from_whole(1)),
372        v if v < 0 => anyhow::bail!("Invalid negative multiplier: {v}"),
373        v => {
374            let mantissa = v as u64;
375            let mut frac_part = mantissa % SCALE;
376            let mut precision = 9u8;
377            while precision > 0 && frac_part.is_multiple_of(10) {
378                frac_part /= 10;
379                precision -= 1;
380            }
381
382            Ok(Quantity::from_mantissa_exponent_checked(
383                mantissa, -9, precision,
384            )?)
385        }
386    }
387}
388
389/// Decodes a lot size from the given value, expressed in standard whole-number units.
390///
391/// # Panics
392///
393/// Panics if `value` is negative.
394#[inline(always)]
395#[must_use]
396pub fn decode_lot_size(value: i32) -> Quantity {
397    match value {
398        0 | i32::MAX => quantity_from_whole(1),
399        value => {
400            assert!(value >= 0, "Invalid negative lot size: {value}");
401            quantity_from_whole(value as u64)
402        }
403    }
404}
405
406#[inline(always)]
407#[must_use]
408fn quantity_from_whole(value: u64) -> Quantity {
409    Quantity::from_mantissa_exponent(value, 0, 0)
410}