Skip to main content

fin_primitives/types/
mod.rs

1//! # Module: types
2//!
3//! ## Responsibility
4//! Provides the core validated newtype wrappers used throughout fin-primitives:
5//! `Symbol`, `Price`, `Quantity`, `Side`, and `NanoTimestamp`.
6//!
7//! ## Guarantees
8//! - `Symbol`: non-empty, no whitespace
9//! - `Price`: strictly positive (`> 0`)
10//! - `Quantity`: non-negative (`>= 0`)
11//! - `NanoTimestamp`: nanosecond-resolution UTC epoch timestamp
12//! - All types implement `Clone`, `Copy` (where applicable), `serde::{Serialize, Deserialize}`
13//!
14//! ## NOT Responsible For
15//! - Currency conversion
16//! - Tick size enforcement (exchange-specific)
17
18use crate::error::FinError;
19use chrono::{DateTime, TimeZone, Utc};
20use rust_decimal::Decimal;
21
22/// A validated ticker symbol: non-empty, contains no whitespace.
23///
24/// # Example
25/// ```rust
26/// use fin_primitives::types::Symbol;
27/// let sym = Symbol::new("AAPL").unwrap();
28/// assert_eq!(sym.as_str(), "AAPL");
29/// ```
30#[derive(Debug, Clone, PartialEq, Eq, Hash, serde::Serialize, serde::Deserialize)]
31pub struct Symbol(String);
32
33impl Symbol {
34    /// Construct a validated `Symbol`.
35    ///
36    /// # Errors
37    /// Returns [`FinError::InvalidSymbol`] if the string is empty or contains whitespace.
38    pub fn new(s: impl Into<String>) -> Result<Self, FinError> {
39        let s = s.into();
40        if s.is_empty() || s.chars().any(char::is_whitespace) {
41            return Err(FinError::InvalidSymbol(s));
42        }
43        Ok(Self(s))
44    }
45
46    /// Returns the inner string slice.
47    pub fn as_str(&self) -> &str {
48        &self.0
49    }
50}
51
52impl std::fmt::Display for Symbol {
53    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
54        f.write_str(&self.0)
55    }
56}
57
58/// A strictly positive price value backed by [`Decimal`].
59///
60/// # Example
61/// ```rust
62/// use fin_primitives::types::Price;
63/// use rust_decimal_macros::dec;
64/// let p = Price::new(dec!(100.50)).unwrap();
65/// assert_eq!(p.value(), dec!(100.50));
66/// ```
67#[derive(
68    Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, serde::Serialize, serde::Deserialize,
69)]
70pub struct Price(Decimal);
71
72impl Price {
73    /// Construct a validated `Price`.
74    ///
75    /// # Errors
76    /// Returns [`FinError::InvalidPrice`] if `d <= 0`.
77    pub fn new(d: Decimal) -> Result<Self, FinError> {
78        if d <= Decimal::ZERO {
79            return Err(FinError::InvalidPrice(d));
80        }
81        Ok(Self(d))
82    }
83
84    /// Returns the inner [`Decimal`] value.
85    pub fn value(&self) -> Decimal {
86        self.0
87    }
88}
89
90/// A non-negative quantity backed by [`Decimal`].
91///
92/// # Example
93/// ```rust
94/// use fin_primitives::types::Quantity;
95/// use rust_decimal_macros::dec;
96/// let q = Quantity::zero();
97/// assert_eq!(q.value(), dec!(0));
98/// ```
99#[derive(
100    Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, serde::Serialize, serde::Deserialize,
101)]
102pub struct Quantity(Decimal);
103
104impl Quantity {
105    /// Construct a validated `Quantity`.
106    ///
107    /// # Errors
108    /// Returns [`FinError::InvalidQuantity`] if `d < 0`.
109    pub fn new(d: Decimal) -> Result<Self, FinError> {
110        if d < Decimal::ZERO {
111            return Err(FinError::InvalidQuantity(d));
112        }
113        Ok(Self(d))
114    }
115
116    /// Returns a zero quantity without allocation.
117    pub fn zero() -> Self {
118        Self(Decimal::ZERO)
119    }
120
121    /// Returns the inner [`Decimal`] value.
122    pub fn value(&self) -> Decimal {
123        self.0
124    }
125}
126
127/// The side of a market order or book level.
128#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
129pub enum Side {
130    /// Buy side (bids).
131    Bid,
132    /// Sell side (asks).
133    Ask,
134}
135
136/// Exchange-epoch timestamp with nanosecond resolution.
137///
138/// Stores nanoseconds since the Unix epoch (UTC).
139#[derive(
140    Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, serde::Serialize, serde::Deserialize,
141)]
142pub struct NanoTimestamp(pub i64);
143
144impl NanoTimestamp {
145    /// Returns the current UTC time as a `NanoTimestamp`.
146    ///
147    /// Falls back to `0` if the system clock overflows nanosecond range (extremely unlikely).
148    pub fn now() -> Self {
149        Self(Utc::now().timestamp_nanos_opt().unwrap_or(0))
150    }
151
152    /// Converts this timestamp to a [`DateTime<Utc>`].
153    pub fn to_datetime(&self) -> DateTime<Utc> {
154        let secs = self.0 / 1_000_000_000;
155        #[allow(clippy::cast_sign_loss)]
156        let nanos = (self.0 % 1_000_000_000) as u32;
157        Utc.timestamp_opt(secs, nanos).single().unwrap_or_else(|| {
158            Utc.timestamp_opt(0, 0)
159                .single()
160                .unwrap_or(DateTime::<Utc>::MIN_UTC)
161        })
162    }
163}
164
165#[cfg(test)]
166mod tests {
167    use super::*;
168    use rust_decimal_macros::dec;
169
170    // --- Symbol ---
171
172    #[test]
173    fn test_symbol_new_valid_ok() {
174        let sym = Symbol::new("AAPL").unwrap();
175        assert_eq!(sym.as_str(), "AAPL");
176    }
177
178    #[test]
179    fn test_symbol_new_empty_fails() {
180        let result = Symbol::new("");
181        assert!(matches!(result, Err(FinError::InvalidSymbol(_))));
182    }
183
184    #[test]
185    fn test_symbol_new_whitespace_fails() {
186        let result = Symbol::new("AA PL");
187        assert!(matches!(result, Err(FinError::InvalidSymbol(_))));
188    }
189
190    #[test]
191    fn test_symbol_new_leading_whitespace_fails() {
192        let result = Symbol::new(" AAPL");
193        assert!(matches!(result, Err(FinError::InvalidSymbol(_))));
194    }
195
196    #[test]
197    fn test_symbol_display() {
198        let sym = Symbol::new("TSLA").unwrap();
199        assert_eq!(format!("{sym}"), "TSLA");
200    }
201
202    #[test]
203    fn test_symbol_clone_equality() {
204        let a = Symbol::new("BTC").unwrap();
205        let b = a.clone();
206        assert_eq!(a, b);
207    }
208
209    // --- Price ---
210
211    #[test]
212    fn test_price_new_positive_ok() {
213        let p = Price::new(dec!(100.5)).unwrap();
214        assert_eq!(p.value(), dec!(100.5));
215    }
216
217    #[test]
218    fn test_price_new_zero_fails() {
219        let result = Price::new(dec!(0));
220        assert!(matches!(result, Err(FinError::InvalidPrice(_))));
221    }
222
223    #[test]
224    fn test_price_new_negative_fails() {
225        let result = Price::new(dec!(-1));
226        assert!(matches!(result, Err(FinError::InvalidPrice(_))));
227    }
228
229    #[test]
230    fn test_price_ordering() {
231        let p1 = Price::new(dec!(1)).unwrap();
232        let p2 = Price::new(dec!(2)).unwrap();
233        assert!(p1 < p2);
234    }
235
236    // --- Quantity ---
237
238    #[test]
239    fn test_quantity_new_zero_ok() {
240        let q = Quantity::new(dec!(0)).unwrap();
241        assert_eq!(q.value(), dec!(0));
242    }
243
244    #[test]
245    fn test_quantity_new_positive_ok() {
246        let q = Quantity::new(dec!(5.5)).unwrap();
247        assert_eq!(q.value(), dec!(5.5));
248    }
249
250    #[test]
251    fn test_quantity_new_negative_fails() {
252        let result = Quantity::new(dec!(-0.01));
253        assert!(matches!(result, Err(FinError::InvalidQuantity(_))));
254    }
255
256    #[test]
257    fn test_quantity_zero_constructor() {
258        let q = Quantity::zero();
259        assert_eq!(q.value(), Decimal::ZERO);
260    }
261
262    // --- NanoTimestamp ---
263
264    #[test]
265    fn test_nano_timestamp_now_positive() {
266        let ts = NanoTimestamp::now();
267        assert!(ts.0 > 0);
268    }
269
270    #[test]
271    fn test_nano_timestamp_ordering() {
272        let ts1 = NanoTimestamp(1_000_000_000);
273        let ts2 = NanoTimestamp(2_000_000_000);
274        assert!(ts1 < ts2);
275    }
276
277    #[test]
278    fn test_nano_timestamp_to_datetime_epoch() {
279        let ts = NanoTimestamp(0);
280        let dt = ts.to_datetime();
281        assert_eq!(dt.timestamp(), 0);
282    }
283
284    #[test]
285    fn test_nano_timestamp_to_datetime_roundtrip() {
286        let ts = NanoTimestamp(1_700_000_000_000_000_000_i64);
287        let dt = ts.to_datetime();
288        assert_eq!(
289            dt.timestamp_nanos_opt().unwrap_or(0),
290            1_700_000_000_000_000_000_i64
291        );
292    }
293}