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