Skip to main content

pricelevel/utils/
value.rs

1use crate::errors::PriceLevelError;
2use serde::{Deserialize, Serialize};
3use std::fmt;
4use std::str::FromStr;
5
6/// Domain value type representing a price.
7#[derive(
8    Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Default, Serialize, Deserialize,
9)]
10#[serde(transparent)]
11pub struct Price(u128);
12
13impl Price {
14    /// Zero price value.
15    pub const ZERO: Self = Self(0);
16
17    /// Creates a new price from a raw integer value.
18    #[must_use]
19    pub const fn new(value: u128) -> Self {
20        Self(value)
21    }
22
23    /// Creates a validated price from a raw integer value.
24    ///
25    /// # Errors
26    ///
27    /// Returns [`PriceLevelError::InvalidFieldValue`] if `value` ever fails the
28    /// price invariant. Every `u128` is currently a valid price, so this
29    /// constructor is presently infallible; the `Result` is part of its stable
30    /// contract so validation can be tightened without a breaking change.
31    pub fn try_new(value: u128) -> Result<Self, PriceLevelError> {
32        Ok(Self(value))
33    }
34
35    /// Creates a price from an `f64` value (rounded to the nearest integer).
36    ///
37    /// # Errors
38    ///
39    /// Returns [`PriceLevelError::InvalidOperation`] if `value` is not finite
40    /// (NaN or infinite), is negative, or (after rounding) does not fit in a
41    /// `u128`. The range check is explicit because an `f64`-to-`u128` `as` cast
42    /// saturates rather than failing, which would silently clamp out-of-range
43    /// input to `u128::MAX`.
44    pub fn from_f64(value: f64) -> Result<Self, PriceLevelError> {
45        if !value.is_finite() || value < 0.0 {
46            return Err(PriceLevelError::InvalidOperation {
47                message: format!("invalid price from f64: {value}"),
48            });
49        }
50
51        let rounded = value.round();
52        // A finite, non-negative f64 fits in u128 iff it is strictly below 2^128.
53        if rounded >= 2.0_f64.powi(128) {
54            return Err(PriceLevelError::InvalidOperation {
55                message: format!("price from f64 out of u128 range: {value}"),
56            });
57        }
58
59        Ok(Self(rounded as u128))
60    }
61
62    /// Converts the price to `f64` with potential precision loss.
63    #[must_use]
64    pub fn to_f64_lossy(self) -> f64 {
65        self.0 as f64
66    }
67
68    /// Returns the inner raw value.
69    #[must_use]
70    pub const fn as_u128(self) -> u128 {
71        self.0
72    }
73}
74
75impl fmt::Display for Price {
76    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
77        write!(f, "{}", self.0)
78    }
79}
80
81impl FromStr for Price {
82    type Err = PriceLevelError;
83
84    fn from_str(s: &str) -> Result<Self, Self::Err> {
85        s.parse::<u128>()
86            .map(Self)
87            .map_err(|_| PriceLevelError::InvalidFieldValue {
88                field: "price".to_string(),
89                value: s.to_string(),
90            })
91    }
92}
93
94/// Domain value type representing a quantity.
95#[derive(
96    Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Default, Serialize, Deserialize,
97)]
98#[serde(transparent)]
99pub struct Quantity(u64);
100
101impl Quantity {
102    /// Zero quantity value.
103    pub const ZERO: Self = Self(0);
104
105    /// Creates a new quantity from a raw integer value.
106    #[must_use]
107    pub const fn new(value: u64) -> Self {
108        Self(value)
109    }
110
111    /// Creates a validated quantity from a raw integer value.
112    ///
113    /// # Errors
114    ///
115    /// Returns [`PriceLevelError::InvalidFieldValue`] if `value` ever fails the
116    /// quantity invariant. Every `u64` is currently a valid quantity, so this
117    /// constructor is presently infallible; the `Result` is part of its stable
118    /// contract so validation can be tightened without a breaking change.
119    pub fn try_new(value: u64) -> Result<Self, PriceLevelError> {
120        Ok(Self(value))
121    }
122
123    /// Creates a quantity from an `f64` value (rounded to the nearest integer).
124    ///
125    /// # Errors
126    ///
127    /// Returns [`PriceLevelError::InvalidOperation`] if `value` is not finite
128    /// (NaN or infinite), is negative, or (after rounding) does not fit in a
129    /// `u64`. The range check is explicit because an `f64`-to-`u64` `as` cast
130    /// saturates rather than failing, which would silently clamp out-of-range
131    /// input to `u64::MAX`.
132    pub fn from_f64(value: f64) -> Result<Self, PriceLevelError> {
133        if !value.is_finite() || value < 0.0 {
134            return Err(PriceLevelError::InvalidOperation {
135                message: format!("invalid quantity from f64: {value}"),
136            });
137        }
138
139        let rounded = value.round();
140        // A finite, non-negative f64 fits in u64 iff it is strictly below 2^64.
141        if rounded >= 2.0_f64.powi(64) {
142            return Err(PriceLevelError::InvalidOperation {
143                message: format!("quantity from f64 out of u64 range: {value}"),
144            });
145        }
146
147        Ok(Self(rounded as u64))
148    }
149
150    /// Converts the quantity to `f64` with potential precision loss.
151    #[must_use]
152    pub fn to_f64_lossy(self) -> f64 {
153        self.0 as f64
154    }
155
156    /// Returns the inner raw value.
157    #[must_use]
158    pub const fn as_u64(self) -> u64 {
159        self.0
160    }
161}
162
163impl fmt::Display for Quantity {
164    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
165        write!(f, "{}", self.0)
166    }
167}
168
169impl FromStr for Quantity {
170    type Err = PriceLevelError;
171
172    fn from_str(s: &str) -> Result<Self, Self::Err> {
173        s.parse::<u64>()
174            .map(Self)
175            .map_err(|_| PriceLevelError::InvalidFieldValue {
176                field: "quantity".to_string(),
177                value: s.to_string(),
178            })
179    }
180}
181
182/// Domain value type representing a timestamp in milliseconds.
183#[derive(
184    Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Default, Serialize, Deserialize,
185)]
186#[serde(transparent)]
187pub struct TimestampMs(u64);
188
189impl TimestampMs {
190    /// Zero timestamp value.
191    pub const ZERO: Self = Self(0);
192
193    /// Creates a new timestamp from milliseconds.
194    #[must_use]
195    pub const fn new(value: u64) -> Self {
196        Self(value)
197    }
198
199    /// Creates a validated timestamp from milliseconds.
200    ///
201    /// # Errors
202    ///
203    /// Returns [`PriceLevelError::InvalidFieldValue`] if `value` ever fails the
204    /// timestamp invariant. Every `u64` is currently a valid millisecond
205    /// timestamp, so this constructor is presently infallible; the `Result` is
206    /// part of its stable contract so validation can be tightened without a
207    /// breaking change.
208    pub fn try_new(value: u64) -> Result<Self, PriceLevelError> {
209        Ok(Self(value))
210    }
211
212    /// Returns the inner raw milliseconds value.
213    #[must_use]
214    pub const fn as_u64(self) -> u64 {
215        self.0
216    }
217}
218
219impl fmt::Display for TimestampMs {
220    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
221        write!(f, "{}", self.0)
222    }
223}
224
225impl FromStr for TimestampMs {
226    type Err = PriceLevelError;
227
228    fn from_str(s: &str) -> Result<Self, Self::Err> {
229        s.parse::<u64>()
230            .map(Self)
231            .map_err(|_| PriceLevelError::InvalidFieldValue {
232                field: "timestamp".to_string(),
233                value: s.to_string(),
234            })
235    }
236}
237
238#[cfg(test)]
239mod tests {
240    use super::{Price, Quantity, TimestampMs};
241    use std::str::FromStr;
242
243    #[test]
244    fn price_roundtrip() {
245        let value = Price::new(1_000);
246        let parsed = Price::from_str(&value.to_string());
247        assert!(parsed.is_ok());
248        assert_eq!(parsed.unwrap_or_default(), value);
249    }
250
251    #[test]
252    fn quantity_roundtrip() {
253        let value = Quantity::new(42);
254        let parsed = Quantity::from_str(&value.to_string());
255        assert!(parsed.is_ok());
256        assert_eq!(parsed.unwrap_or_default(), value);
257    }
258
259    #[test]
260    fn timestamp_roundtrip() {
261        let value = TimestampMs::new(1_716_000_000_000);
262        let parsed = TimestampMs::from_str(&value.to_string());
263        assert!(parsed.is_ok());
264        assert_eq!(parsed.unwrap_or_default(), value);
265    }
266
267    #[test]
268    fn from_f64_rejects_negative() {
269        assert!(Price::from_f64(-1.0).is_err());
270        assert!(Quantity::from_f64(-1.0).is_err());
271    }
272
273    #[test]
274    fn from_f64_rejects_out_of_range() {
275        // f64 -> int `as` casts saturate; from_f64 must reject instead of
276        // silently clamping to u128::MAX / u64::MAX.
277        assert!(matches!(
278            Price::from_f64(2.0_f64.powi(128)),
279            Err(crate::errors::PriceLevelError::InvalidOperation { .. })
280        ));
281        assert!(matches!(
282            Quantity::from_f64(2.0_f64.powi(64)),
283            Err(crate::errors::PriceLevelError::InvalidOperation { .. })
284        ));
285        // Just-in-range values still convert.
286        assert!(Price::from_f64(1_000_000.0).is_ok());
287        assert!(Quantity::from_f64(1_000_000.0).is_ok());
288    }
289
290    #[test]
291    fn from_f64_rejects_non_finite() {
292        assert!(Price::from_f64(f64::NAN).is_err());
293        assert!(Price::from_f64(f64::INFINITY).is_err());
294        assert!(Quantity::from_f64(f64::NAN).is_err());
295    }
296}