Skip to main content

fin_primitives/types/
mod.rs

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