Skip to main content

nautilus_lighter/common/
parse.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//! Cross-cutting parsing helpers shared between HTTP and WebSocket layers.
17//!
18//! Lighter's wire payloads encode prices and order sizes as integer multiples
19//! of a per-market tick. The number of decimal places (`price_decimals` and
20//! `size_decimals`) is published once per market in the `orderBookDetails`
21//! REST response. The helpers here turn those mantissa/precision pairs into
22//! Nautilus [`Price`] and [`Quantity`] without a floating-point round-trip.
23
24use std::str::FromStr;
25
26use nautilus_core::{
27    UnixNanos,
28    datetime::{NANOSECONDS_IN_MILLISECOND, NANOSECONDS_IN_SECOND},
29};
30use nautilus_model::types::{Price, Quantity, fixed::FIXED_PRECISION};
31use rust_decimal::Decimal;
32
33/// Maximum decimal places that fit into Nautilus [`Price`] / [`Quantity`].
34pub const MAX_DECIMALS: u8 = FIXED_PRECISION;
35
36/// Converts a Lighter price tick count into a Nautilus [`Price`].
37///
38/// Lighter encodes limit and trigger prices as `u32` multiples of `10^-decimals`
39/// quote-asset units. The conversion uses pure integer arithmetic via
40/// [`Price::from_mantissa_exponent`].
41///
42/// # Errors
43///
44/// Returns an error if `decimals` exceeds [`MAX_DECIMALS`].
45pub fn parse_price_from_ticks(ticks: u32, decimals: u8) -> anyhow::Result<Price> {
46    anyhow::ensure!(
47        decimals <= MAX_DECIMALS,
48        "price decimals {decimals} exceeds maximum {MAX_DECIMALS}",
49    );
50    let exponent = -(decimals as i8);
51    Ok(Price::from_mantissa_exponent(
52        i64::from(ticks),
53        exponent,
54        decimals,
55    ))
56}
57
58/// Converts a Lighter base-amount tick count into a Nautilus [`Quantity`].
59///
60/// Order sizes on the wire are signed `i64` multiples of `10^-decimals`
61/// base-asset units. Nautilus [`Quantity`] is non-negative, so a negative
62/// `ticks` value is rejected: callers (e.g. position parsers) extract the
63/// sign separately before invoking this helper.
64///
65/// Conversion routes through [`Decimal`] and [`Quantity::from_decimal_dp`]
66/// so out-of-range tick counts return an error rather than panicking inside
67/// the unchecked mantissa-exponent constructor.
68///
69/// # Errors
70///
71/// Returns an error if `decimals` exceeds [`MAX_DECIMALS`], if `ticks` is
72/// negative, or if the resulting value exceeds the [`Quantity`] range.
73pub fn parse_quantity_from_ticks(ticks: i64, decimals: u8) -> anyhow::Result<Quantity> {
74    anyhow::ensure!(
75        decimals <= MAX_DECIMALS,
76        "size decimals {decimals} exceeds maximum {MAX_DECIMALS}",
77    );
78    anyhow::ensure!(ticks >= 0, "negative tick count {ticks} for Quantity");
79    let decimal = Decimal::new(ticks, u32::from(decimals));
80    Quantity::from_decimal_dp(decimal, decimals).map_err(|e| {
81        anyhow::anyhow!("Quantity overflow for ticks={ticks}, decimals={decimals}: {e}")
82    })
83}
84
85/// Converts a decimal string into a Nautilus [`Price`] at the requested precision.
86///
87/// # Errors
88///
89/// Returns an error if the string is not a decimal, if `precision` exceeds
90/// [`MAX_DECIMALS`], or if the resulting value is out of range.
91pub fn parse_price(value: &str, precision: u8) -> anyhow::Result<Price> {
92    anyhow::ensure!(
93        precision <= MAX_DECIMALS,
94        "price precision {precision} exceeds maximum {MAX_DECIMALS}",
95    );
96    let decimal =
97        Decimal::from_str(value).map_err(|e| anyhow::anyhow!("invalid price `{value}`: {e}"))?;
98    Price::from_decimal_dp(decimal, precision)
99        .map_err(|e| anyhow::anyhow!("invalid price `{value}` at precision {precision}: {e}"))
100}
101
102/// Converts a decimal string into a non-negative Nautilus [`Quantity`].
103///
104/// Zero is allowed because Lighter sends zero-size book levels to delete
105/// existing orders.
106///
107/// # Errors
108///
109/// Returns an error if the string is not a decimal, if `precision` exceeds
110/// [`MAX_DECIMALS`], if the value is negative, or if the resulting quantity
111/// is out of range.
112pub fn parse_quantity(value: &str, precision: u8) -> anyhow::Result<Quantity> {
113    anyhow::ensure!(
114        precision <= MAX_DECIMALS,
115        "size precision {precision} exceeds maximum {MAX_DECIMALS}",
116    );
117    let decimal =
118        Decimal::from_str(value).map_err(|e| anyhow::anyhow!("invalid quantity `{value}`: {e}"))?;
119    anyhow::ensure!(decimal.is_sign_positive(), "negative quantity `{value}`");
120    Quantity::from_decimal_dp(decimal, precision)
121        .map_err(|e| anyhow::anyhow!("invalid quantity `{value}` at precision {precision}: {e}"))
122}
123
124/// Converts a [`Decimal`] into a Nautilus [`Price`] at the requested precision.
125///
126/// Use this when the wire value has already been deserialized as a [`Decimal`]
127/// (the standard pattern for model fields tagged with `deserialize_decimal`).
128///
129/// # Errors
130///
131/// Returns an error if `precision` exceeds [`MAX_DECIMALS`] or if the value
132/// is out of [`Price`] range.
133pub fn price_from_decimal(value: Decimal, precision: u8) -> anyhow::Result<Price> {
134    anyhow::ensure!(
135        precision <= MAX_DECIMALS,
136        "price precision {precision} exceeds maximum {MAX_DECIMALS}",
137    );
138    Price::from_decimal_dp(value, precision)
139        .map_err(|e| anyhow::anyhow!("invalid price `{value}` at precision {precision}: {e}"))
140}
141
142/// Converts a [`Decimal`] into a non-negative Nautilus [`Quantity`] at the
143/// requested precision.
144///
145/// Zero is allowed because Lighter sends zero-size book levels to delete
146/// existing orders.
147///
148/// # Errors
149///
150/// Returns an error if `precision` exceeds [`MAX_DECIMALS`], if the value is
151/// negative, or if the resulting quantity is out of range.
152pub fn quantity_from_decimal(value: Decimal, precision: u8) -> anyhow::Result<Quantity> {
153    anyhow::ensure!(
154        precision <= MAX_DECIMALS,
155        "size precision {precision} exceeds maximum {MAX_DECIMALS}",
156    );
157    anyhow::ensure!(value.is_sign_positive(), "negative quantity `{value}`");
158    Quantity::from_decimal_dp(value, precision)
159        .map_err(|e| anyhow::anyhow!("invalid quantity `{value}` at precision {precision}: {e}"))
160}
161
162/// Converts a Unix millisecond timestamp into [`UnixNanos`].
163///
164/// # Errors
165///
166/// Returns an error if `millis * 1_000_000` would overflow `u64`. Realistic
167/// venue timestamps are nowhere near this bound; the check rejects malformed
168/// payloads instead of silently wrapping in release builds.
169pub fn parse_millis_to_nanos(millis: u64) -> anyhow::Result<UnixNanos> {
170    let nanos = millis
171        .checked_mul(NANOSECONDS_IN_MILLISECOND)
172        .ok_or_else(|| {
173            anyhow::anyhow!("millisecond timestamp {millis} overflows when scaled to nanoseconds")
174        })?;
175    Ok(UnixNanos::from(nanos))
176}
177
178/// Converts a Unix microsecond timestamp into [`UnixNanos`].
179///
180/// # Errors
181///
182/// Returns an error if `micros * 1_000` would overflow `u64`.
183pub fn parse_micros_to_nanos(micros: u64) -> anyhow::Result<UnixNanos> {
184    let nanos = micros.checked_mul(1_000).ok_or_else(|| {
185        anyhow::anyhow!("microsecond timestamp {micros} overflows when scaled to nanoseconds")
186    })?;
187    Ok(UnixNanos::from(nanos))
188}
189
190/// Converts a Unix second timestamp into [`UnixNanos`].
191///
192/// # Errors
193///
194/// Returns an error if `secs * 1_000_000_000` would overflow `u64`.
195pub fn parse_secs_to_nanos(secs: u64) -> anyhow::Result<UnixNanos> {
196    let nanos = secs.checked_mul(NANOSECONDS_IN_SECOND).ok_or_else(|| {
197        anyhow::anyhow!("second timestamp {secs} overflows when scaled to nanoseconds")
198    })?;
199    Ok(UnixNanos::from(nanos))
200}
201
202/// Converts a signed Unix millisecond timestamp into [`UnixNanos`].
203///
204/// Negative inputs are mapped to `Ok(None)` so callers can model fields
205/// where the wire uses `-1` (or any negative sentinel) as "absent". Fields
206/// that overload `0` with a separate meaning (e.g. `0` for IOC on
207/// `OrderInfo::order_expiry`) must apply that interpretation at the call
208/// site; this helper treats `0` as a literal Unix epoch timestamp.
209///
210/// # Errors
211///
212/// Returns an error if a non-negative `millis` overflows when scaled to
213/// nanoseconds.
214pub fn parse_optional_millis_to_nanos(millis: i64) -> anyhow::Result<Option<UnixNanos>> {
215    if millis < 0 {
216        Ok(None)
217    } else {
218        parse_millis_to_nanos(millis as u64).map(Some)
219    }
220}
221
222#[cfg(test)]
223mod tests {
224    use rstest::rstest;
225
226    use super::*;
227
228    #[rstest]
229    fn parse_price_zero_decimals_is_integer() {
230        let price = parse_price_from_ticks(42, 0).unwrap();
231        assert_eq!(price.precision, 0);
232        assert_eq!(price.to_string(), "42");
233    }
234
235    #[rstest]
236    fn parse_price_with_decimals_inserts_decimal_point() {
237        // 405_000 ticks at 2 decimals = $4_050.00
238        let price = parse_price_from_ticks(405_000, 2).unwrap();
239        assert_eq!(price.precision, 2);
240        assert_eq!(price.to_string(), "4050.00");
241    }
242
243    #[rstest]
244    fn parse_price_at_max_decimals() {
245        let price = parse_price_from_ticks(1, MAX_DECIMALS).unwrap();
246        assert_eq!(price.precision, MAX_DECIMALS);
247    }
248
249    #[rstest]
250    fn parse_price_rejects_decimals_above_max() {
251        let err = parse_price_from_ticks(1, MAX_DECIMALS + 1).unwrap_err();
252        assert!(err.to_string().contains("exceeds maximum"));
253    }
254
255    #[rstest]
256    fn parse_quantity_with_decimals_inserts_decimal_point() {
257        // 1_000 ticks at 3 decimals = 1.000
258        let qty = parse_quantity_from_ticks(1_000, 3).unwrap();
259        assert_eq!(qty.precision, 3);
260        assert_eq!(qty.to_string(), "1.000");
261    }
262
263    #[rstest]
264    fn parse_quantity_rejects_negative_ticks() {
265        let err = parse_quantity_from_ticks(-1, 2).unwrap_err();
266        assert!(err.to_string().contains("negative tick count"));
267    }
268
269    #[rstest]
270    fn parse_quantity_rejects_oversized_ticks() {
271        // i64::MAX at 0 decimals exceeds the Nautilus Quantity range.
272        let err = parse_quantity_from_ticks(i64::MAX, 0).unwrap_err();
273        assert!(err.to_string().contains("Quantity overflow"));
274    }
275
276    #[rstest]
277    fn parse_quantity_zero_is_valid() {
278        let qty = parse_quantity_from_ticks(0, 4).unwrap();
279        assert_eq!(qty.as_f64(), 0.0);
280        assert_eq!(qty.precision, 4);
281    }
282
283    #[rstest]
284    fn parse_price_from_decimal_string() {
285        let price = parse_price("2352.73", 2).unwrap();
286        assert_eq!(price.to_string(), "2352.73");
287        assert_eq!(price.precision, 2);
288    }
289
290    #[rstest]
291    fn parse_price_rejects_invalid_decimal() {
292        let err = parse_price("not-a-price", 2).unwrap_err();
293        assert!(err.to_string().contains("invalid price"));
294    }
295
296    #[rstest]
297    fn parse_quantity_from_decimal_string() {
298        let quantity = parse_quantity("0.1336", 4).unwrap();
299        assert_eq!(quantity.to_string(), "0.1336");
300        assert_eq!(quantity.precision, 4);
301    }
302
303    #[rstest]
304    fn parse_quantity_rejects_negative_decimal_string() {
305        let err = parse_quantity("-0.1", 4).unwrap_err();
306        assert!(err.to_string().contains("negative quantity"));
307    }
308
309    #[rstest]
310    fn parse_millis_to_nanos_scales_correctly() {
311        assert_eq!(parse_millis_to_nanos(0).unwrap(), UnixNanos::from(0));
312        assert_eq!(
313            parse_millis_to_nanos(1).unwrap(),
314            UnixNanos::from(1_000_000),
315        );
316        assert_eq!(
317            parse_millis_to_nanos(1_700_000_000_000).unwrap(),
318            UnixNanos::from(1_700_000_000_000_000_000),
319        );
320    }
321
322    #[rstest]
323    fn parse_millis_to_nanos_rejects_overflow() {
324        let err = parse_millis_to_nanos(u64::MAX).unwrap_err();
325        assert!(err.to_string().contains("overflows"));
326    }
327
328    #[rstest]
329    fn parse_micros_to_nanos_scales_correctly() {
330        assert_eq!(parse_micros_to_nanos(0).unwrap(), UnixNanos::from(0));
331        assert_eq!(parse_micros_to_nanos(1).unwrap(), UnixNanos::from(1_000));
332        assert_eq!(
333            parse_micros_to_nanos(1_700_000_000_000_000).unwrap(),
334            UnixNanos::from(1_700_000_000_000_000_000),
335        );
336    }
337
338    #[rstest]
339    fn parse_micros_to_nanos_rejects_overflow() {
340        let err = parse_micros_to_nanos(u64::MAX).unwrap_err();
341        assert!(err.to_string().contains("overflows"));
342    }
343
344    #[rstest]
345    fn parse_secs_to_nanos_scales_correctly() {
346        assert_eq!(parse_secs_to_nanos(0).unwrap(), UnixNanos::from(0));
347        assert_eq!(
348            parse_secs_to_nanos(1).unwrap(),
349            UnixNanos::from(1_000_000_000),
350        );
351    }
352
353    #[rstest]
354    fn parse_secs_to_nanos_rejects_overflow() {
355        let err = parse_secs_to_nanos(u64::MAX).unwrap_err();
356        assert!(err.to_string().contains("overflows"));
357    }
358
359    #[rstest]
360    fn parse_optional_millis_returns_none_for_negative() {
361        assert!(parse_optional_millis_to_nanos(-1).unwrap().is_none());
362        assert!(parse_optional_millis_to_nanos(i64::MIN).unwrap().is_none());
363    }
364
365    #[rstest]
366    fn parse_optional_millis_returns_some_for_non_negative() {
367        assert_eq!(
368            parse_optional_millis_to_nanos(0).unwrap(),
369            Some(UnixNanos::from(0)),
370        );
371        assert_eq!(
372            parse_optional_millis_to_nanos(1_500).unwrap(),
373            Some(UnixNanos::from(1_500_000_000)),
374        );
375    }
376
377    #[rstest]
378    fn parse_optional_millis_propagates_overflow() {
379        let err = parse_optional_millis_to_nanos(i64::MAX).unwrap_err();
380        assert!(err.to_string().contains("overflows"));
381    }
382}