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}