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; backed by `Arc<str>` for O(1) clone
9//! - `Price`: strictly positive (`> 0`)
10//! - `Quantity`: non-negative (`>= 0`)
11//! - `NanoTimestamp`: nanosecond-resolution UTC epoch timestamp; inner field is private
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, Timelike, Utc};
20use rust_decimal::Decimal;
21use std::sync::Arc;
22
23/// A validated ticker symbol: non-empty, contains no whitespace.
24///
25/// Backed by `Arc<str>` so cloning is O(1).
26///
27/// # Example
28/// ```rust
29/// use fin_primitives::types::Symbol;
30/// let sym = Symbol::new("AAPL").unwrap();
31/// assert_eq!(sym.as_str(), "AAPL");
32/// ```
33#[derive(Debug, Clone, PartialEq, Eq, Hash, serde::Serialize, serde::Deserialize)]
34#[serde(transparent)]
35pub struct Symbol(Arc<str>);
36
37impl Symbol {
38    /// Construct a validated `Symbol`.
39    ///
40    /// # Errors
41    /// Returns [`FinError::InvalidSymbol`] if the string is empty or contains whitespace.
42    pub fn new(s: impl AsRef<str>) -> Result<Self, FinError> {
43        let s = s.as_ref();
44        if s.is_empty() || s.chars().any(char::is_whitespace) {
45            return Err(FinError::InvalidSymbol(s.to_owned()));
46        }
47        Ok(Self(Arc::from(s)))
48    }
49
50    /// Returns the inner string slice.
51    pub fn as_str(&self) -> &str {
52        &self.0
53    }
54
55    /// Returns the number of bytes in the symbol string.
56    pub fn len(&self) -> usize {
57        self.0.len()
58    }
59
60    /// Returns `true` if the symbol string is empty.
61    ///
62    /// Note: construction always rejects empty strings, so this always returns `false`
63    /// for any successfully constructed `Symbol`.
64    pub fn is_empty(&self) -> bool {
65        self.0.is_empty()
66    }
67}
68
69impl std::fmt::Display for Symbol {
70    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
71        f.write_str(&self.0)
72    }
73}
74
75impl AsRef<str> for Symbol {
76    fn as_ref(&self) -> &str {
77        &self.0
78    }
79}
80
81impl std::borrow::Borrow<str> for Symbol {
82    fn borrow(&self) -> &str {
83        &self.0
84    }
85}
86
87impl TryFrom<String> for Symbol {
88    type Error = FinError;
89
90    fn try_from(s: String) -> Result<Self, Self::Error> {
91        Symbol::new(s)
92    }
93}
94
95impl TryFrom<&str> for Symbol {
96    type Error = FinError;
97
98    fn try_from(s: &str) -> Result<Self, Self::Error> {
99        Symbol::new(s)
100    }
101}
102
103impl PartialOrd for Symbol {
104    fn partial_cmp(&self, other: &Self) -> Option<std::cmp::Ordering> {
105        Some(self.cmp(other))
106    }
107}
108
109impl Ord for Symbol {
110    fn cmp(&self, other: &Self) -> std::cmp::Ordering {
111        self.0.as_ref().cmp(other.0.as_ref())
112    }
113}
114
115impl From<Symbol> for String {
116    fn from(s: Symbol) -> Self {
117        s.as_str().to_owned()
118    }
119}
120
121impl From<Symbol> for Arc<str> {
122    fn from(s: Symbol) -> Self {
123        s.0.clone()
124    }
125}
126
127/// A strictly positive price value backed by [`Decimal`].
128///
129/// # Example
130/// ```rust
131/// use fin_primitives::types::Price;
132/// use rust_decimal_macros::dec;
133/// let p = Price::new(dec!(100.50)).unwrap();
134/// assert_eq!(p.value(), dec!(100.50));
135/// ```
136#[derive(
137    Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, serde::Serialize, serde::Deserialize,
138)]
139pub struct Price(Decimal);
140
141impl Price {
142    /// Construct a validated `Price`.
143    ///
144    /// # Errors
145    /// Returns [`FinError::InvalidPrice`] if `d <= 0`.
146    pub fn new(d: Decimal) -> Result<Self, FinError> {
147        if d <= Decimal::ZERO {
148            return Err(FinError::InvalidPrice(d));
149        }
150        Ok(Self(d))
151    }
152
153    /// Returns the inner [`Decimal`] value.
154    pub fn value(&self) -> Decimal {
155        self.0
156    }
157
158    /// Converts to `f64` with possible precision loss.
159    pub fn to_f64(&self) -> f64 {
160        rust_decimal::prelude::ToPrimitive::to_f64(&self.0).unwrap_or(f64::NAN)
161    }
162
163    /// Constructs a `Price` from an `f64`. Returns `None` if `f` is not finite or `<= 0`.
164    pub fn from_f64(f: f64) -> Option<Self> {
165        use rust_decimal::prelude::FromPrimitive;
166        let d = Decimal::from_f64(f)?;
167        Self::new(d).ok()
168    }
169
170    /// Returns a `String` representation rounded to `dp` decimal places.
171    ///
172    /// Useful for display/logging without losing the underlying decimal precision.
173    pub fn to_string_with_dp(&self, dp: u32) -> String {
174        self.0.round_dp(dp).to_string()
175    }
176}
177
178impl Price {
179    /// Returns the percentage change from `self` to `other`: `(other - self) / self * 100`.
180    ///
181    /// Positive values indicate a price increase; negative values indicate a decrease.
182    pub fn pct_change_to(self, other: Price) -> Decimal {
183        (other.0 - self.0) / self.0 * Decimal::ONE_HUNDRED
184    }
185
186    /// Returns the midpoint between `self` and `other`: `(self + other) / 2`.
187    pub fn mid(self, other: Price) -> Price {
188        Price((self.0 + other.0) / Decimal::TWO)
189    }
190}
191
192impl Price {
193    /// Returns the absolute difference between `self` and `other`: `|self - other|`.
194    pub fn abs_diff(self, other: Price) -> Decimal {
195        (self.0 - other.0).abs()
196    }
197
198    /// Rounds this price to the nearest multiple of `tick_size`.
199    ///
200    /// Returns `None` if `tick_size <= 0` or if the rounded value is not a valid
201    /// `Price` (i.e. the result is zero or negative).
202    ///
203    /// # Example
204    /// ```rust
205    /// use fin_primitives::types::Price;
206    /// use rust_decimal_macros::dec;
207    ///
208    /// let p = Price::new(dec!(10.3)).unwrap();
209    /// let snapped = p.snap_to_tick(dec!(0.5)).unwrap();
210    /// assert_eq!(snapped.value(), dec!(10.5));
211    /// ```
212    pub fn snap_to_tick(self, tick_size: Decimal) -> Option<Price> {
213        if tick_size <= Decimal::ZERO {
214            return None;
215        }
216        let rounded = (self.0 / tick_size).round() * tick_size;
217        Price::new(rounded).ok()
218    }
219
220    /// Clamps this price to the inclusive range `[lo, hi]`.
221    ///
222    /// Returns `lo` if `self < lo`, `hi` if `self > hi`, otherwise `self`.
223    pub fn clamp(self, lo: Price, hi: Price) -> Price {
224        if self.0 < lo.0 {
225            lo
226        } else if self.0 > hi.0 {
227            hi
228        } else {
229            self
230        }
231    }
232}
233
234impl Price {
235    /// Rounds this price to `dp` decimal places using banker's rounding.
236    ///
237    /// Returns `None` if the rounded value is zero or negative (invalid price).
238    pub fn round_to(self, dp: u32) -> Option<Price> {
239        let rounded = self.0.round_dp(dp);
240        Price::new(rounded).ok()
241    }
242
243    /// Rounds this price to `dp` decimal places using round-half-up (conventional rounding).
244    ///
245    /// Unlike [`round_to`](Price::round_to) which uses banker's rounding, this always rounds
246    /// `0.5` away from zero. Returns `None` if the rounded result is zero or negative.
247    pub fn round_half_up(self, dp: u32) -> Option<Price> {
248        use rust_decimal::RoundingStrategy;
249        let rounded = self.0.round_dp_with_strategy(dp, RoundingStrategy::MidpointAwayFromZero);
250        Price::new(rounded).ok()
251    }
252}
253
254impl Price {
255    /// Adds `other` to `self`, returning the result as a `Price`, or `None` on overflow.
256    ///
257    /// Useful when combining two price levels and needing a validated result.
258    pub fn checked_add(self, other: Price) -> Option<Price> {
259        let sum = self.0.checked_add(other.0)?;
260        Price::new(sum).ok()
261    }
262}
263
264impl Price {
265    /// Multiplies this price by `qty`, returning `None` if the result overflows.
266    ///
267    /// Prefer this over the `*` operator when overflow is a concern (e.g., large
268    /// notional values with many decimal digits).
269    pub fn checked_mul(self, qty: Quantity) -> Option<Decimal> {
270        self.0.checked_mul(qty.0)
271    }
272}
273
274impl Price {
275    /// Returns the midpoint between `bid` and `ask`: `(bid + ask) / 2`.
276    ///
277    /// Useful for computing the theoretical fair value between two prices.
278    pub fn midpoint(bid: Price, ask: Price) -> Decimal {
279        (bid.0 + ask.0) / Decimal::TWO
280    }
281
282    /// Applies a percentage move to this price: `self * (1 + pct / 100)`.
283    ///
284    /// Returns `None` if the result is not a valid price (e.g., a large negative `pct`
285    /// would drive the price to zero or below).
286    pub fn pct_move(self, pct: Decimal) -> Option<Price> {
287        let result = self.0 * (Decimal::ONE + pct / Decimal::ONE_HUNDRED);
288        Price::new(result).ok()
289    }
290
291    /// Linearly interpolates between `self` and `other` by factor `t` in `[0, 1]`.
292    ///
293    /// Returns `self + (other - self) * t`. Returns `None` if `t` is outside `[0, 1]`
294    /// or if the result is not a valid price (i.e., not strictly positive).
295    pub fn lerp(self, other: Price, t: Decimal) -> Option<Price> {
296        if t < Decimal::ZERO || t > Decimal::ONE {
297            return None;
298        }
299        let result = self.0 + (other.0 - self.0) * t;
300        Price::new(result).ok()
301    }
302
303    /// Returns `true` if `other` is within `pct` percent of `self`.
304    ///
305    /// Computes `|self - other| / self * 100 <= pct`.
306    /// Returns `false` if `pct` is negative.
307    pub fn is_within_pct(self, other: Price, pct: Decimal) -> bool {
308        if pct < Decimal::ZERO {
309            return false;
310        }
311        let diff = (self.0 - other.0).abs();
312        diff / self.0 * Decimal::ONE_HUNDRED <= pct
313    }
314
315    /// Signed percentage distance from `self` to `other`: `(other - self) / self * 100`.
316    ///
317    /// Positive when `other > self`, negative when `other < self`.
318    pub fn distance_pct(self, other: Price) -> Decimal {
319        (other.0 - self.0) / self.0 * Decimal::ONE_HUNDRED
320    }
321
322    /// Rounds this price to the nearest multiple of `tick_size` using standard rounding.
323    ///
324    /// Equivalent to `snap_to_tick` but named for readability at call sites that
325    /// want explicit rounding (e.g. order routing) vs. snapping (e.g. display).
326    /// Returns `None` if `tick_size <= 0` or the result is zero/negative.
327    pub fn round_to_tick(self, tick_size: Decimal) -> Option<Price> {
328        self.snap_to_tick(tick_size)
329    }
330}
331
332impl std::fmt::Display for Price {
333    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
334        write!(f, "{}", self.0)
335    }
336}
337
338/// `Price + Price` yields a raw `Decimal` (sum is not necessarily a valid price in all contexts).
339impl std::ops::Add<Price> for Price {
340    type Output = Decimal;
341    fn add(self, rhs: Price) -> Decimal {
342        self.0 + rhs.0
343    }
344}
345
346/// `Price - Price` yields a raw `Decimal` (difference may be zero or negative).
347impl std::ops::Sub<Price> for Price {
348    type Output = Decimal;
349    fn sub(self, rhs: Price) -> Decimal {
350        self.0 - rhs.0
351    }
352}
353
354/// `Price * Quantity` yields the notional value as `Decimal`.
355impl std::ops::Mul<Quantity> for Price {
356    type Output = Decimal;
357    fn mul(self, rhs: Quantity) -> Decimal {
358        self.0 * rhs.0
359    }
360}
361
362/// `Price * Decimal` scales a price; returns `None` if the result is not a valid `Price`.
363impl std::ops::Mul<Decimal> for Price {
364    type Output = Option<Price>;
365    fn mul(self, rhs: Decimal) -> Option<Price> {
366        Price::new(self.0 * rhs).ok()
367    }
368}
369
370/// A non-negative quantity backed by [`Decimal`].
371///
372/// # Example
373/// ```rust
374/// use fin_primitives::types::Quantity;
375/// use rust_decimal_macros::dec;
376/// let q = Quantity::zero();
377/// assert_eq!(q.value(), dec!(0));
378/// ```
379#[derive(
380    Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, serde::Serialize, serde::Deserialize,
381)]
382pub struct Quantity(Decimal);
383
384impl Quantity {
385    /// Construct a validated `Quantity`.
386    ///
387    /// # Errors
388    /// Returns [`FinError::InvalidQuantity`] if `d < 0`.
389    pub fn new(d: Decimal) -> Result<Self, FinError> {
390        if d < Decimal::ZERO {
391            return Err(FinError::InvalidQuantity(d));
392        }
393        Ok(Self(d))
394    }
395
396    /// Returns a zero quantity without allocation.
397    pub fn zero() -> Self {
398        Self(Decimal::ZERO)
399    }
400
401    /// Returns `true` if this quantity is zero.
402    pub fn is_zero(&self) -> bool {
403        self.0 == Decimal::ZERO
404    }
405
406    /// Returns the inner [`Decimal`] value.
407    pub fn value(&self) -> Decimal {
408        self.0
409    }
410
411    /// Converts to `f64` with possible precision loss.
412    pub fn to_f64(&self) -> f64 {
413        rust_decimal::prelude::ToPrimitive::to_f64(&self.0).unwrap_or(f64::NAN)
414    }
415
416    /// Constructs a `Quantity` from an `f64`. Returns `None` if `f` is not finite or `< 0`.
417    pub fn from_f64(f: f64) -> Option<Self> {
418        use rust_decimal::prelude::FromPrimitive;
419        let d = Decimal::from_f64(f)?;
420        Self::new(d).ok()
421    }
422}
423
424impl Quantity {
425    /// Adds `other` to this quantity, returning `None` if the result overflows.
426    pub fn checked_add(self, other: Quantity) -> Option<Quantity> {
427        self.0.checked_add(other.0).map(Quantity)
428    }
429
430    /// Subtracts `other` from `self`, returning `None` if the result would be negative or overflow.
431    pub fn checked_sub(self, other: Quantity) -> Option<Quantity> {
432        let result = self.0.checked_sub(other.0)?;
433        if result < Decimal::ZERO {
434            None
435        } else {
436            Some(Quantity(result))
437        }
438    }
439
440    /// Returns the absolute value of this quantity's underlying decimal.
441    ///
442    /// `Quantity` values are normally non-negative, but this is useful when
443    /// working with raw `Decimal` fields (e.g. from `sub` operations that yield
444    /// negative `Decimal`s wrapped in `Quantity(d)` via internal code paths).
445    pub fn abs(self) -> Quantity {
446        Quantity(self.0.abs())
447    }
448
449    /// Splits this quantity into `n` equal parts, with the last absorbing any remainder.
450    ///
451    /// Returns an empty vec if `n` is zero.
452    /// Guarantees that `sum(result) == self.value()`.
453    pub fn split(self, n: usize) -> Vec<Quantity> {
454        if n == 0 {
455            return Vec::new();
456        }
457        let part = self.0 / Decimal::from(n as u64);
458        let mut parts: Vec<Quantity> = (0..n - 1).map(|_| Quantity(part)).collect();
459        let assigned: Decimal = part * Decimal::from((n - 1) as u64);
460        parts.push(Quantity(self.0 - assigned));
461        parts
462    }
463
464    /// Returns `self / total` as a proportion, or `None` if `total` is zero.
465    ///
466    /// Useful for computing position weight within a portfolio.
467    pub fn proportion_of(self, total: Quantity) -> Option<Decimal> {
468        if total.is_zero() {
469            return None;
470        }
471        Some(self.0 / total.0)
472    }
473
474    /// Multiplies this quantity by `factor`, returning `None` if the result is negative.
475    ///
476    /// Useful for scaling position sizes by a fraction (e.g. `0.5` for half-position).
477    /// Returns `None` if `factor` is negative (which would produce an invalid quantity).
478    pub fn scale(self, factor: Decimal) -> Option<Quantity> {
479        if factor < Decimal::ZERO {
480            return None;
481        }
482        Some(Quantity(self.0 * factor))
483    }
484}
485
486impl std::fmt::Display for Quantity {
487    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
488        write!(f, "{}", self.0)
489    }
490}
491
492/// `Quantity + Quantity` always yields a valid `Quantity` (sum of non-negatives is non-negative).
493impl std::ops::Add<Quantity> for Quantity {
494    type Output = Quantity;
495    fn add(self, rhs: Quantity) -> Quantity {
496        Quantity(self.0 + rhs.0)
497    }
498}
499
500/// `Quantity - Quantity` yields a raw `Decimal` (result may be negative).
501impl std::ops::Sub<Quantity> for Quantity {
502    type Output = Decimal;
503    fn sub(self, rhs: Quantity) -> Decimal {
504        self.0 - rhs.0
505    }
506}
507
508/// `Quantity * Decimal` scales a quantity; yields raw `Decimal`.
509impl std::ops::Mul<Decimal> for Quantity {
510    type Output = Decimal;
511    fn mul(self, rhs: Decimal) -> Decimal {
512        self.0 * rhs
513    }
514}
515
516/// The side of a market order or book level.
517#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
518pub enum Side {
519    /// Buy side (bids).
520    Bid,
521    /// Sell side (asks).
522    Ask,
523}
524
525impl Side {
526    /// Returns the opposite side: `Bid` → `Ask`, `Ask` → `Bid`.
527    pub fn opposite(self) -> Side {
528        match self {
529            Side::Bid => Side::Ask,
530            Side::Ask => Side::Bid,
531        }
532    }
533}
534
535impl std::fmt::Display for Side {
536    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
537        match self {
538            Side::Bid => f.write_str("Bid"),
539            Side::Ask => f.write_str("Ask"),
540        }
541    }
542}
543
544/// Exchange-epoch timestamp with nanosecond resolution.
545///
546/// Stores nanoseconds since the Unix epoch (UTC). The inner field is private;
547/// use [`NanoTimestamp::new`] to construct and [`NanoTimestamp::nanos`] to read.
548#[derive(
549    Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, serde::Serialize, serde::Deserialize,
550)]
551pub struct NanoTimestamp(i64);
552
553impl NanoTimestamp {
554    /// Smallest representable timestamp (earliest possible time).
555    pub const MIN: NanoTimestamp = NanoTimestamp(i64::MIN);
556
557    /// Largest representable timestamp (latest possible time).
558    pub const MAX: NanoTimestamp = NanoTimestamp(i64::MAX);
559    /// Constructs a `NanoTimestamp` from a raw nanosecond integer.
560    pub fn new(nanos: i64) -> Self {
561        Self(nanos)
562    }
563
564    /// Returns the raw nanosecond value.
565    pub fn nanos(&self) -> i64 {
566        self.0
567    }
568
569    /// Returns the raw nanosecond value as `u128`.
570    ///
571    /// Saturates to zero for negative timestamps (before the epoch).
572    pub fn as_nanos(&self) -> u128 {
573        self.0.max(0) as u128
574    }
575
576    /// Returns the current UTC time as a `NanoTimestamp`.
577    ///
578    /// Falls back to `0` if the system clock overflows nanosecond range (extremely unlikely).
579    pub fn now() -> Self {
580        Self(Utc::now().timestamp_nanos_opt().unwrap_or(0))
581    }
582
583    /// Returns the nanoseconds elapsed since `self` (i.e. `NanoTimestamp::now() - self`).
584    ///
585    /// Positive when `self` is in the past, negative when `self` is in the future.
586    pub fn elapsed(&self) -> i64 {
587        NanoTimestamp::now().0 - self.0
588    }
589
590    /// Returns the signed nanosecond difference `self - other`.
591    ///
592    /// Positive when `self` is later than `other`, negative when earlier.
593    pub fn duration_since(&self, other: NanoTimestamp) -> i64 {
594        self.0 - other.0
595    }
596
597    /// Returns the signed millisecond difference `self - other`.
598    ///
599    /// Positive when `self` is later than `other`. Rounds toward zero (truncates
600    /// sub-millisecond nanoseconds).
601    pub fn diff_millis(&self, other: NanoTimestamp) -> i64 {
602        (self.0 - other.0) / 1_000_000
603    }
604
605    /// Returns `Some(nanos)` if `self >= other` (non-negative elapsed time), otherwise `None`.
606    ///
607    /// Use this when you want to measure forward elapsed time and treat a negative
608    /// difference as "not yet elapsed" rather than a negative value.
609    pub fn elapsed_nanos_since(&self, other: NanoTimestamp) -> Option<i64> {
610        let diff = self.0 - other.0;
611        if diff >= 0 {
612            Some(diff)
613        } else {
614            None
615        }
616    }
617
618    /// Returns a new `NanoTimestamp` offset by `nanos` (positive = forward in time).
619    pub fn add_nanos(&self, nanos: i64) -> NanoTimestamp {
620        NanoTimestamp(self.0 + nanos)
621    }
622
623    /// Returns a new `NanoTimestamp` offset by `ms` milliseconds.
624    pub fn add_millis(&self, ms: i64) -> NanoTimestamp {
625        NanoTimestamp(self.0 + ms * 1_000_000)
626    }
627
628    /// Returns a new `NanoTimestamp` offset by `secs` seconds.
629    pub fn add_seconds(&self, secs: i64) -> NanoTimestamp {
630        NanoTimestamp(self.0 + secs * 1_000_000_000)
631    }
632
633    /// Shifts this timestamp forward by `minutes` minutes (negative values go backwards).
634    pub fn add_minutes(&self, minutes: i64) -> NanoTimestamp {
635        NanoTimestamp(self.0 + minutes * 60_000_000_000)
636    }
637
638    /// Shifts this timestamp forward by `hours` hours (negative values go backwards).
639    pub fn add_hours(&self, hours: i64) -> NanoTimestamp {
640        NanoTimestamp(self.0 + hours * 3_600_000_000_000)
641    }
642
643    /// Returns `true` if `self` is strictly earlier than `other`.
644    pub fn is_before(&self, other: NanoTimestamp) -> bool {
645        self.0 < other.0
646    }
647
648    /// Returns `true` if `self` is strictly later than `other`.
649    pub fn is_after(&self, other: NanoTimestamp) -> bool {
650        self.0 > other.0
651    }
652
653    /// Returns `true` if `self` and `other` fall within the same calendar second.
654    ///
655    /// Two timestamps are in the same second when
656    /// `floor(self / 1_000_000_000) == floor(other / 1_000_000_000)`.
657    pub fn is_same_second(&self, other: NanoTimestamp) -> bool {
658        self.0.div_euclid(1_000_000_000) == other.0.div_euclid(1_000_000_000)
659    }
660
661    /// Returns `true` if `self` and `other` fall within the same calendar minute.
662    ///
663    /// Two timestamps are in the same minute when
664    /// `floor(self / 60_000_000_000) == floor(other / 60_000_000_000)`.
665    pub fn is_same_minute(&self, other: NanoTimestamp) -> bool {
666        self.0.div_euclid(60_000_000_000) == other.0.div_euclid(60_000_000_000)
667    }
668
669    /// Constructs a `NanoTimestamp` from milliseconds since the Unix epoch.
670    pub fn from_millis(ms: i64) -> Self {
671        Self(ms * 1_000_000)
672    }
673
674    /// Returns milliseconds since the Unix epoch (truncates sub-millisecond precision).
675    pub fn to_millis(&self) -> i64 {
676        self.0 / 1_000_000
677    }
678
679    /// Constructs a `NanoTimestamp` from whole seconds since the Unix epoch.
680    pub fn from_secs(secs: i64) -> Self {
681        Self(secs * 1_000_000_000)
682    }
683
684    /// Returns whole seconds since the Unix epoch (truncates sub-second precision).
685    pub fn to_secs(&self) -> i64 {
686        self.0 / 1_000_000_000
687    }
688
689    /// Constructs a `NanoTimestamp` from a [`DateTime<Utc>`].
690    ///
691    /// Falls back to `0` if the datetime is outside the representable nanosecond range.
692    pub fn from_datetime(dt: DateTime<Utc>) -> Self {
693        Self(dt.timestamp_nanos_opt().unwrap_or(0))
694    }
695
696    /// Converts this timestamp to a [`DateTime<Utc>`].
697    pub fn to_datetime(&self) -> DateTime<Utc> {
698        let secs = self.0 / 1_000_000_000;
699        #[allow(clippy::cast_sign_loss)]
700        let nanos = (self.0 % 1_000_000_000) as u32;
701        Utc.timestamp_opt(secs, nanos).single().unwrap_or_else(|| {
702            Utc.timestamp_opt(0, 0)
703                .single()
704                .unwrap_or(DateTime::<Utc>::MIN_UTC)
705        })
706    }
707
708    /// Converts this timestamp to floating-point seconds since the Unix epoch.
709    pub fn to_seconds(&self) -> f64 {
710        self.0 as f64 / 1_000_000_000.0
711    }
712
713    /// Returns the signed millisecond difference `self - other`.
714    ///
715    /// Positive when `self` is after `other`.
716    pub fn duration_millis(self, other: NanoTimestamp) -> i64 {
717        (self.0 - other.0) / 1_000_000
718    }
719
720    /// Returns the earlier of `self` and `other`.
721    pub fn min(self, other: NanoTimestamp) -> NanoTimestamp {
722        if self.0 <= other.0 { self } else { other }
723    }
724
725    /// Returns the later of `self` and `other`.
726    pub fn max(self, other: NanoTimestamp) -> NanoTimestamp {
727        if self.0 >= other.0 { self } else { other }
728    }
729
730    /// Returns the signed nanosecond difference `self - earlier`.
731    ///
732    /// Positive when `self` is after `earlier`, negative when before.
733    /// Use for computing durations between two timestamps without assuming ordering.
734    pub fn elapsed_since(self, earlier: NanoTimestamp) -> i64 {
735        self.0 - earlier.0
736    }
737
738    /// Returns the difference in whole seconds: `(self - earlier) / 1_000_000_000`.
739    ///
740    /// Positive when `self` is after `earlier`.
741    pub fn seconds_since(self, earlier: NanoTimestamp) -> i64 {
742        (self.0 - earlier.0) / 1_000_000_000
743    }
744
745    /// Returns the difference in whole minutes: `(self - earlier) / 60_000_000_000`.
746    ///
747    /// Positive when `self` is after `earlier`.
748    pub fn minutes_since(self, earlier: NanoTimestamp) -> i64 {
749        (self.0 - earlier.0) / 60_000_000_000
750    }
751
752    /// Returns the difference in whole hours: `(self - earlier) / 3_600_000_000_000`.
753    ///
754    /// Positive when `self` is after `earlier`.
755    pub fn hours_since(self, earlier: NanoTimestamp) -> i64 {
756        (self.0 - earlier.0) / 3_600_000_000_000
757    }
758
759    /// Snaps this timestamp down to the nearest multiple of `period_nanos`.
760    ///
761    /// For example, rounding `ts=1_500_000_000` down to `period_nanos=1_000_000_000`
762    /// yields `1_000_000_000`. Useful for bar-boundary calculations.
763    ///
764    /// Returns `self` unchanged when `period_nanos == 0`.
765    pub fn round_down_to(&self, period_nanos: i64) -> NanoTimestamp {
766        if period_nanos == 0 {
767            return *self;
768        }
769        NanoTimestamp(self.0 - self.0.rem_euclid(period_nanos))
770    }
771
772    /// Formats this timestamp as a UTC date string `"YYYY-MM-DD"`.
773    ///
774    /// Useful for grouping bars or ticks by calendar date (e.g., daily session boundaries).
775    pub fn to_date_string(&self) -> String {
776        use chrono::{DateTime, Utc};
777        let secs = self.0 / 1_000_000_000;
778        let nanos_part = (self.0 % 1_000_000_000).unsigned_abs() as u32;
779        let dt = DateTime::<Utc>::from_timestamp(secs, nanos_part)
780            .unwrap_or_default();
781        dt.format("%Y-%m-%d").to_string()
782    }
783
784    /// Returns `true` if `self` and `other` fall on the same UTC calendar day.
785    ///
786    /// Useful for detecting session boundaries when grouping bars by date.
787    pub fn is_same_day(&self, other: NanoTimestamp) -> bool {
788        // Two timestamps are on the same day when they share the same `floor(nanos / 86400e9)`.
789        const DAY_NANOS: i64 = 86_400 * 1_000_000_000;
790        self.0.div_euclid(DAY_NANOS) == other.0.div_euclid(DAY_NANOS)
791    }
792
793    /// Floors this timestamp to the start of the current UTC minute.
794    ///
795    /// Floors this timestamp to the start of the current UTC hour.
796    ///
797    /// Truncates minutes, seconds, and nanoseconds: returns `HH:00:00.000000000`.
798    pub fn floor_to_hour(&self) -> NanoTimestamp {
799        const HOUR_NANOS: i64 = 3_600 * 1_000_000_000;
800        NanoTimestamp(self.0.div_euclid(HOUR_NANOS) * HOUR_NANOS)
801    }
802
803    /// Returns the UTC hour of day (0–23).
804    pub fn hour_of_day(self) -> u8 {
805        use chrono::Timelike;
806        self.to_datetime().hour() as u8
807    }
808
809    /// Returns the minute within the current UTC hour (0–59).
810    pub fn minute_of_hour(self) -> u8 {
811        use chrono::Timelike;
812        self.to_datetime().minute() as u8
813    }
814
815    /// Returns `true` if the UTC hour falls within `[open_hour, close_hour)`.
816    ///
817    /// Useful for checking whether a timestamp is within a trading session.
818    /// Both `open_hour` and `close_hour` must be in 0–23; if `open_hour >= close_hour` the
819    /// function returns `false`.
820    pub fn is_market_hours(self, open_hour: u8, close_hour: u8) -> bool {
821        if open_hour >= close_hour { return false; }
822        let h = self.hour_of_day();
823        h >= open_hour && h < close_hour
824    }
825
826    /// Floors this timestamp to midnight UTC (start of the day).
827    ///
828    /// Truncates hours, minutes, seconds, and nanoseconds: returns `00:00:00.000000000`.
829    pub fn floor_to_day(&self) -> NanoTimestamp {
830        const DAY_NANOS: i64 = 86_400 * 1_000_000_000;
831        NanoTimestamp(self.0.div_euclid(DAY_NANOS) * DAY_NANOS)
832    }
833
834    /// Truncates nanoseconds and seconds: returns the timestamp at `HH:MM:00.000000000`.
835    pub fn floor_to_minute(&self) -> NanoTimestamp {
836        const MINUTE_NANOS: i64 = 60 * 1_000_000_000;
837        NanoTimestamp(self.0.div_euclid(MINUTE_NANOS) * MINUTE_NANOS)
838    }
839
840    /// Returns the signed elapsed time between `self` and `other` in seconds.
841    ///
842    /// Positive when `self` is after `other`. Resolution is nanoseconds.
843    pub fn elapsed_seconds(&self, other: NanoTimestamp) -> f64 {
844        (self.0 - other.0) as f64 / 1_000_000_000.0
845    }
846
847    /// Formats this timestamp as a UTC datetime string `"YYYY-MM-DD HH:MM:SS"`.
848    ///
849    /// Useful for logging and display when a full datetime is needed rather than just the date.
850    pub fn to_datetime_string(&self) -> String {
851        use chrono::{DateTime, Utc};
852        let secs = self.0 / 1_000_000_000;
853        let nanos_part = (self.0 % 1_000_000_000).unsigned_abs() as u32;
854        let dt = DateTime::<Utc>::from_timestamp(secs, nanos_part)
855            .unwrap_or_default();
856        dt.format("%Y-%m-%d %H:%M:%S").to_string()
857    }
858
859    /// Returns `true` if `self` falls within `[start, end]` (inclusive on both ends).
860    pub fn is_between(self, start: NanoTimestamp, end: NanoTimestamp) -> bool {
861        self.0 >= start.0 && self.0 <= end.0
862    }
863
864    /// Converts this timestamp to Unix milliseconds (truncating sub-millisecond precision).
865    pub fn to_unix_ms(self) -> i64 {
866        self.0 / 1_000_000
867    }
868
869    /// Returns whole seconds since the Unix epoch (truncates sub-second precision).
870    pub fn to_unix_seconds(self) -> i64 {
871        self.0 / 1_000_000_000
872    }
873
874    /// Returns the second within the current UTC minute (0–59).
875    pub fn second_of_minute(self) -> u8 {
876        use chrono::Timelike;
877        self.to_datetime().second() as u8
878    }
879
880    /// Returns the day of the week as `u8` where Monday = 0 and Sunday = 6.
881    ///
882    /// Computed from the Unix epoch (1970-01-01 was a Thursday = 3).
883    pub fn day_of_week(self) -> u8 {
884        const DAY_NANOS: i64 = 86_400 * 1_000_000_000;
885        let days = self.0.div_euclid(DAY_NANOS);
886        // Unix epoch (day 0) was Thursday = 3
887        ((days + 3).rem_euclid(7)) as u8
888    }
889
890    /// Shifts the timestamp backward by `minutes` minutes.
891    ///
892    /// Equivalent to `add_minutes(-minutes)`.
893    pub fn sub_minutes(&self, minutes: i64) -> NanoTimestamp {
894        NanoTimestamp(self.0 - minutes * 60_000_000_000)
895    }
896
897    /// Returns `true` if this timestamp falls on a Saturday (5) or Sunday (6) in UTC.
898    pub fn is_weekend(self) -> bool {
899        let dow = self.day_of_week();
900        dow == 5 || dow == 6
901    }
902
903    /// Returns the timestamp floored to Monday 00:00:00 UTC of the containing week.
904    pub fn start_of_week(self) -> NanoTimestamp {
905        const DAY_NANOS: i64 = 86_400 * 1_000_000_000;
906        let dow = self.day_of_week() as i64; // 0=Mon … 6=Sun
907        NanoTimestamp(self.floor_to_day().0 - dow * DAY_NANOS)
908    }
909
910    /// Shifts the timestamp forward by `days` calendar days (positive or negative).
911    pub fn add_days(&self, days: i64) -> NanoTimestamp {
912        const DAY_NANOS: i64 = 86_400 * 1_000_000_000;
913        NanoTimestamp(self.0 + days * DAY_NANOS)
914    }
915
916    /// Returns the absolute number of whole minutes between `self` and `other`.
917    pub fn minutes_between(self, other: NanoTimestamp) -> u64 {
918        const MINUTE_NANOS: u64 = 60 * 1_000_000_000;
919        (self.0 - other.0).unsigned_abs() / MINUTE_NANOS
920    }
921
922    /// Returns the absolute number of whole seconds between `self` and `other`.
923    pub fn seconds_between(self, other: NanoTimestamp) -> u64 {
924        const SECOND_NANOS: u64 = 1_000_000_000;
925        (self.0 - other.0).unsigned_abs() / SECOND_NANOS
926    }
927
928    /// Returns the day of the year (1 = January 1, 365/366 = December 31).
929    ///
930    /// Uses the UTC calendar. The Unix epoch (1970-01-01) is day 1.
931    pub fn day_of_year(self) -> u16 {
932        use chrono::Datelike;
933        self.to_datetime().ordinal() as u16
934    }
935
936    /// Returns the quarter number: 1 (Jan–Mar), 2 (Apr–Jun), 3 (Jul–Sep), or 4 (Oct–Dec).
937    ///
938    /// Uses the UTC calendar.
939    pub fn quarter(self) -> u8 {
940        use chrono::Datelike;
941        let month = self.to_datetime().month();
942        ((month - 1) / 3 + 1) as u8
943    }
944
945    /// Returns the ISO 8601 week number (1–53).
946    ///
947    /// Uses the UTC calendar. Week 1 is the first week containing a Thursday.
948    pub fn week_of_year(self) -> u32 {
949        use chrono::Datelike;
950        self.to_datetime().iso_week().week()
951    }
952
953    /// Returns `true` if `self` and `other` fall in the same ISO calendar week and year.
954    pub fn is_same_week(self, other: NanoTimestamp) -> bool {
955        use chrono::Datelike;
956        let a = self.to_datetime().iso_week();
957        let b = other.to_datetime().iso_week();
958        a.week() == b.week() && a.year() == b.year()
959    }
960
961    /// Returns `true` if `self` and `other` fall in the same calendar month and year.
962    pub fn is_same_month(self, other: NanoTimestamp) -> bool {
963        use chrono::Datelike;
964        let a = self.to_datetime();
965        let b = other.to_datetime();
966        a.year() == b.year() && a.month() == b.month()
967    }
968
969    /// Snaps this timestamp to the most recent Monday at 00:00:00 UTC (start of ISO week).
970    pub fn floor_to_week(self) -> NanoTimestamp {
971        use chrono::{Datelike, Duration, TimeZone};
972        let dt = self.to_datetime();
973        let days_since_monday = dt.weekday().num_days_from_monday() as i64;
974        let monday = dt - Duration::days(days_since_monday);
975        let monday_midnight = Utc
976            .with_ymd_and_hms(monday.year(), monday.month(), monday.day(), 0, 0, 0)
977            .single()
978            .expect("valid date");
979        NanoTimestamp::from_datetime(monday_midnight)
980    }
981
982    /// Returns `true` if `self` and `other` fall in the same calendar year.
983    pub fn is_same_year(self, other: NanoTimestamp) -> bool {
984        use chrono::Datelike;
985        self.to_datetime().year() == other.to_datetime().year()
986    }
987
988    /// Returns the absolute number of calendar days between two timestamps.
989    pub fn days_between(self, other: NanoTimestamp) -> u64 {
990        let diff_nanos = (self.0 - other.0).unsigned_abs();
991        diff_nanos / 86_400_000_000_000
992    }
993
994    /// Returns a `NanoTimestamp` at 23:59:59.999_999_999 UTC on the same calendar day.
995    pub fn end_of_day(self) -> NanoTimestamp {
996        use chrono::{Datelike, TimeZone, Timelike};
997        let dt = chrono::Utc.timestamp_nanos(self.0);
998        let eod = chrono::Utc
999            .with_ymd_and_hms(dt.year(), dt.month(), dt.day(), 23, 59, 59)
1000            .single()
1001            .map(|d| d.with_nanosecond(999_999_999).unwrap_or(d))
1002            .unwrap_or(dt);
1003        NanoTimestamp(eod.timestamp_nanos_opt().unwrap_or(self.0))
1004    }
1005
1006    /// Returns a `NanoTimestamp` at 00:00:00.000_000_000 UTC on the first day of the same month.
1007    pub fn start_of_month(self) -> NanoTimestamp {
1008        use chrono::{Datelike, TimeZone};
1009        let dt = chrono::Utc.timestamp_nanos(self.0);
1010        let som = chrono::Utc
1011            .with_ymd_and_hms(dt.year(), dt.month(), 1, 0, 0, 0)
1012            .single()
1013            .unwrap_or(dt);
1014        NanoTimestamp(som.timestamp_nanos_opt().unwrap_or(self.0))
1015    }
1016
1017    /// Returns a `NanoTimestamp` at 23:59:59.999_999_999 UTC on the last day of the same month.
1018    pub fn end_of_month(self) -> NanoTimestamp {
1019        use chrono::{Datelike, TimeZone};
1020        let dt = chrono::Utc.timestamp_nanos(self.0);
1021        // Advance to the first day of next month, then subtract one nanosecond.
1022        let (next_year, next_month) = if dt.month() == 12 {
1023            (dt.year() + 1, 1u32)
1024        } else {
1025            (dt.year(), dt.month() + 1)
1026        };
1027        let start_of_next = chrono::Utc
1028            .with_ymd_and_hms(next_year, next_month, 1, 0, 0, 0)
1029            .single()
1030            .unwrap_or(dt);
1031        let nanos = start_of_next.timestamp_nanos_opt().unwrap_or(self.0) - 1;
1032        NanoTimestamp(nanos)
1033    }
1034
1035    /// Truncates the timestamp to the nearest whole second.
1036    pub fn floor_to_second(self) -> NanoTimestamp {
1037        const NANOS_PER_SECOND: i64 = 1_000_000_000;
1038        NanoTimestamp((self.0 / NANOS_PER_SECOND) * NANOS_PER_SECOND)
1039    }
1040
1041    /// Returns `true` if both timestamps fall in the same UTC hour.
1042    pub fn is_same_hour(self, other: NanoTimestamp) -> bool {
1043        use chrono::{Datelike, TimeZone, Timelike};
1044        let a = chrono::Utc.timestamp_nanos(self.0);
1045        let b = chrono::Utc.timestamp_nanos(other.0);
1046        a.year() == b.year() && a.month() == b.month() && a.day() == b.day() && a.hour() == b.hour()
1047    }
1048
1049    /// Shifts the timestamp forward by `weeks` weeks (negative shifts backward).
1050    pub fn add_weeks(&self, weeks: i64) -> NanoTimestamp {
1051        const NANOS_PER_WEEK: i64 = 7 * 24 * 3_600 * 1_000_000_000;
1052        NanoTimestamp(self.0 + weeks * NANOS_PER_WEEK)
1053    }
1054
1055    /// Shifts the timestamp backward by `hours` hours.
1056    pub fn sub_hours(&self, hours: i64) -> NanoTimestamp {
1057        const NANOS_PER_HOUR: i64 = 3_600 * 1_000_000_000;
1058        NanoTimestamp(self.0 - hours * NANOS_PER_HOUR)
1059    }
1060
1061    /// Shifts the timestamp backward by `weeks` weeks.
1062    pub fn sub_weeks(&self, weeks: i64) -> NanoTimestamp {
1063        const NANOS_PER_WEEK: i64 = 7 * 24 * 3_600 * 1_000_000_000;
1064        NanoTimestamp(self.0 - weeks * NANOS_PER_WEEK)
1065    }
1066
1067    /// Shifts the timestamp backward by `secs` seconds.
1068    pub fn sub_seconds(&self, secs: i64) -> NanoTimestamp {
1069        const NANOS_PER_SECOND: i64 = 1_000_000_000;
1070        NanoTimestamp(self.0 - secs * NANOS_PER_SECOND)
1071    }
1072
1073    /// Formats the timestamp as `"HH:MM:SS"` in UTC.
1074    pub fn to_time_string(&self) -> String {
1075        use chrono::{TimeZone, Timelike};
1076        let dt = chrono::Utc.timestamp_nanos(self.0);
1077        format!("{:02}:{:02}:{:02}", dt.hour(), dt.minute(), dt.second())
1078    }
1079
1080    /// Elapsed time in hours between `self` and `other` (always non-negative).
1081    pub fn elapsed_hours(&self, other: NanoTimestamp) -> f64 {
1082        let diff = (self.0 - other.0).unsigned_abs();
1083        diff as f64 / (3_600.0 * 1_000_000_000.0)
1084    }
1085
1086    /// Returns `true` if this timestamp falls on the same UTC calendar day as `other`.
1087    pub fn is_today(&self, other: NanoTimestamp) -> bool {
1088        self.is_same_day(other)
1089    }
1090
1091    /// Absolute difference in nanoseconds between `self` and `other`.
1092    pub fn nanoseconds_between(self, other: NanoTimestamp) -> u64 {
1093        (self.0 - other.0).unsigned_abs()
1094    }
1095
1096    /// Elapsed time in minutes between `self` and `other` (always non-negative).
1097    pub fn elapsed_minutes(&self, other: NanoTimestamp) -> f64 {
1098        let diff = (self.0 - other.0).unsigned_abs();
1099        diff as f64 / (60.0 * 1_000_000_000.0)
1100    }
1101
1102    /// Elapsed time in calendar days (as a float) between `self` and `other`.
1103    pub fn elapsed_days(&self, other: NanoTimestamp) -> f64 {
1104        let diff = (self.0 - other.0).unsigned_abs();
1105        diff as f64 / (86_400.0 * 1_000_000_000.0)
1106    }
1107
1108    /// Shifts the timestamp backward by `nanos` nanoseconds.
1109    pub fn sub_nanos(&self, nanos: i64) -> NanoTimestamp {
1110        NanoTimestamp(self.0 - nanos)
1111    }
1112
1113    /// Truncates to the first nanosecond of the UTC year (January 1, 00:00:00.000000000).
1114    pub fn start_of_year(self) -> NanoTimestamp {
1115        use chrono::{Datelike, TimeZone};
1116        let dt = chrono::Utc.timestamp_nanos(self.0);
1117        let start = chrono::Utc
1118            .with_ymd_and_hms(dt.year(), 1, 1, 0, 0, 0)
1119            .single()
1120            .unwrap_or(dt);
1121        NanoTimestamp(start.timestamp_nanos_opt().unwrap_or(self.0))
1122    }
1123
1124    /// Returns the last nanosecond of the UTC year containing this timestamp.
1125    pub fn end_of_year(self) -> NanoTimestamp {
1126        use chrono::{Datelike, TimeZone};
1127        let dt = chrono::Utc.timestamp_nanos(self.0);
1128        let start_next = chrono::Utc
1129            .with_ymd_and_hms(dt.year() + 1, 1, 1, 0, 0, 0)
1130            .single()
1131            .unwrap_or(dt);
1132        let nanos = start_next.timestamp_nanos_opt().unwrap_or(self.0) - 1;
1133        NanoTimestamp(nanos)
1134    }
1135
1136    /// Adds `months` calendar months, clamping to the last day of the resulting month.
1137    pub fn add_months(&self, months: i32) -> NanoTimestamp {
1138        use chrono::{Datelike, TimeZone};
1139        let dt = chrono::Utc.timestamp_nanos(self.0);
1140        let total_months = dt.month() as i32 + months;
1141        let year = dt.year() + (total_months - 1).div_euclid(12);
1142        let month = ((total_months - 1).rem_euclid(12) + 1) as u32;
1143        let day = dt.day().min(days_in_month(year, month));
1144        let new_dt = chrono::Utc
1145            .with_ymd_and_hms(year, month, day, dt.hour(), dt.minute(), dt.second())
1146            .single()
1147            .unwrap_or(dt);
1148        NanoTimestamp(new_dt.timestamp_nanos_opt().unwrap_or(self.0))
1149    }
1150
1151    /// Returns the `NanoTimestamp` at the start of the current calendar quarter (Jan/Apr/Jul/Oct 1
1152    /// 00:00:00.000000000 UTC).
1153    pub fn start_of_quarter(self) -> NanoTimestamp {
1154        use chrono::{Datelike, TimeZone};
1155        let dt = chrono::Utc.timestamp_nanos(self.0);
1156        let quarter_start_month = ((dt.month() - 1) / 3) * 3 + 1;
1157        chrono::Utc
1158            .with_ymd_and_hms(dt.year(), quarter_start_month, 1, 0, 0, 0)
1159            .single()
1160            .map(|d| NanoTimestamp(d.timestamp_nanos_opt().unwrap_or(self.0)))
1161            .unwrap_or(self)
1162    }
1163
1164    /// Returns the `NanoTimestamp` at the last nanosecond of the current calendar quarter.
1165    pub fn end_of_quarter(self) -> NanoTimestamp {
1166        use chrono::{Datelike, TimeZone};
1167        let dt = chrono::Utc.timestamp_nanos(self.0);
1168        let quarter_end_month = ((dt.month() - 1) / 3) * 3 + 3;
1169        let last_day = days_in_month(dt.year(), quarter_end_month);
1170        chrono::Utc
1171            .with_ymd_and_hms(dt.year(), quarter_end_month, last_day, 23, 59, 59)
1172            .single()
1173            .map(|d| NanoTimestamp(d.timestamp_nanos_opt().unwrap_or(self.0) + 999_999_999))
1174            .unwrap_or(self)
1175    }
1176
1177    /// Returns `true` if `self` and `other` fall in the same calendar quarter and year.
1178    pub fn is_same_quarter(self, other: NanoTimestamp) -> bool {
1179        use chrono::{Datelike, TimeZone};
1180        let a = chrono::Utc.timestamp_nanos(self.0);
1181        let b = chrono::Utc.timestamp_nanos(other.0);
1182        a.year() == b.year() && ((a.month() - 1) / 3) == ((b.month() - 1) / 3)
1183    }
1184}
1185
1186fn days_in_month(year: i32, month: u32) -> u32 {
1187    match month {
1188        1 | 3 | 5 | 7 | 8 | 10 | 12 => 31,
1189        4 | 6 | 9 | 11 => 30,
1190        2 if year % 400 == 0 || (year % 4 == 0 && year % 100 != 0) => 29,
1191        2 => 28,
1192        _ => 30,
1193    }
1194}
1195
1196/// `NanoTimestamp + i64` shifts the timestamp forward by `nanos` nanoseconds.
1197impl std::ops::Add<i64> for NanoTimestamp {
1198    type Output = NanoTimestamp;
1199    fn add(self, rhs: i64) -> NanoTimestamp {
1200        NanoTimestamp(self.0 + rhs)
1201    }
1202}
1203
1204/// `NanoTimestamp - i64` shifts the timestamp backward by `nanos` nanoseconds.
1205impl std::ops::Sub<i64> for NanoTimestamp {
1206    type Output = NanoTimestamp;
1207    fn sub(self, rhs: i64) -> NanoTimestamp {
1208        NanoTimestamp(self.0 - rhs)
1209    }
1210}
1211
1212/// `NanoTimestamp - NanoTimestamp` returns the signed nanosecond difference.
1213impl std::ops::Sub<NanoTimestamp> for NanoTimestamp {
1214    type Output = i64;
1215    fn sub(self, rhs: NanoTimestamp) -> i64 {
1216        self.0 - rhs.0
1217    }
1218}
1219
1220impl std::fmt::Display for NanoTimestamp {
1221    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1222        write!(f, "{}", self.0)
1223    }
1224}
1225
1226#[cfg(test)]
1227mod tests {
1228    use super::*;
1229    use rust_decimal_macros::dec;
1230
1231    // --- Symbol ---
1232
1233    #[test]
1234    fn test_symbol_new_valid_ok() {
1235        let sym = Symbol::new("AAPL").unwrap();
1236        assert_eq!(sym.as_str(), "AAPL");
1237    }
1238
1239    #[test]
1240    fn test_symbol_new_empty_fails() {
1241        let result = Symbol::new("");
1242        assert!(matches!(result, Err(FinError::InvalidSymbol(_))));
1243    }
1244
1245    #[test]
1246    fn test_symbol_new_whitespace_fails() {
1247        let result = Symbol::new("AA PL");
1248        assert!(matches!(result, Err(FinError::InvalidSymbol(_))));
1249    }
1250
1251    #[test]
1252    fn test_symbol_new_leading_whitespace_fails() {
1253        let result = Symbol::new(" AAPL");
1254        assert!(matches!(result, Err(FinError::InvalidSymbol(_))));
1255    }
1256
1257    #[test]
1258    fn test_symbol_display() {
1259        let sym = Symbol::new("TSLA").unwrap();
1260        assert_eq!(format!("{sym}"), "TSLA");
1261    }
1262
1263    #[test]
1264    fn test_symbol_clone_equality() {
1265        let a = Symbol::new("BTC").unwrap();
1266        let b = a.clone();
1267        assert_eq!(a, b);
1268    }
1269
1270    #[test]
1271    fn test_symbol_arc_clone_is_cheap() {
1272        let a = Symbol::new("ETH").unwrap();
1273        let b = a.clone();
1274        assert_eq!(a.as_str().as_ptr(), b.as_str().as_ptr());
1275    }
1276
1277    // --- Price ---
1278
1279    #[test]
1280    fn test_price_new_positive_ok() {
1281        let p = Price::new(dec!(100.5)).unwrap();
1282        assert_eq!(p.value(), dec!(100.5));
1283    }
1284
1285    #[test]
1286    fn test_price_new_zero_fails() {
1287        let result = Price::new(dec!(0));
1288        assert!(matches!(result, Err(FinError::InvalidPrice(_))));
1289    }
1290
1291    #[test]
1292    fn test_price_new_negative_fails() {
1293        let result = Price::new(dec!(-1));
1294        assert!(matches!(result, Err(FinError::InvalidPrice(_))));
1295    }
1296
1297    #[test]
1298    fn test_price_ordering() {
1299        let p1 = Price::new(dec!(1)).unwrap();
1300        let p2 = Price::new(dec!(2)).unwrap();
1301        assert!(p1 < p2);
1302    }
1303
1304    #[test]
1305    fn test_price_add() {
1306        let a = Price::new(dec!(10)).unwrap();
1307        let b = Price::new(dec!(5)).unwrap();
1308        assert_eq!(a + b, dec!(15));
1309    }
1310
1311    #[test]
1312    fn test_price_sub() {
1313        let a = Price::new(dec!(10)).unwrap();
1314        let b = Price::new(dec!(3)).unwrap();
1315        assert_eq!(a - b, dec!(7));
1316    }
1317
1318    #[test]
1319    fn test_price_mul_quantity() {
1320        let p = Price::new(dec!(10)).unwrap();
1321        let q = Quantity::new(dec!(5)).unwrap();
1322        assert_eq!(p * q, dec!(50));
1323    }
1324
1325    #[test]
1326    fn test_price_mul_decimal_valid() {
1327        let p = Price::new(dec!(10)).unwrap();
1328        assert_eq!((p * dec!(2)).unwrap().value(), dec!(20));
1329    }
1330
1331    #[test]
1332    fn test_price_mul_decimal_zero_returns_none() {
1333        let p = Price::new(dec!(10)).unwrap();
1334        assert!((p * dec!(0)).is_none());
1335    }
1336
1337    // --- Quantity ---
1338
1339    #[test]
1340    fn test_quantity_new_zero_ok() {
1341        let q = Quantity::new(dec!(0)).unwrap();
1342        assert_eq!(q.value(), dec!(0));
1343    }
1344
1345    #[test]
1346    fn test_quantity_new_positive_ok() {
1347        let q = Quantity::new(dec!(5.5)).unwrap();
1348        assert_eq!(q.value(), dec!(5.5));
1349    }
1350
1351    #[test]
1352    fn test_quantity_new_negative_fails() {
1353        let result = Quantity::new(dec!(-0.01));
1354        assert!(matches!(result, Err(FinError::InvalidQuantity(_))));
1355    }
1356
1357    #[test]
1358    fn test_quantity_zero_constructor() {
1359        let q = Quantity::zero();
1360        assert_eq!(q.value(), Decimal::ZERO);
1361    }
1362
1363    #[test]
1364    fn test_quantity_add() {
1365        let a = Quantity::new(dec!(3)).unwrap();
1366        let b = Quantity::new(dec!(4)).unwrap();
1367        assert_eq!((a + b).value(), dec!(7));
1368    }
1369
1370    #[test]
1371    fn test_quantity_sub_positive() {
1372        let a = Quantity::new(dec!(10)).unwrap();
1373        let b = Quantity::new(dec!(3)).unwrap();
1374        assert_eq!(a - b, dec!(7));
1375    }
1376
1377    #[test]
1378    fn test_quantity_sub_negative() {
1379        let a = Quantity::new(dec!(3)).unwrap();
1380        let b = Quantity::new(dec!(10)).unwrap();
1381        assert_eq!(a - b, dec!(-7));
1382    }
1383
1384    #[test]
1385    fn test_quantity_mul_decimal() {
1386        let q = Quantity::new(dec!(5)).unwrap();
1387        assert_eq!(q * dec!(3), dec!(15));
1388    }
1389
1390    #[test]
1391    fn test_quantity_is_zero() {
1392        assert!(Quantity::zero().is_zero());
1393        assert!(!Quantity::new(dec!(1)).unwrap().is_zero());
1394    }
1395
1396    // --- Side ---
1397
1398    #[test]
1399    fn test_side_display_bid() {
1400        assert_eq!(format!("{}", Side::Bid), "Bid");
1401    }
1402
1403    #[test]
1404    fn test_side_display_ask() {
1405        assert_eq!(format!("{}", Side::Ask), "Ask");
1406    }
1407
1408    // --- NanoTimestamp ---
1409
1410    #[test]
1411    fn test_nano_timestamp_now_positive() {
1412        let ts = NanoTimestamp::now();
1413        assert!(ts.nanos() > 0);
1414    }
1415
1416    #[test]
1417    fn test_nano_timestamp_ordering() {
1418        let ts1 = NanoTimestamp::new(1_000_000_000);
1419        let ts2 = NanoTimestamp::new(2_000_000_000);
1420        assert!(ts1 < ts2);
1421    }
1422
1423    #[test]
1424    fn test_nano_timestamp_to_datetime_epoch() {
1425        let ts = NanoTimestamp::new(0);
1426        let dt = ts.to_datetime();
1427        assert_eq!(dt.timestamp(), 0);
1428    }
1429
1430    #[test]
1431    fn test_nano_timestamp_to_datetime_roundtrip() {
1432        let ts = NanoTimestamp::new(1_700_000_000_000_000_000_i64);
1433        let dt = ts.to_datetime();
1434        assert_eq!(
1435            dt.timestamp_nanos_opt().unwrap_or(0),
1436            1_700_000_000_000_000_000_i64
1437        );
1438    }
1439
1440    #[test]
1441    fn test_nano_timestamp_nanos_roundtrip() {
1442        let ts = NanoTimestamp::new(42_000_000);
1443        assert_eq!(ts.nanos(), 42_000_000);
1444    }
1445
1446    #[test]
1447    fn test_nano_timestamp_duration_since_positive() {
1448        let a = NanoTimestamp::new(1_000);
1449        let b = NanoTimestamp::new(600);
1450        assert_eq!(a.duration_since(b), 400);
1451    }
1452
1453    #[test]
1454    fn test_nano_timestamp_duration_since_negative() {
1455        let a = NanoTimestamp::new(500);
1456        let b = NanoTimestamp::new(1_000);
1457        assert_eq!(a.duration_since(b), -500);
1458    }
1459
1460    #[test]
1461    fn test_symbol_len() {
1462        let sym = Symbol::new("AAPL").unwrap();
1463        assert_eq!(sym.len(), 4);
1464    }
1465
1466    #[test]
1467    fn test_symbol_is_empty_always_false() {
1468        let sym = Symbol::new("X").unwrap();
1469        assert!(!sym.is_empty());
1470    }
1471
1472    #[test]
1473    fn test_symbol_try_from_string_valid() {
1474        let sym = Symbol::try_from("AAPL".to_owned()).unwrap();
1475        assert_eq!(sym.as_str(), "AAPL");
1476    }
1477
1478    #[test]
1479    fn test_symbol_try_from_str_valid() {
1480        let sym = Symbol::try_from("ETH").unwrap();
1481        assert_eq!(sym.as_str(), "ETH");
1482    }
1483
1484    #[test]
1485    fn test_symbol_try_from_empty_fails() {
1486        assert!(Symbol::try_from("").is_err());
1487    }
1488
1489    #[test]
1490    fn test_symbol_try_from_whitespace_fails() {
1491        assert!(Symbol::try_from("BTC USD").is_err());
1492    }
1493
1494    #[test]
1495    fn test_nano_timestamp_from_datetime_roundtrip() {
1496        let original = NanoTimestamp::new(1_700_000_000_000_000_000_i64);
1497        let dt = original.to_datetime();
1498        let recovered = NanoTimestamp::from_datetime(dt);
1499        assert_eq!(recovered.nanos(), original.nanos());
1500    }
1501
1502    #[test]
1503    fn test_nano_timestamp_from_datetime_epoch() {
1504        use chrono::Utc;
1505        let epoch = Utc.timestamp_opt(0, 0).single().unwrap();
1506        let ts = NanoTimestamp::from_datetime(epoch);
1507        assert_eq!(ts.nanos(), 0);
1508    }
1509
1510    #[test]
1511    fn test_price_to_f64() {
1512        let p = Price::new(dec!(123.45)).unwrap();
1513        let f = p.to_f64();
1514        assert!((f - 123.45_f64).abs() < 1e-6);
1515    }
1516
1517    #[test]
1518    fn test_quantity_to_f64() {
1519        let q = Quantity::new(dec!(42)).unwrap();
1520        assert!((q.to_f64() - 42.0_f64).abs() < 1e-10);
1521    }
1522
1523    #[test]
1524    fn test_price_from_f64_valid() {
1525        let p = Price::from_f64(42.5).unwrap();
1526        assert!((p.to_f64() - 42.5).abs() < 1e-6);
1527    }
1528
1529    #[test]
1530    fn test_price_from_f64_zero_returns_none() {
1531        assert!(Price::from_f64(0.0).is_none());
1532    }
1533
1534    #[test]
1535    fn test_price_from_f64_negative_returns_none() {
1536        assert!(Price::from_f64(-1.0).is_none());
1537    }
1538
1539    #[test]
1540    fn test_quantity_from_f64_valid() {
1541        let q = Quantity::from_f64(10.0).unwrap();
1542        assert!((q.to_f64() - 10.0).abs() < 1e-10);
1543    }
1544
1545    #[test]
1546    fn test_quantity_from_f64_zero_valid() {
1547        let q = Quantity::from_f64(0.0).unwrap();
1548        assert!(q.is_zero());
1549    }
1550
1551    #[test]
1552    fn test_quantity_from_f64_negative_returns_none() {
1553        assert!(Quantity::from_f64(-1.0).is_none());
1554    }
1555
1556    #[test]
1557    fn test_nano_timestamp_add_millis() {
1558        let ts = NanoTimestamp::new(0);
1559        assert_eq!(ts.add_millis(1).nanos(), 1_000_000);
1560    }
1561
1562    #[test]
1563    fn test_nano_timestamp_add_seconds() {
1564        let ts = NanoTimestamp::new(0);
1565        assert_eq!(ts.add_seconds(2).nanos(), 2_000_000_000);
1566    }
1567
1568    #[test]
1569    fn test_nano_timestamp_is_before_after() {
1570        let a = NanoTimestamp::new(1_000);
1571        let b = NanoTimestamp::new(2_000);
1572        assert!(a.is_before(b));
1573        assert!(b.is_after(a));
1574        assert!(!a.is_after(b));
1575        assert!(!b.is_before(a));
1576    }
1577
1578    #[test]
1579    fn test_nano_timestamp_from_secs_roundtrip() {
1580        let ts = NanoTimestamp::from_secs(1_700_000_000);
1581        assert_eq!(ts.to_secs(), 1_700_000_000);
1582    }
1583
1584    #[test]
1585    fn test_nano_timestamp_from_secs_truncates_sub_second() {
1586        let ts = NanoTimestamp::new(1_700_000_000_999_999_999);
1587        assert_eq!(ts.to_secs(), 1_700_000_000);
1588    }
1589
1590    #[test]
1591    fn test_symbol_ord_lexicographic() {
1592        let a = Symbol::new("AAPL").unwrap();
1593        let b = Symbol::new("MSFT").unwrap();
1594        let c = Symbol::new("AAPL").unwrap();
1595        assert!(a < b);
1596        assert!(b > a);
1597        assert_eq!(a.cmp(&c), std::cmp::Ordering::Equal);
1598    }
1599
1600    #[test]
1601    fn test_symbol_ord_usable_in_btreemap() {
1602        use std::collections::BTreeMap;
1603        let mut m: BTreeMap<Symbol, i32> = BTreeMap::new();
1604        m.insert(Symbol::new("Z").unwrap(), 3);
1605        m.insert(Symbol::new("A").unwrap(), 1);
1606        m.insert(Symbol::new("M").unwrap(), 2);
1607        let keys: Vec<_> = m.keys().map(|s| s.as_str()).collect();
1608        assert_eq!(keys, ["A", "M", "Z"]);
1609    }
1610
1611    #[test]
1612    fn test_price_pct_change_positive() {
1613        let p1 = Price::new(dec!(100)).unwrap();
1614        let p2 = Price::new(dec!(110)).unwrap();
1615        assert_eq!(p1.pct_change_to(p2), dec!(10));
1616    }
1617
1618    #[test]
1619    fn test_price_pct_change_negative() {
1620        let p1 = Price::new(dec!(100)).unwrap();
1621        let p2 = Price::new(dec!(90)).unwrap();
1622        assert_eq!(p1.pct_change_to(p2), dec!(-10));
1623    }
1624
1625    #[test]
1626    fn test_price_pct_change_zero() {
1627        let p = Price::new(dec!(100)).unwrap();
1628        assert_eq!(p.pct_change_to(p), dec!(0));
1629    }
1630
1631    #[test]
1632    fn test_nano_timestamp_elapsed_is_non_negative_for_past() {
1633        let past = NanoTimestamp::new(0); // epoch — definitely in the past
1634        assert!(past.elapsed() > 0);
1635    }
1636
1637    #[test]
1638    fn test_price_checked_mul_some() {
1639        let p = Price::new(dec!(100)).unwrap();
1640        let q = Quantity::new(dec!(5)).unwrap();
1641        assert_eq!(p.checked_mul(q), Some(dec!(500)));
1642    }
1643
1644    #[test]
1645    fn test_price_checked_mul_with_zero_qty() {
1646        let p = Price::new(dec!(100)).unwrap();
1647        let q = Quantity::zero();
1648        assert_eq!(p.checked_mul(q), Some(dec!(0)));
1649    }
1650
1651    #[test]
1652    fn test_quantity_checked_add() {
1653        let a = Quantity::new(dec!(10)).unwrap();
1654        let b = Quantity::new(dec!(5)).unwrap();
1655        assert_eq!(a.checked_add(b).map(|q| q.value()), Some(dec!(15)));
1656    }
1657
1658    #[test]
1659    fn test_nano_timestamp_min_less_than_max() {
1660        assert!(NanoTimestamp::MIN < NanoTimestamp::MAX);
1661        assert!(NanoTimestamp::MIN < NanoTimestamp::new(0));
1662        assert!(NanoTimestamp::new(0) < NanoTimestamp::MAX);
1663    }
1664
1665    #[test]
1666    fn test_price_midpoint() {
1667        let bid = Price::new(dec!(99)).unwrap();
1668        let ask = Price::new(dec!(101)).unwrap();
1669        assert_eq!(Price::midpoint(bid, ask), dec!(100));
1670    }
1671
1672    #[test]
1673    fn test_price_midpoint_same_price() {
1674        let p = Price::new(dec!(100)).unwrap();
1675        assert_eq!(Price::midpoint(p, p), dec!(100));
1676    }
1677
1678    #[test]
1679    fn test_price_mid_method() {
1680        let bid = Price::new(dec!(100)).unwrap();
1681        let ask = Price::new(dec!(102)).unwrap();
1682        let mid = bid.mid(ask);
1683        assert_eq!(mid.value(), dec!(101));
1684    }
1685
1686    #[test]
1687    fn test_price_mid_method_same_price() {
1688        let p = Price::new(dec!(100)).unwrap();
1689        assert_eq!(p.mid(p).value(), dec!(100));
1690    }
1691
1692    #[test]
1693    fn test_price_abs_diff_positive() {
1694        let a = Price::new(dec!(105)).unwrap();
1695        let b = Price::new(dec!(100)).unwrap();
1696        assert_eq!(a.abs_diff(b), dec!(5));
1697        assert_eq!(b.abs_diff(a), dec!(5));
1698    }
1699
1700    #[test]
1701    fn test_price_abs_diff_same() {
1702        let p = Price::new(dec!(100)).unwrap();
1703        assert_eq!(p.abs_diff(p), dec!(0));
1704    }
1705
1706    #[test]
1707    fn test_quantity_checked_sub_valid() {
1708        let a = Quantity::new(dec!(10)).unwrap();
1709        let b = Quantity::new(dec!(3)).unwrap();
1710        assert_eq!(a.checked_sub(b).unwrap().value(), dec!(7));
1711    }
1712
1713    #[test]
1714    fn test_quantity_checked_sub_exact_zero() {
1715        let a = Quantity::new(dec!(5)).unwrap();
1716        let b = Quantity::new(dec!(5)).unwrap();
1717        assert_eq!(a.checked_sub(b).unwrap().value(), dec!(0));
1718    }
1719
1720    #[test]
1721    fn test_quantity_checked_sub_negative_returns_none() {
1722        let a = Quantity::new(dec!(3)).unwrap();
1723        let b = Quantity::new(dec!(5)).unwrap();
1724        assert!(a.checked_sub(b).is_none());
1725    }
1726
1727    #[test]
1728    fn test_nano_timestamp_min_returns_earlier() {
1729        let t1 = NanoTimestamp::new(100);
1730        let t2 = NanoTimestamp::new(200);
1731        assert_eq!(t1.min(t2), t1);
1732        assert_eq!(t2.min(t1), t1);
1733    }
1734
1735    #[test]
1736    fn test_nano_timestamp_max_returns_later() {
1737        let t1 = NanoTimestamp::new(100);
1738        let t2 = NanoTimestamp::new(200);
1739        assert_eq!(t1.max(t2), t2);
1740        assert_eq!(t2.max(t1), t2);
1741    }
1742
1743    #[test]
1744    fn test_nano_timestamp_min_max_same() {
1745        let t = NanoTimestamp::new(500);
1746        assert_eq!(t.min(t), t);
1747        assert_eq!(t.max(t), t);
1748    }
1749
1750    #[test]
1751    fn test_side_opposite_bid() {
1752        assert_eq!(Side::Bid.opposite(), Side::Ask);
1753    }
1754
1755    #[test]
1756    fn test_side_opposite_ask() {
1757        assert_eq!(Side::Ask.opposite(), Side::Bid);
1758    }
1759
1760    #[test]
1761    fn test_side_opposite_involution() {
1762        assert_eq!(Side::Bid.opposite().opposite(), Side::Bid);
1763    }
1764
1765    #[test]
1766    fn test_price_checked_add_valid() {
1767        let a = Price::new(dec!(100)).unwrap();
1768        let b = Price::new(dec!(50)).unwrap();
1769        assert_eq!(a.checked_add(b).unwrap().value(), dec!(150));
1770    }
1771
1772    #[test]
1773    fn test_price_checked_add_result_validated() {
1774        // Sum of two valid prices is always positive → always Some
1775        let a = Price::new(dec!(1)).unwrap();
1776        let b = Price::new(dec!(2)).unwrap();
1777        assert!(a.checked_add(b).is_some());
1778    }
1779
1780    #[test]
1781    fn test_price_lerp_midpoint() {
1782        let a = Price::new(dec!(100)).unwrap();
1783        let b = Price::new(dec!(200)).unwrap();
1784        let mid = a.lerp(b, dec!(0.5)).unwrap();
1785        assert_eq!(mid.value(), dec!(150));
1786    }
1787
1788    #[test]
1789    fn test_price_lerp_at_zero_returns_self() {
1790        let a = Price::new(dec!(100)).unwrap();
1791        let b = Price::new(dec!(200)).unwrap();
1792        assert_eq!(a.lerp(b, Decimal::ZERO).unwrap().value(), dec!(100));
1793    }
1794
1795    #[test]
1796    fn test_price_lerp_at_one_returns_other() {
1797        let a = Price::new(dec!(100)).unwrap();
1798        let b = Price::new(dec!(200)).unwrap();
1799        assert_eq!(a.lerp(b, Decimal::ONE).unwrap().value(), dec!(200));
1800    }
1801
1802    #[test]
1803    fn test_price_lerp_out_of_range_returns_none() {
1804        let a = Price::new(dec!(100)).unwrap();
1805        let b = Price::new(dec!(200)).unwrap();
1806        assert!(a.lerp(b, dec!(1.5)).is_none());
1807        assert!(a.lerp(b, dec!(-0.1)).is_none());
1808    }
1809
1810    #[test]
1811    fn test_quantity_scale_half() {
1812        let q = Quantity::new(dec!(100)).unwrap();
1813        let result = q.scale(dec!(0.5)).unwrap();
1814        assert_eq!(result.value(), dec!(50));
1815    }
1816
1817    #[test]
1818    fn test_quantity_scale_zero_factor() {
1819        let q = Quantity::new(dec!(100)).unwrap();
1820        let result = q.scale(Decimal::ZERO).unwrap();
1821        assert_eq!(result.value(), dec!(0));
1822    }
1823
1824    #[test]
1825    fn test_quantity_scale_negative_factor_returns_none() {
1826        let q = Quantity::new(dec!(100)).unwrap();
1827        assert!(q.scale(dec!(-1)).is_none());
1828    }
1829
1830    #[test]
1831    fn test_nano_timestamp_elapsed_since_positive() {
1832        let earlier = NanoTimestamp::new(1000);
1833        let later = NanoTimestamp::new(3000);
1834        assert_eq!(later.elapsed_since(earlier), 2000);
1835    }
1836
1837    #[test]
1838    fn test_nano_timestamp_elapsed_since_negative() {
1839        let earlier = NanoTimestamp::new(1000);
1840        let later = NanoTimestamp::new(3000);
1841        // reversed order gives negative result
1842        assert_eq!(earlier.elapsed_since(later), -2000);
1843    }
1844
1845    #[test]
1846    fn test_nano_timestamp_elapsed_since_same_is_zero() {
1847        let ts = NanoTimestamp::new(5000);
1848        assert_eq!(ts.elapsed_since(ts), 0);
1849    }
1850
1851    #[test]
1852    fn test_nano_timestamp_to_seconds_one_second() {
1853        let ts = NanoTimestamp::new(1_000_000_000);
1854        assert!((ts.to_seconds() - 1.0_f64).abs() < 1e-9);
1855    }
1856
1857    #[test]
1858    fn test_nano_timestamp_to_seconds_zero() {
1859        let ts = NanoTimestamp::new(0);
1860        assert_eq!(ts.to_seconds(), 0.0);
1861    }
1862
1863    #[test]
1864    fn test_quantity_split_even() {
1865        let q = Quantity::new(dec!(10)).unwrap();
1866        let parts = q.split(5);
1867        assert_eq!(parts.len(), 5);
1868        let total: Decimal = parts.iter().map(|p| p.value()).sum();
1869        assert_eq!(total, dec!(10));
1870    }
1871
1872    #[test]
1873    fn test_quantity_split_remainder_goes_to_last() {
1874        let q = Quantity::new(dec!(10)).unwrap();
1875        let parts = q.split(3);
1876        assert_eq!(parts.len(), 3);
1877        let total: Decimal = parts.iter().map(|p| p.value()).sum();
1878        assert_eq!(total, dec!(10));
1879    }
1880
1881    #[test]
1882    fn test_quantity_split_zero_n_returns_empty() {
1883        let q = Quantity::new(dec!(10)).unwrap();
1884        assert!(q.split(0).is_empty());
1885    }
1886
1887    #[test]
1888    fn test_quantity_split_one_returns_self() {
1889        let q = Quantity::new(dec!(10)).unwrap();
1890        let parts = q.split(1);
1891        assert_eq!(parts.len(), 1);
1892        assert_eq!(parts[0].value(), dec!(10));
1893    }
1894
1895    #[test]
1896    fn test_price_pct_move_up() {
1897        let p = Price::new(dec!(100)).unwrap();
1898        let result = p.pct_move(dec!(10)).unwrap();
1899        assert_eq!(result.value(), dec!(110));
1900    }
1901
1902    #[test]
1903    fn test_price_pct_move_down() {
1904        let p = Price::new(dec!(100)).unwrap();
1905        let result = p.pct_move(dec!(-10)).unwrap();
1906        assert_eq!(result.value(), dec!(90));
1907    }
1908
1909    #[test]
1910    fn test_price_pct_move_negative_to_invalid() {
1911        let p = Price::new(dec!(100)).unwrap();
1912        // -100% → price = 0, invalid
1913        assert!(p.pct_move(dec!(-100)).is_none());
1914    }
1915
1916    #[test]
1917    fn test_quantity_proportion_of_half() {
1918        let a = Quantity::new(dec!(5)).unwrap();
1919        let total = Quantity::new(dec!(10)).unwrap();
1920        assert_eq!(a.proportion_of(total), Some(dec!(0.5)));
1921    }
1922
1923    #[test]
1924    fn test_quantity_proportion_of_zero_total_returns_none() {
1925        let a = Quantity::new(dec!(5)).unwrap();
1926        let total = Quantity::zero();
1927        assert!(a.proportion_of(total).is_none());
1928    }
1929
1930    #[test]
1931    fn test_nano_timestamp_duration_millis() {
1932        let a = NanoTimestamp::new(0);
1933        let b = NanoTimestamp::new(1_500_000_000); // 1.5 seconds
1934        assert_eq!(b.duration_millis(a), 1500);
1935    }
1936
1937    #[test]
1938    fn test_nano_timestamp_duration_millis_negative() {
1939        let a = NanoTimestamp::new(0);
1940        let b = NanoTimestamp::new(2_000_000_000);
1941        assert_eq!(a.duration_millis(b), -2000);
1942    }
1943
1944    #[test]
1945    fn test_nano_timestamp_minutes_since_positive() {
1946        let a = NanoTimestamp::new(0);
1947        let b = NanoTimestamp::new(3 * 60_000_000_000i64);
1948        assert_eq!(b.minutes_since(a), 3);
1949    }
1950
1951    #[test]
1952    fn test_nano_timestamp_minutes_since_negative() {
1953        let a = NanoTimestamp::new(0);
1954        let b = NanoTimestamp::new(3 * 60_000_000_000i64);
1955        assert_eq!(a.minutes_since(b), -3);
1956    }
1957
1958    #[test]
1959    fn test_nano_timestamp_hours_since_positive() {
1960        let a = NanoTimestamp::new(0);
1961        let b = NanoTimestamp::new(2 * 3_600_000_000_000i64);
1962        assert_eq!(b.hours_since(a), 2);
1963    }
1964
1965    #[test]
1966    fn test_nano_timestamp_hours_since_same_returns_zero() {
1967        let a = NanoTimestamp::new(1_000_000);
1968        assert_eq!(a.hours_since(a), 0);
1969    }
1970
1971    #[test]
1972    fn test_price_is_within_pct_same_price() {
1973        let p = Price::new(dec!(100)).unwrap();
1974        assert!(p.is_within_pct(p, dec!(0)));
1975    }
1976
1977    #[test]
1978    fn test_price_is_within_pct_within_range() {
1979        let p = Price::new(dec!(100)).unwrap();
1980        let q = Price::new(dec!(101)).unwrap();
1981        assert!(p.is_within_pct(q, dec!(2)));
1982    }
1983
1984    #[test]
1985    fn test_price_is_within_pct_outside_range() {
1986        let p = Price::new(dec!(100)).unwrap();
1987        let q = Price::new(dec!(110)).unwrap();
1988        assert!(!p.is_within_pct(q, dec!(5)));
1989    }
1990
1991    #[test]
1992    fn test_price_is_within_pct_negative_pct_returns_false() {
1993        let p = Price::new(dec!(100)).unwrap();
1994        assert!(!p.is_within_pct(p, dec!(-1)));
1995    }
1996
1997    #[test]
1998    fn test_timestamp_is_between_inclusive() {
1999        let ts = NanoTimestamp::new(500);
2000        assert!(ts.is_between(NanoTimestamp::new(100), NanoTimestamp::new(900)));
2001        assert!(ts.is_between(NanoTimestamp::new(500), NanoTimestamp::new(500))); // exact bounds
2002    }
2003
2004    #[test]
2005    fn test_timestamp_is_between_outside() {
2006        let ts = NanoTimestamp::new(50);
2007        assert!(!ts.is_between(NanoTimestamp::new(100), NanoTimestamp::new(900)));
2008        let ts2 = NanoTimestamp::new(1000);
2009        assert!(!ts2.is_between(NanoTimestamp::new(100), NanoTimestamp::new(900)));
2010    }
2011
2012    #[test]
2013    fn test_timestamp_to_unix_ms() {
2014        let ts = NanoTimestamp::new(1_500_000_000); // 1.5 seconds
2015        assert_eq!(ts.to_unix_ms(), 1500);
2016    }
2017
2018    #[test]
2019    fn test_timestamp_to_unix_ms_truncates() {
2020        let ts = NanoTimestamp::new(1_999_999); // 1.999999 ms — truncates to 1
2021        assert_eq!(ts.to_unix_ms(), 1);
2022    }
2023
2024    #[test]
2025    fn test_price_round_to_tick_same_as_snap() {
2026        let p = Price::new(dec!(100.7)).unwrap();
2027        assert_eq!(p.round_to_tick(dec!(0.5)), p.snap_to_tick(dec!(0.5)));
2028    }
2029
2030    #[test]
2031    fn test_price_round_to_tick_invalid_tick_returns_none() {
2032        let p = Price::new(dec!(100)).unwrap();
2033        assert!(p.round_to_tick(dec!(0)).is_none());
2034        assert!(p.round_to_tick(dec!(-1)).is_none());
2035    }
2036
2037    #[test]
2038    fn test_nanotimestamp_day_of_week_epoch_is_thursday() {
2039        // Unix epoch (1970-01-01) was Thursday = 3
2040        let ts = NanoTimestamp::new(0);
2041        assert_eq!(ts.day_of_week(), 3);
2042    }
2043
2044    #[test]
2045    fn test_nanotimestamp_day_of_week_next_day() {
2046        // 1970-01-02 was Friday = 4
2047        let ts = NanoTimestamp::new(86_400 * 1_000_000_000);
2048        assert_eq!(ts.day_of_week(), 4);
2049    }
2050
2051    #[test]
2052    fn test_nanotimestamp_sub_minutes_round_trip() {
2053        let ts = NanoTimestamp::new(3_600_000_000_000); // 1 hour
2054        let back = ts.sub_minutes(30);
2055        let forward = back.add_minutes(30);
2056        assert_eq!(forward.nanos(), ts.nanos());
2057    }
2058
2059    #[test]
2060    fn test_nanotimestamp_sub_minutes_by_zero() {
2061        let ts = NanoTimestamp::new(1_000_000_000);
2062        assert_eq!(ts.sub_minutes(0).nanos(), ts.nanos());
2063    }
2064}