Skip to main content

nautilus_serialization/arrow/display/
mod.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//! Display-mode Arrow encoders for Nautilus types.
17//!
18//! These encoders emit schemas compatible with display pipelines that cannot
19//! consume `FixedSizeBinary` columns.
20//! Prices and quantities render as `Float64` via `.as_f64()`, `instrument_id` becomes a
21//! `Utf8` column rather than batch metadata (so mixed-instrument batches work), and
22//! timestamps render as `Timestamp(Nanosecond, None)` rather than `UInt64`.
23//!
24//! The conversion is lossy: precision metadata is discarded when values cast to `f64`.
25//! For catalog storage that must round-trip, use the `FixedSizeBinary` encoders in
26//! the parent [`crate::arrow`] module instead.
27
28pub mod account_state;
29pub mod bar;
30pub mod close;
31pub mod delta;
32pub mod depth;
33pub mod index_price;
34pub mod instrument;
35pub mod mark_price;
36pub mod order_filled;
37pub mod position;
38pub mod quote;
39pub mod report;
40pub mod trade;
41
42use arrow::datatypes::{DataType, Field, TimeUnit};
43use nautilus_model::types::{
44    Money, Price, Quantity, fixed::MAX_FLOAT_PRECISION, price::PRICE_ERROR,
45};
46use rust_decimal::prelude::ToPrimitive;
47
48/// Upper bound on precision the display encoders accept. Values above this are
49/// treated as pathological sentinels (most notably `ERROR_PRICE`, which carries
50/// `precision: 255`) and emit `NaN`. Legitimate high-precision inputs top out at
51/// `nautilus_model::defi::WEI_PRECISION` (18).
52const DISPLAY_MAX_PRECISION: u8 = 18;
53
54/// Builds a non-nullable `Utf8` field with the given name.
55pub(super) fn utf8_field(name: &str, nullable: bool) -> Field {
56    Field::new(name, DataType::Utf8, nullable)
57}
58
59/// Builds a `Boolean` field with the given name and nullability.
60pub(super) fn bool_field(name: &str, nullable: bool) -> Field {
61    Field::new(name, DataType::Boolean, nullable)
62}
63
64/// Builds a `Float64` field with the given name and nullability.
65pub(super) fn float64_field(name: &str, nullable: bool) -> Field {
66    Field::new(name, DataType::Float64, nullable)
67}
68
69/// Builds a `UInt8` field with the given name and nullability.
70pub(super) fn uint8_field(name: &str, nullable: bool) -> Field {
71    Field::new(name, DataType::UInt8, nullable)
72}
73
74/// Builds a `UInt32` field with the given name and nullability.
75pub(super) fn uint32_field(name: &str, nullable: bool) -> Field {
76    Field::new(name, DataType::UInt32, nullable)
77}
78
79/// Builds a `UInt64` field with the given name and nullability.
80pub(super) fn uint64_field(name: &str, nullable: bool) -> Field {
81    Field::new(name, DataType::UInt64, nullable)
82}
83
84/// Builds a `Timestamp(Nanosecond, None)` field with the given name and nullability.
85pub(super) fn timestamp_field(name: &str, nullable: bool) -> Field {
86    Field::new(
87        name,
88        DataType::Timestamp(TimeUnit::Nanosecond, None),
89        nullable,
90    )
91}
92
93/// Converts a `u64` nanosecond timestamp to the `i64` expected by Arrow.
94///
95/// Nautilus timestamps fit comfortably in `i64`, but this clamps defensively
96/// to avoid an overflow panic on the cast.
97pub(super) fn unix_nanos_to_i64(value: u64) -> i64 {
98    i64::try_from(value).unwrap_or(i64::MAX)
99}
100
101/// Converts a [`Price`] to `f64` for display without panicking.
102///
103/// Returns [`f64::NAN`] for sentinel values (`PRICE_UNDEF`, `PRICE_ERROR`,
104/// and the `ERROR_PRICE` synthetic with `precision: 255`), so clear-style
105/// order book deltas and error sentinels render as missing cells instead of
106/// bogus numeric values. [`Price::as_f64`] panics when the `defi` feature is
107/// enabled and precision exceeds [`MAX_FLOAT_PRECISION`] (16), so this helper
108/// falls back to a [`rust_decimal::Decimal`] conversion in that range. The
109/// decimal path returns [`f64::NAN`] if the value is outside `f64` range.
110pub(super) fn price_to_f64(price: &Price) -> f64 {
111    if price.is_undefined() || price.raw == PRICE_ERROR || price.precision > DISPLAY_MAX_PRECISION {
112        return f64::NAN;
113    }
114
115    if price.precision <= MAX_FLOAT_PRECISION {
116        price.as_f64()
117    } else {
118        price.as_decimal().to_f64().unwrap_or(f64::NAN)
119    }
120}
121
122/// Converts a [`Quantity`] to `f64` for display without panicking.
123///
124/// See [`price_to_f64`] for the rationale. Returns [`f64::NAN`] for the
125/// `QUANTITY_UNDEF` sentinel and for pathological precisions.
126pub(super) fn quantity_to_f64(quantity: &Quantity) -> f64 {
127    if quantity.is_undefined() || quantity.precision > DISPLAY_MAX_PRECISION {
128        return f64::NAN;
129    }
130
131    if quantity.precision <= MAX_FLOAT_PRECISION {
132        quantity.as_f64()
133    } else {
134        quantity.as_decimal().to_f64().unwrap_or(f64::NAN)
135    }
136}
137
138/// Converts a [`Money`] amount to `f64` for display without panicking.
139///
140/// [`Money::as_f64`] panics under `feature = "defi"` when the currency
141/// precision exceeds [`MAX_FLOAT_PRECISION`] (16); high-precision tokens
142/// (e.g. 18-decimal ERC-20s) would otherwise abort an entire display batch.
143/// This helper guards pathological precisions and falls back to the decimal
144/// path for 17-18 decimal currencies. Returns [`f64::NAN`] if the value is
145/// outside `f64` range.
146pub(super) fn money_to_f64(money: &Money) -> f64 {
147    if money.currency.precision > DISPLAY_MAX_PRECISION {
148        return f64::NAN;
149    }
150
151    if money.currency.precision <= MAX_FLOAT_PRECISION {
152        money.as_f64()
153    } else {
154        money.as_decimal().to_f64().unwrap_or(f64::NAN)
155    }
156}
157
158#[cfg(test)]
159mod tests {
160    use nautilus_model::types::{
161        Currency, Money, Price, Quantity,
162        price::{ERROR_PRICE, PRICE_ERROR, PRICE_UNDEF},
163        quantity::QUANTITY_UNDEF,
164    };
165    use rstest::rstest;
166
167    use super::{money_to_f64, price_to_f64, quantity_to_f64};
168
169    #[rstest]
170    fn test_price_to_f64_normal_value() {
171        let price = Price::from("100.10");
172        assert!((price_to_f64(&price) - 100.10).abs() < 1e-9);
173    }
174
175    #[rstest]
176    fn test_price_to_f64_undef_sentinel_is_nan() {
177        let price = Price::from_raw(PRICE_UNDEF, 0);
178        assert!(price_to_f64(&price).is_nan());
179    }
180
181    #[rstest]
182    fn test_price_to_f64_error_sentinel_is_nan() {
183        let price = Price::from_raw(PRICE_ERROR, 0);
184        assert!(price_to_f64(&price).is_nan());
185    }
186
187    #[rstest]
188    fn test_price_to_f64_error_price_constant_is_nan() {
189        // ERROR_PRICE has precision = 255; must not panic and must emit NaN
190        assert!(price_to_f64(&ERROR_PRICE).is_nan());
191    }
192
193    #[rstest]
194    fn test_price_to_f64_wei_precision_boundary_is_finite() {
195        // Precision 18 is the upper bound for legitimate wei-precision inputs
196        // and must not be caught by the pathological-precision guard. Struct
197        // literal bypasses `from_raw`'s `FIXED_PRECISION` assertion so the
198        // test runs across all feature combinations.
199        let price = Price {
200            raw: 1_000_000_000_000_000_000,
201            precision: 18,
202        };
203        let value = price_to_f64(&price);
204
205        assert!(value.is_finite(), "precision 18 should not return NaN");
206        assert!((value - 1.0).abs() < 1e-9);
207    }
208
209    #[rstest]
210    fn test_quantity_to_f64_normal_value() {
211        let quantity = Quantity::from(1_000);
212        assert!((quantity_to_f64(&quantity) - 1_000.0).abs() < 1e-9);
213    }
214
215    #[rstest]
216    fn test_quantity_to_f64_undef_sentinel_is_nan() {
217        let quantity = Quantity::from_raw(QUANTITY_UNDEF, 0);
218        assert!(quantity_to_f64(&quantity).is_nan());
219    }
220
221    #[rstest]
222    fn test_quantity_to_f64_pathological_precision_is_nan() {
223        // Mirrors the `ERROR_PRICE` guard for `price_to_f64`: any precision
224        // beyond `DISPLAY_MAX_PRECISION` (18) must emit NaN rather than
225        // panic or render a bogus value.
226        let quantity = Quantity {
227            raw: 0,
228            precision: 200,
229        };
230        assert!(quantity_to_f64(&quantity).is_nan());
231    }
232
233    #[rstest]
234    fn test_money_to_f64_normal_value() {
235        let money = Money::new(123.45, Currency::USD());
236        assert!((money_to_f64(&money) - 123.45).abs() < 1e-9);
237    }
238
239    #[rstest]
240    fn test_money_to_f64_pathological_precision_is_nan() {
241        // Simulates a DeFi currency whose precision exceeds DISPLAY_MAX_PRECISION
242        // so Money::as_f64 would panic under feature = "defi"; we mutate precision
243        // directly since Currency::new rejects values above FIXED_PRECISION.
244        let mut money = Money::new(0.0, Currency::USD());
245        money.currency.precision = 200;
246        assert!(money_to_f64(&money).is_nan());
247    }
248}