use crate::error::FinError;
use chrono::{DateTime, TimeZone, Timelike, Utc};
use rust_decimal::Decimal;
use std::sync::Arc;
#[derive(Debug, Clone, PartialEq, Eq, Hash, serde::Serialize, serde::Deserialize)]
#[serde(transparent)]
pub struct Symbol(Arc<str>);
impl Symbol {
pub fn new(s: impl AsRef<str>) -> Result<Self, FinError> {
let s = s.as_ref();
if s.is_empty() || s.chars().any(char::is_whitespace) {
return Err(FinError::InvalidSymbol(s.to_owned()));
}
Ok(Self(Arc::from(s)))
}
pub fn as_str(&self) -> &str {
&self.0
}
pub fn len(&self) -> usize {
self.0.len()
}
pub fn is_empty(&self) -> bool {
self.0.is_empty()
}
}
impl std::fmt::Display for Symbol {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(&self.0)
}
}
impl AsRef<str> for Symbol {
fn as_ref(&self) -> &str {
&self.0
}
}
impl std::borrow::Borrow<str> for Symbol {
fn borrow(&self) -> &str {
&self.0
}
}
impl TryFrom<String> for Symbol {
type Error = FinError;
fn try_from(s: String) -> Result<Self, Self::Error> {
Symbol::new(s)
}
}
impl TryFrom<&str> for Symbol {
type Error = FinError;
fn try_from(s: &str) -> Result<Self, Self::Error> {
Symbol::new(s)
}
}
impl PartialOrd for Symbol {
fn partial_cmp(&self, other: &Self) -> Option<std::cmp::Ordering> {
Some(self.cmp(other))
}
}
impl Ord for Symbol {
fn cmp(&self, other: &Self) -> std::cmp::Ordering {
self.0.as_ref().cmp(other.0.as_ref())
}
}
impl From<Symbol> for String {
fn from(s: Symbol) -> Self {
s.as_str().to_owned()
}
}
impl From<Symbol> for Arc<str> {
fn from(s: Symbol) -> Self {
s.0.clone()
}
}
#[derive(
Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, serde::Serialize, serde::Deserialize,
)]
pub struct Price(Decimal);
impl Price {
pub fn new(d: Decimal) -> Result<Self, FinError> {
if d <= Decimal::ZERO {
return Err(FinError::InvalidPrice(d));
}
Ok(Self(d))
}
pub fn value(&self) -> Decimal {
self.0
}
pub fn to_f64(&self) -> f64 {
rust_decimal::prelude::ToPrimitive::to_f64(&self.0).unwrap_or(f64::NAN)
}
pub fn from_f64(f: f64) -> Option<Self> {
use rust_decimal::prelude::FromPrimitive;
let d = Decimal::from_f64(f)?;
Self::new(d).ok()
}
pub fn to_string_with_dp(&self, dp: u32) -> String {
self.0.round_dp(dp).to_string()
}
}
impl Price {
pub fn pct_change_to(self, other: Price) -> Decimal {
(other.0 - self.0) / self.0 * Decimal::ONE_HUNDRED
}
pub fn mid(self, other: Price) -> Price {
Price((self.0 + other.0) / Decimal::TWO)
}
}
impl Price {
pub fn abs_diff(self, other: Price) -> Decimal {
(self.0 - other.0).abs()
}
pub fn snap_to_tick(self, tick_size: Decimal) -> Option<Price> {
if tick_size <= Decimal::ZERO {
return None;
}
let rounded = (self.0 / tick_size).round() * tick_size;
Price::new(rounded).ok()
}
pub fn clamp(self, lo: Price, hi: Price) -> Price {
if self.0 < lo.0 {
lo
} else if self.0 > hi.0 {
hi
} else {
self
}
}
}
impl Price {
pub fn round_to(self, dp: u32) -> Option<Price> {
let rounded = self.0.round_dp(dp);
Price::new(rounded).ok()
}
pub fn round_half_up(self, dp: u32) -> Option<Price> {
use rust_decimal::RoundingStrategy;
let rounded = self.0.round_dp_with_strategy(dp, RoundingStrategy::MidpointAwayFromZero);
Price::new(rounded).ok()
}
}
impl Price {
pub fn checked_add(self, other: Price) -> Option<Price> {
let sum = self.0.checked_add(other.0)?;
Price::new(sum).ok()
}
}
impl Price {
pub fn checked_mul(self, qty: Quantity) -> Option<Decimal> {
self.0.checked_mul(qty.0)
}
}
impl Price {
pub fn midpoint(bid: Price, ask: Price) -> Decimal {
(bid.0 + ask.0) / Decimal::TWO
}
pub fn pct_move(self, pct: Decimal) -> Option<Price> {
let result = self.0 * (Decimal::ONE + pct / Decimal::ONE_HUNDRED);
Price::new(result).ok()
}
pub fn lerp(self, other: Price, t: Decimal) -> Option<Price> {
if t < Decimal::ZERO || t > Decimal::ONE {
return None;
}
let result = self.0 + (other.0 - self.0) * t;
Price::new(result).ok()
}
pub fn is_within_pct(self, other: Price, pct: Decimal) -> bool {
if pct < Decimal::ZERO {
return false;
}
let diff = (self.0 - other.0).abs();
diff / self.0 * Decimal::ONE_HUNDRED <= pct
}
pub fn distance_pct(self, other: Price) -> Decimal {
(other.0 - self.0) / self.0 * Decimal::ONE_HUNDRED
}
pub fn round_to_tick(self, tick_size: Decimal) -> Option<Price> {
self.snap_to_tick(tick_size)
}
}
impl std::fmt::Display for Price {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "{}", self.0)
}
}
impl std::ops::Add<Price> for Price {
type Output = Decimal;
fn add(self, rhs: Price) -> Decimal {
self.0 + rhs.0
}
}
impl std::ops::Sub<Price> for Price {
type Output = Decimal;
fn sub(self, rhs: Price) -> Decimal {
self.0 - rhs.0
}
}
impl std::ops::Mul<Quantity> for Price {
type Output = Decimal;
fn mul(self, rhs: Quantity) -> Decimal {
self.0 * rhs.0
}
}
impl std::ops::Mul<Decimal> for Price {
type Output = Option<Price>;
fn mul(self, rhs: Decimal) -> Option<Price> {
Price::new(self.0 * rhs).ok()
}
}
#[derive(
Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, serde::Serialize, serde::Deserialize,
)]
pub struct Quantity(Decimal);
impl Quantity {
pub fn new(d: Decimal) -> Result<Self, FinError> {
if d < Decimal::ZERO {
return Err(FinError::InvalidQuantity(d));
}
Ok(Self(d))
}
pub fn zero() -> Self {
Self(Decimal::ZERO)
}
pub fn is_zero(&self) -> bool {
self.0 == Decimal::ZERO
}
pub fn value(&self) -> Decimal {
self.0
}
pub fn to_f64(&self) -> f64 {
rust_decimal::prelude::ToPrimitive::to_f64(&self.0).unwrap_or(f64::NAN)
}
pub fn from_f64(f: f64) -> Option<Self> {
use rust_decimal::prelude::FromPrimitive;
let d = Decimal::from_f64(f)?;
Self::new(d).ok()
}
}
impl Quantity {
pub fn checked_add(self, other: Quantity) -> Option<Quantity> {
self.0.checked_add(other.0).map(Quantity)
}
pub fn checked_sub(self, other: Quantity) -> Option<Quantity> {
let result = self.0.checked_sub(other.0)?;
if result < Decimal::ZERO {
None
} else {
Some(Quantity(result))
}
}
pub fn abs(self) -> Quantity {
Quantity(self.0.abs())
}
pub fn split(self, n: usize) -> Vec<Quantity> {
if n == 0 {
return Vec::new();
}
let part = self.0 / Decimal::from(n as u64);
let mut parts: Vec<Quantity> = (0..n - 1).map(|_| Quantity(part)).collect();
let assigned: Decimal = part * Decimal::from((n - 1) as u64);
parts.push(Quantity(self.0 - assigned));
parts
}
pub fn proportion_of(self, total: Quantity) -> Option<Decimal> {
if total.is_zero() {
return None;
}
Some(self.0 / total.0)
}
pub fn scale(self, factor: Decimal) -> Option<Quantity> {
if factor < Decimal::ZERO {
return None;
}
Some(Quantity(self.0 * factor))
}
}
impl std::fmt::Display for Quantity {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "{}", self.0)
}
}
impl std::ops::Add<Quantity> for Quantity {
type Output = Quantity;
fn add(self, rhs: Quantity) -> Quantity {
Quantity(self.0 + rhs.0)
}
}
impl std::ops::Sub<Quantity> for Quantity {
type Output = Decimal;
fn sub(self, rhs: Quantity) -> Decimal {
self.0 - rhs.0
}
}
impl std::ops::Mul<Decimal> for Quantity {
type Output = Decimal;
fn mul(self, rhs: Decimal) -> Decimal {
self.0 * rhs
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
pub enum Side {
Bid,
Ask,
}
impl Side {
pub fn opposite(self) -> Side {
match self {
Side::Bid => Side::Ask,
Side::Ask => Side::Bid,
}
}
}
impl std::fmt::Display for Side {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Side::Bid => f.write_str("Bid"),
Side::Ask => f.write_str("Ask"),
}
}
}
#[derive(
Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, serde::Serialize, serde::Deserialize,
)]
pub struct NanoTimestamp(i64);
impl NanoTimestamp {
pub const MIN: NanoTimestamp = NanoTimestamp(i64::MIN);
pub const MAX: NanoTimestamp = NanoTimestamp(i64::MAX);
pub fn new(nanos: i64) -> Self {
Self(nanos)
}
pub fn nanos(&self) -> i64 {
self.0
}
pub fn as_nanos(&self) -> u128 {
self.0.max(0) as u128
}
pub fn now() -> Self {
Self(Utc::now().timestamp_nanos_opt().unwrap_or(0))
}
pub fn elapsed(&self) -> i64 {
NanoTimestamp::now().0 - self.0
}
pub fn duration_since(&self, other: NanoTimestamp) -> i64 {
self.0 - other.0
}
pub fn diff_millis(&self, other: NanoTimestamp) -> i64 {
(self.0 - other.0) / 1_000_000
}
pub fn elapsed_nanos_since(&self, other: NanoTimestamp) -> Option<i64> {
let diff = self.0 - other.0;
if diff >= 0 {
Some(diff)
} else {
None
}
}
pub fn add_nanos(&self, nanos: i64) -> NanoTimestamp {
NanoTimestamp(self.0 + nanos)
}
pub fn add_millis(&self, ms: i64) -> NanoTimestamp {
NanoTimestamp(self.0 + ms * 1_000_000)
}
pub fn add_seconds(&self, secs: i64) -> NanoTimestamp {
NanoTimestamp(self.0 + secs * 1_000_000_000)
}
pub fn add_minutes(&self, minutes: i64) -> NanoTimestamp {
NanoTimestamp(self.0 + minutes * 60_000_000_000)
}
pub fn add_hours(&self, hours: i64) -> NanoTimestamp {
NanoTimestamp(self.0 + hours * 3_600_000_000_000)
}
pub fn is_before(&self, other: NanoTimestamp) -> bool {
self.0 < other.0
}
pub fn is_after(&self, other: NanoTimestamp) -> bool {
self.0 > other.0
}
pub fn is_same_second(&self, other: NanoTimestamp) -> bool {
self.0.div_euclid(1_000_000_000) == other.0.div_euclid(1_000_000_000)
}
pub fn is_same_minute(&self, other: NanoTimestamp) -> bool {
self.0.div_euclid(60_000_000_000) == other.0.div_euclid(60_000_000_000)
}
pub fn from_millis(ms: i64) -> Self {
Self(ms * 1_000_000)
}
pub fn to_millis(&self) -> i64 {
self.0 / 1_000_000
}
pub fn from_secs(secs: i64) -> Self {
Self(secs * 1_000_000_000)
}
pub fn to_secs(&self) -> i64 {
self.0 / 1_000_000_000
}
pub fn from_datetime(dt: DateTime<Utc>) -> Self {
Self(dt.timestamp_nanos_opt().unwrap_or(0))
}
pub fn to_datetime(&self) -> DateTime<Utc> {
let secs = self.0 / 1_000_000_000;
#[allow(clippy::cast_sign_loss)]
let nanos = (self.0 % 1_000_000_000) as u32;
Utc.timestamp_opt(secs, nanos).single().unwrap_or_else(|| {
Utc.timestamp_opt(0, 0)
.single()
.unwrap_or(DateTime::<Utc>::MIN_UTC)
})
}
pub fn to_seconds(&self) -> f64 {
self.0 as f64 / 1_000_000_000.0
}
pub fn duration_millis(self, other: NanoTimestamp) -> i64 {
(self.0 - other.0) / 1_000_000
}
pub fn min(self, other: NanoTimestamp) -> NanoTimestamp {
if self.0 <= other.0 { self } else { other }
}
pub fn max(self, other: NanoTimestamp) -> NanoTimestamp {
if self.0 >= other.0 { self } else { other }
}
pub fn elapsed_since(self, earlier: NanoTimestamp) -> i64 {
self.0 - earlier.0
}
pub fn seconds_since(self, earlier: NanoTimestamp) -> i64 {
(self.0 - earlier.0) / 1_000_000_000
}
pub fn minutes_since(self, earlier: NanoTimestamp) -> i64 {
(self.0 - earlier.0) / 60_000_000_000
}
pub fn hours_since(self, earlier: NanoTimestamp) -> i64 {
(self.0 - earlier.0) / 3_600_000_000_000
}
pub fn round_down_to(&self, period_nanos: i64) -> NanoTimestamp {
if period_nanos == 0 {
return *self;
}
NanoTimestamp(self.0 - self.0.rem_euclid(period_nanos))
}
pub fn to_date_string(&self) -> String {
use chrono::{DateTime, Utc};
let secs = self.0 / 1_000_000_000;
let nanos_part = (self.0 % 1_000_000_000).unsigned_abs() as u32;
let dt = DateTime::<Utc>::from_timestamp(secs, nanos_part)
.unwrap_or_default();
dt.format("%Y-%m-%d").to_string()
}
pub fn is_same_day(&self, other: NanoTimestamp) -> bool {
const DAY_NANOS: i64 = 86_400 * 1_000_000_000;
self.0.div_euclid(DAY_NANOS) == other.0.div_euclid(DAY_NANOS)
}
pub fn floor_to_hour(&self) -> NanoTimestamp {
const HOUR_NANOS: i64 = 3_600 * 1_000_000_000;
NanoTimestamp(self.0.div_euclid(HOUR_NANOS) * HOUR_NANOS)
}
pub fn hour_of_day(self) -> u8 {
use chrono::Timelike;
self.to_datetime().hour() as u8
}
pub fn minute_of_hour(self) -> u8 {
use chrono::Timelike;
self.to_datetime().minute() as u8
}
pub fn is_market_hours(self, open_hour: u8, close_hour: u8) -> bool {
if open_hour >= close_hour { return false; }
let h = self.hour_of_day();
h >= open_hour && h < close_hour
}
pub fn floor_to_day(&self) -> NanoTimestamp {
const DAY_NANOS: i64 = 86_400 * 1_000_000_000;
NanoTimestamp(self.0.div_euclid(DAY_NANOS) * DAY_NANOS)
}
pub fn floor_to_minute(&self) -> NanoTimestamp {
const MINUTE_NANOS: i64 = 60 * 1_000_000_000;
NanoTimestamp(self.0.div_euclid(MINUTE_NANOS) * MINUTE_NANOS)
}
pub fn elapsed_seconds(&self, other: NanoTimestamp) -> f64 {
(self.0 - other.0) as f64 / 1_000_000_000.0
}
pub fn to_datetime_string(&self) -> String {
use chrono::{DateTime, Utc};
let secs = self.0 / 1_000_000_000;
let nanos_part = (self.0 % 1_000_000_000).unsigned_abs() as u32;
let dt = DateTime::<Utc>::from_timestamp(secs, nanos_part)
.unwrap_or_default();
dt.format("%Y-%m-%d %H:%M:%S").to_string()
}
pub fn is_between(self, start: NanoTimestamp, end: NanoTimestamp) -> bool {
self.0 >= start.0 && self.0 <= end.0
}
pub fn to_unix_ms(self) -> i64 {
self.0 / 1_000_000
}
pub fn to_unix_seconds(self) -> i64 {
self.0 / 1_000_000_000
}
pub fn second_of_minute(self) -> u8 {
use chrono::Timelike;
self.to_datetime().second() as u8
}
pub fn day_of_week(self) -> u8 {
const DAY_NANOS: i64 = 86_400 * 1_000_000_000;
let days = self.0.div_euclid(DAY_NANOS);
((days + 3).rem_euclid(7)) as u8
}
pub fn sub_minutes(&self, minutes: i64) -> NanoTimestamp {
NanoTimestamp(self.0 - minutes * 60_000_000_000)
}
pub fn is_weekend(self) -> bool {
let dow = self.day_of_week();
dow == 5 || dow == 6
}
pub fn start_of_week(self) -> NanoTimestamp {
const DAY_NANOS: i64 = 86_400 * 1_000_000_000;
let dow = self.day_of_week() as i64; NanoTimestamp(self.floor_to_day().0 - dow * DAY_NANOS)
}
pub fn add_days(&self, days: i64) -> NanoTimestamp {
const DAY_NANOS: i64 = 86_400 * 1_000_000_000;
NanoTimestamp(self.0 + days * DAY_NANOS)
}
pub fn minutes_between(self, other: NanoTimestamp) -> u64 {
const MINUTE_NANOS: u64 = 60 * 1_000_000_000;
(self.0 - other.0).unsigned_abs() / MINUTE_NANOS
}
pub fn seconds_between(self, other: NanoTimestamp) -> u64 {
const SECOND_NANOS: u64 = 1_000_000_000;
(self.0 - other.0).unsigned_abs() / SECOND_NANOS
}
pub fn day_of_year(self) -> u16 {
use chrono::Datelike;
self.to_datetime().ordinal() as u16
}
pub fn quarter(self) -> u8 {
use chrono::Datelike;
let month = self.to_datetime().month();
((month - 1) / 3 + 1) as u8
}
pub fn week_of_year(self) -> u32 {
use chrono::Datelike;
self.to_datetime().iso_week().week()
}
pub fn is_same_week(self, other: NanoTimestamp) -> bool {
use chrono::Datelike;
let a = self.to_datetime().iso_week();
let b = other.to_datetime().iso_week();
a.week() == b.week() && a.year() == b.year()
}
pub fn is_same_month(self, other: NanoTimestamp) -> bool {
use chrono::Datelike;
let a = self.to_datetime();
let b = other.to_datetime();
a.year() == b.year() && a.month() == b.month()
}
pub fn floor_to_week(self) -> NanoTimestamp {
use chrono::{Datelike, Duration, TimeZone};
let dt = self.to_datetime();
let days_since_monday = dt.weekday().num_days_from_monday() as i64;
let monday = dt - Duration::days(days_since_monday);
let monday_midnight = Utc
.with_ymd_and_hms(monday.year(), monday.month(), monday.day(), 0, 0, 0)
.single()
.expect("valid date");
NanoTimestamp::from_datetime(monday_midnight)
}
pub fn is_same_year(self, other: NanoTimestamp) -> bool {
use chrono::Datelike;
self.to_datetime().year() == other.to_datetime().year()
}
pub fn days_between(self, other: NanoTimestamp) -> u64 {
let diff_nanos = (self.0 - other.0).unsigned_abs();
diff_nanos / 86_400_000_000_000
}
pub fn end_of_day(self) -> NanoTimestamp {
use chrono::{Datelike, TimeZone, Timelike};
let dt = chrono::Utc.timestamp_nanos(self.0);
let eod = chrono::Utc
.with_ymd_and_hms(dt.year(), dt.month(), dt.day(), 23, 59, 59)
.single()
.map(|d| d.with_nanosecond(999_999_999).unwrap_or(d))
.unwrap_or(dt);
NanoTimestamp(eod.timestamp_nanos_opt().unwrap_or(self.0))
}
pub fn start_of_month(self) -> NanoTimestamp {
use chrono::{Datelike, TimeZone};
let dt = chrono::Utc.timestamp_nanos(self.0);
let som = chrono::Utc
.with_ymd_and_hms(dt.year(), dt.month(), 1, 0, 0, 0)
.single()
.unwrap_or(dt);
NanoTimestamp(som.timestamp_nanos_opt().unwrap_or(self.0))
}
pub fn end_of_month(self) -> NanoTimestamp {
use chrono::{Datelike, TimeZone};
let dt = chrono::Utc.timestamp_nanos(self.0);
let (next_year, next_month) = if dt.month() == 12 {
(dt.year() + 1, 1u32)
} else {
(dt.year(), dt.month() + 1)
};
let start_of_next = chrono::Utc
.with_ymd_and_hms(next_year, next_month, 1, 0, 0, 0)
.single()
.unwrap_or(dt);
let nanos = start_of_next.timestamp_nanos_opt().unwrap_or(self.0) - 1;
NanoTimestamp(nanos)
}
pub fn floor_to_second(self) -> NanoTimestamp {
const NANOS_PER_SECOND: i64 = 1_000_000_000;
NanoTimestamp((self.0 / NANOS_PER_SECOND) * NANOS_PER_SECOND)
}
pub fn is_same_hour(self, other: NanoTimestamp) -> bool {
use chrono::{Datelike, TimeZone, Timelike};
let a = chrono::Utc.timestamp_nanos(self.0);
let b = chrono::Utc.timestamp_nanos(other.0);
a.year() == b.year() && a.month() == b.month() && a.day() == b.day() && a.hour() == b.hour()
}
pub fn add_weeks(&self, weeks: i64) -> NanoTimestamp {
const NANOS_PER_WEEK: i64 = 7 * 24 * 3_600 * 1_000_000_000;
NanoTimestamp(self.0 + weeks * NANOS_PER_WEEK)
}
pub fn sub_hours(&self, hours: i64) -> NanoTimestamp {
const NANOS_PER_HOUR: i64 = 3_600 * 1_000_000_000;
NanoTimestamp(self.0 - hours * NANOS_PER_HOUR)
}
pub fn sub_weeks(&self, weeks: i64) -> NanoTimestamp {
const NANOS_PER_WEEK: i64 = 7 * 24 * 3_600 * 1_000_000_000;
NanoTimestamp(self.0 - weeks * NANOS_PER_WEEK)
}
pub fn sub_seconds(&self, secs: i64) -> NanoTimestamp {
const NANOS_PER_SECOND: i64 = 1_000_000_000;
NanoTimestamp(self.0 - secs * NANOS_PER_SECOND)
}
pub fn to_time_string(&self) -> String {
use chrono::{TimeZone, Timelike};
let dt = chrono::Utc.timestamp_nanos(self.0);
format!("{:02}:{:02}:{:02}", dt.hour(), dt.minute(), dt.second())
}
pub fn elapsed_hours(&self, other: NanoTimestamp) -> f64 {
let diff = (self.0 - other.0).unsigned_abs();
diff as f64 / (3_600.0 * 1_000_000_000.0)
}
pub fn is_today(&self, other: NanoTimestamp) -> bool {
self.is_same_day(other)
}
pub fn nanoseconds_between(self, other: NanoTimestamp) -> u64 {
(self.0 - other.0).unsigned_abs()
}
pub fn elapsed_minutes(&self, other: NanoTimestamp) -> f64 {
let diff = (self.0 - other.0).unsigned_abs();
diff as f64 / (60.0 * 1_000_000_000.0)
}
pub fn elapsed_days(&self, other: NanoTimestamp) -> f64 {
let diff = (self.0 - other.0).unsigned_abs();
diff as f64 / (86_400.0 * 1_000_000_000.0)
}
pub fn sub_nanos(&self, nanos: i64) -> NanoTimestamp {
NanoTimestamp(self.0 - nanos)
}
pub fn start_of_year(self) -> NanoTimestamp {
use chrono::{Datelike, TimeZone};
let dt = chrono::Utc.timestamp_nanos(self.0);
let start = chrono::Utc
.with_ymd_and_hms(dt.year(), 1, 1, 0, 0, 0)
.single()
.unwrap_or(dt);
NanoTimestamp(start.timestamp_nanos_opt().unwrap_or(self.0))
}
pub fn end_of_year(self) -> NanoTimestamp {
use chrono::{Datelike, TimeZone};
let dt = chrono::Utc.timestamp_nanos(self.0);
let start_next = chrono::Utc
.with_ymd_and_hms(dt.year() + 1, 1, 1, 0, 0, 0)
.single()
.unwrap_or(dt);
let nanos = start_next.timestamp_nanos_opt().unwrap_or(self.0) - 1;
NanoTimestamp(nanos)
}
pub fn add_months(&self, months: i32) -> NanoTimestamp {
use chrono::{Datelike, TimeZone};
let dt = chrono::Utc.timestamp_nanos(self.0);
let total_months = dt.month() as i32 + months;
let year = dt.year() + (total_months - 1).div_euclid(12);
let month = ((total_months - 1).rem_euclid(12) + 1) as u32;
let day = dt.day().min(days_in_month(year, month));
let new_dt = chrono::Utc
.with_ymd_and_hms(year, month, day, dt.hour(), dt.minute(), dt.second())
.single()
.unwrap_or(dt);
NanoTimestamp(new_dt.timestamp_nanos_opt().unwrap_or(self.0))
}
pub fn start_of_quarter(self) -> NanoTimestamp {
use chrono::{Datelike, TimeZone};
let dt = chrono::Utc.timestamp_nanos(self.0);
let quarter_start_month = ((dt.month() - 1) / 3) * 3 + 1;
chrono::Utc
.with_ymd_and_hms(dt.year(), quarter_start_month, 1, 0, 0, 0)
.single()
.map(|d| NanoTimestamp(d.timestamp_nanos_opt().unwrap_or(self.0)))
.unwrap_or(self)
}
pub fn end_of_quarter(self) -> NanoTimestamp {
use chrono::{Datelike, TimeZone};
let dt = chrono::Utc.timestamp_nanos(self.0);
let quarter_end_month = ((dt.month() - 1) / 3) * 3 + 3;
let last_day = days_in_month(dt.year(), quarter_end_month);
chrono::Utc
.with_ymd_and_hms(dt.year(), quarter_end_month, last_day, 23, 59, 59)
.single()
.map(|d| NanoTimestamp(d.timestamp_nanos_opt().unwrap_or(self.0) + 999_999_999))
.unwrap_or(self)
}
pub fn is_same_quarter(self, other: NanoTimestamp) -> bool {
use chrono::{Datelike, TimeZone};
let a = chrono::Utc.timestamp_nanos(self.0);
let b = chrono::Utc.timestamp_nanos(other.0);
a.year() == b.year() && ((a.month() - 1) / 3) == ((b.month() - 1) / 3)
}
}
fn days_in_month(year: i32, month: u32) -> u32 {
match month {
1 | 3 | 5 | 7 | 8 | 10 | 12 => 31,
4 | 6 | 9 | 11 => 30,
2 if year % 400 == 0 || (year % 4 == 0 && year % 100 != 0) => 29,
2 => 28,
_ => 30,
}
}
impl std::ops::Add<i64> for NanoTimestamp {
type Output = NanoTimestamp;
fn add(self, rhs: i64) -> NanoTimestamp {
NanoTimestamp(self.0 + rhs)
}
}
impl std::ops::Sub<i64> for NanoTimestamp {
type Output = NanoTimestamp;
fn sub(self, rhs: i64) -> NanoTimestamp {
NanoTimestamp(self.0 - rhs)
}
}
impl std::ops::Sub<NanoTimestamp> for NanoTimestamp {
type Output = i64;
fn sub(self, rhs: NanoTimestamp) -> i64 {
self.0 - rhs.0
}
}
impl std::fmt::Display for NanoTimestamp {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "{}", self.0)
}
}
#[cfg(test)]
mod tests {
use super::*;
use rust_decimal_macros::dec;
#[test]
fn test_symbol_new_valid_ok() {
let sym = Symbol::new("AAPL").unwrap();
assert_eq!(sym.as_str(), "AAPL");
}
#[test]
fn test_symbol_new_empty_fails() {
let result = Symbol::new("");
assert!(matches!(result, Err(FinError::InvalidSymbol(_))));
}
#[test]
fn test_symbol_new_whitespace_fails() {
let result = Symbol::new("AA PL");
assert!(matches!(result, Err(FinError::InvalidSymbol(_))));
}
#[test]
fn test_symbol_new_leading_whitespace_fails() {
let result = Symbol::new(" AAPL");
assert!(matches!(result, Err(FinError::InvalidSymbol(_))));
}
#[test]
fn test_symbol_display() {
let sym = Symbol::new("TSLA").unwrap();
assert_eq!(format!("{sym}"), "TSLA");
}
#[test]
fn test_symbol_clone_equality() {
let a = Symbol::new("BTC").unwrap();
let b = a.clone();
assert_eq!(a, b);
}
#[test]
fn test_symbol_arc_clone_is_cheap() {
let a = Symbol::new("ETH").unwrap();
let b = a.clone();
assert_eq!(a.as_str().as_ptr(), b.as_str().as_ptr());
}
#[test]
fn test_price_new_positive_ok() {
let p = Price::new(dec!(100.5)).unwrap();
assert_eq!(p.value(), dec!(100.5));
}
#[test]
fn test_price_new_zero_fails() {
let result = Price::new(dec!(0));
assert!(matches!(result, Err(FinError::InvalidPrice(_))));
}
#[test]
fn test_price_new_negative_fails() {
let result = Price::new(dec!(-1));
assert!(matches!(result, Err(FinError::InvalidPrice(_))));
}
#[test]
fn test_price_ordering() {
let p1 = Price::new(dec!(1)).unwrap();
let p2 = Price::new(dec!(2)).unwrap();
assert!(p1 < p2);
}
#[test]
fn test_price_add() {
let a = Price::new(dec!(10)).unwrap();
let b = Price::new(dec!(5)).unwrap();
assert_eq!(a + b, dec!(15));
}
#[test]
fn test_price_sub() {
let a = Price::new(dec!(10)).unwrap();
let b = Price::new(dec!(3)).unwrap();
assert_eq!(a - b, dec!(7));
}
#[test]
fn test_price_mul_quantity() {
let p = Price::new(dec!(10)).unwrap();
let q = Quantity::new(dec!(5)).unwrap();
assert_eq!(p * q, dec!(50));
}
#[test]
fn test_price_mul_decimal_valid() {
let p = Price::new(dec!(10)).unwrap();
assert_eq!((p * dec!(2)).unwrap().value(), dec!(20));
}
#[test]
fn test_price_mul_decimal_zero_returns_none() {
let p = Price::new(dec!(10)).unwrap();
assert!((p * dec!(0)).is_none());
}
#[test]
fn test_quantity_new_zero_ok() {
let q = Quantity::new(dec!(0)).unwrap();
assert_eq!(q.value(), dec!(0));
}
#[test]
fn test_quantity_new_positive_ok() {
let q = Quantity::new(dec!(5.5)).unwrap();
assert_eq!(q.value(), dec!(5.5));
}
#[test]
fn test_quantity_new_negative_fails() {
let result = Quantity::new(dec!(-0.01));
assert!(matches!(result, Err(FinError::InvalidQuantity(_))));
}
#[test]
fn test_quantity_zero_constructor() {
let q = Quantity::zero();
assert_eq!(q.value(), Decimal::ZERO);
}
#[test]
fn test_quantity_add() {
let a = Quantity::new(dec!(3)).unwrap();
let b = Quantity::new(dec!(4)).unwrap();
assert_eq!((a + b).value(), dec!(7));
}
#[test]
fn test_quantity_sub_positive() {
let a = Quantity::new(dec!(10)).unwrap();
let b = Quantity::new(dec!(3)).unwrap();
assert_eq!(a - b, dec!(7));
}
#[test]
fn test_quantity_sub_negative() {
let a = Quantity::new(dec!(3)).unwrap();
let b = Quantity::new(dec!(10)).unwrap();
assert_eq!(a - b, dec!(-7));
}
#[test]
fn test_quantity_mul_decimal() {
let q = Quantity::new(dec!(5)).unwrap();
assert_eq!(q * dec!(3), dec!(15));
}
#[test]
fn test_quantity_is_zero() {
assert!(Quantity::zero().is_zero());
assert!(!Quantity::new(dec!(1)).unwrap().is_zero());
}
#[test]
fn test_side_display_bid() {
assert_eq!(format!("{}", Side::Bid), "Bid");
}
#[test]
fn test_side_display_ask() {
assert_eq!(format!("{}", Side::Ask), "Ask");
}
#[test]
fn test_nano_timestamp_now_positive() {
let ts = NanoTimestamp::now();
assert!(ts.nanos() > 0);
}
#[test]
fn test_nano_timestamp_ordering() {
let ts1 = NanoTimestamp::new(1_000_000_000);
let ts2 = NanoTimestamp::new(2_000_000_000);
assert!(ts1 < ts2);
}
#[test]
fn test_nano_timestamp_to_datetime_epoch() {
let ts = NanoTimestamp::new(0);
let dt = ts.to_datetime();
assert_eq!(dt.timestamp(), 0);
}
#[test]
fn test_nano_timestamp_to_datetime_roundtrip() {
let ts = NanoTimestamp::new(1_700_000_000_000_000_000_i64);
let dt = ts.to_datetime();
assert_eq!(
dt.timestamp_nanos_opt().unwrap_or(0),
1_700_000_000_000_000_000_i64
);
}
#[test]
fn test_nano_timestamp_nanos_roundtrip() {
let ts = NanoTimestamp::new(42_000_000);
assert_eq!(ts.nanos(), 42_000_000);
}
#[test]
fn test_nano_timestamp_duration_since_positive() {
let a = NanoTimestamp::new(1_000);
let b = NanoTimestamp::new(600);
assert_eq!(a.duration_since(b), 400);
}
#[test]
fn test_nano_timestamp_duration_since_negative() {
let a = NanoTimestamp::new(500);
let b = NanoTimestamp::new(1_000);
assert_eq!(a.duration_since(b), -500);
}
#[test]
fn test_symbol_len() {
let sym = Symbol::new("AAPL").unwrap();
assert_eq!(sym.len(), 4);
}
#[test]
fn test_symbol_is_empty_always_false() {
let sym = Symbol::new("X").unwrap();
assert!(!sym.is_empty());
}
#[test]
fn test_symbol_try_from_string_valid() {
let sym = Symbol::try_from("AAPL".to_owned()).unwrap();
assert_eq!(sym.as_str(), "AAPL");
}
#[test]
fn test_symbol_try_from_str_valid() {
let sym = Symbol::try_from("ETH").unwrap();
assert_eq!(sym.as_str(), "ETH");
}
#[test]
fn test_symbol_try_from_empty_fails() {
assert!(Symbol::try_from("").is_err());
}
#[test]
fn test_symbol_try_from_whitespace_fails() {
assert!(Symbol::try_from("BTC USD").is_err());
}
#[test]
fn test_nano_timestamp_from_datetime_roundtrip() {
let original = NanoTimestamp::new(1_700_000_000_000_000_000_i64);
let dt = original.to_datetime();
let recovered = NanoTimestamp::from_datetime(dt);
assert_eq!(recovered.nanos(), original.nanos());
}
#[test]
fn test_nano_timestamp_from_datetime_epoch() {
use chrono::Utc;
let epoch = Utc.timestamp_opt(0, 0).single().unwrap();
let ts = NanoTimestamp::from_datetime(epoch);
assert_eq!(ts.nanos(), 0);
}
#[test]
fn test_price_to_f64() {
let p = Price::new(dec!(123.45)).unwrap();
let f = p.to_f64();
assert!((f - 123.45_f64).abs() < 1e-6);
}
#[test]
fn test_quantity_to_f64() {
let q = Quantity::new(dec!(42)).unwrap();
assert!((q.to_f64() - 42.0_f64).abs() < 1e-10);
}
#[test]
fn test_price_from_f64_valid() {
let p = Price::from_f64(42.5).unwrap();
assert!((p.to_f64() - 42.5).abs() < 1e-6);
}
#[test]
fn test_price_from_f64_zero_returns_none() {
assert!(Price::from_f64(0.0).is_none());
}
#[test]
fn test_price_from_f64_negative_returns_none() {
assert!(Price::from_f64(-1.0).is_none());
}
#[test]
fn test_quantity_from_f64_valid() {
let q = Quantity::from_f64(10.0).unwrap();
assert!((q.to_f64() - 10.0).abs() < 1e-10);
}
#[test]
fn test_quantity_from_f64_zero_valid() {
let q = Quantity::from_f64(0.0).unwrap();
assert!(q.is_zero());
}
#[test]
fn test_quantity_from_f64_negative_returns_none() {
assert!(Quantity::from_f64(-1.0).is_none());
}
#[test]
fn test_nano_timestamp_add_millis() {
let ts = NanoTimestamp::new(0);
assert_eq!(ts.add_millis(1).nanos(), 1_000_000);
}
#[test]
fn test_nano_timestamp_add_seconds() {
let ts = NanoTimestamp::new(0);
assert_eq!(ts.add_seconds(2).nanos(), 2_000_000_000);
}
#[test]
fn test_nano_timestamp_is_before_after() {
let a = NanoTimestamp::new(1_000);
let b = NanoTimestamp::new(2_000);
assert!(a.is_before(b));
assert!(b.is_after(a));
assert!(!a.is_after(b));
assert!(!b.is_before(a));
}
#[test]
fn test_nano_timestamp_from_secs_roundtrip() {
let ts = NanoTimestamp::from_secs(1_700_000_000);
assert_eq!(ts.to_secs(), 1_700_000_000);
}
#[test]
fn test_nano_timestamp_from_secs_truncates_sub_second() {
let ts = NanoTimestamp::new(1_700_000_000_999_999_999);
assert_eq!(ts.to_secs(), 1_700_000_000);
}
#[test]
fn test_symbol_ord_lexicographic() {
let a = Symbol::new("AAPL").unwrap();
let b = Symbol::new("MSFT").unwrap();
let c = Symbol::new("AAPL").unwrap();
assert!(a < b);
assert!(b > a);
assert_eq!(a.cmp(&c), std::cmp::Ordering::Equal);
}
#[test]
fn test_symbol_ord_usable_in_btreemap() {
use std::collections::BTreeMap;
let mut m: BTreeMap<Symbol, i32> = BTreeMap::new();
m.insert(Symbol::new("Z").unwrap(), 3);
m.insert(Symbol::new("A").unwrap(), 1);
m.insert(Symbol::new("M").unwrap(), 2);
let keys: Vec<_> = m.keys().map(|s| s.as_str()).collect();
assert_eq!(keys, ["A", "M", "Z"]);
}
#[test]
fn test_price_pct_change_positive() {
let p1 = Price::new(dec!(100)).unwrap();
let p2 = Price::new(dec!(110)).unwrap();
assert_eq!(p1.pct_change_to(p2), dec!(10));
}
#[test]
fn test_price_pct_change_negative() {
let p1 = Price::new(dec!(100)).unwrap();
let p2 = Price::new(dec!(90)).unwrap();
assert_eq!(p1.pct_change_to(p2), dec!(-10));
}
#[test]
fn test_price_pct_change_zero() {
let p = Price::new(dec!(100)).unwrap();
assert_eq!(p.pct_change_to(p), dec!(0));
}
#[test]
fn test_nano_timestamp_elapsed_is_non_negative_for_past() {
let past = NanoTimestamp::new(0); assert!(past.elapsed() > 0);
}
#[test]
fn test_price_checked_mul_some() {
let p = Price::new(dec!(100)).unwrap();
let q = Quantity::new(dec!(5)).unwrap();
assert_eq!(p.checked_mul(q), Some(dec!(500)));
}
#[test]
fn test_price_checked_mul_with_zero_qty() {
let p = Price::new(dec!(100)).unwrap();
let q = Quantity::zero();
assert_eq!(p.checked_mul(q), Some(dec!(0)));
}
#[test]
fn test_quantity_checked_add() {
let a = Quantity::new(dec!(10)).unwrap();
let b = Quantity::new(dec!(5)).unwrap();
assert_eq!(a.checked_add(b).map(|q| q.value()), Some(dec!(15)));
}
#[test]
fn test_nano_timestamp_min_less_than_max() {
assert!(NanoTimestamp::MIN < NanoTimestamp::MAX);
assert!(NanoTimestamp::MIN < NanoTimestamp::new(0));
assert!(NanoTimestamp::new(0) < NanoTimestamp::MAX);
}
#[test]
fn test_price_midpoint() {
let bid = Price::new(dec!(99)).unwrap();
let ask = Price::new(dec!(101)).unwrap();
assert_eq!(Price::midpoint(bid, ask), dec!(100));
}
#[test]
fn test_price_midpoint_same_price() {
let p = Price::new(dec!(100)).unwrap();
assert_eq!(Price::midpoint(p, p), dec!(100));
}
#[test]
fn test_price_mid_method() {
let bid = Price::new(dec!(100)).unwrap();
let ask = Price::new(dec!(102)).unwrap();
let mid = bid.mid(ask);
assert_eq!(mid.value(), dec!(101));
}
#[test]
fn test_price_mid_method_same_price() {
let p = Price::new(dec!(100)).unwrap();
assert_eq!(p.mid(p).value(), dec!(100));
}
#[test]
fn test_price_abs_diff_positive() {
let a = Price::new(dec!(105)).unwrap();
let b = Price::new(dec!(100)).unwrap();
assert_eq!(a.abs_diff(b), dec!(5));
assert_eq!(b.abs_diff(a), dec!(5));
}
#[test]
fn test_price_abs_diff_same() {
let p = Price::new(dec!(100)).unwrap();
assert_eq!(p.abs_diff(p), dec!(0));
}
#[test]
fn test_quantity_checked_sub_valid() {
let a = Quantity::new(dec!(10)).unwrap();
let b = Quantity::new(dec!(3)).unwrap();
assert_eq!(a.checked_sub(b).unwrap().value(), dec!(7));
}
#[test]
fn test_quantity_checked_sub_exact_zero() {
let a = Quantity::new(dec!(5)).unwrap();
let b = Quantity::new(dec!(5)).unwrap();
assert_eq!(a.checked_sub(b).unwrap().value(), dec!(0));
}
#[test]
fn test_quantity_checked_sub_negative_returns_none() {
let a = Quantity::new(dec!(3)).unwrap();
let b = Quantity::new(dec!(5)).unwrap();
assert!(a.checked_sub(b).is_none());
}
#[test]
fn test_nano_timestamp_min_returns_earlier() {
let t1 = NanoTimestamp::new(100);
let t2 = NanoTimestamp::new(200);
assert_eq!(t1.min(t2), t1);
assert_eq!(t2.min(t1), t1);
}
#[test]
fn test_nano_timestamp_max_returns_later() {
let t1 = NanoTimestamp::new(100);
let t2 = NanoTimestamp::new(200);
assert_eq!(t1.max(t2), t2);
assert_eq!(t2.max(t1), t2);
}
#[test]
fn test_nano_timestamp_min_max_same() {
let t = NanoTimestamp::new(500);
assert_eq!(t.min(t), t);
assert_eq!(t.max(t), t);
}
#[test]
fn test_side_opposite_bid() {
assert_eq!(Side::Bid.opposite(), Side::Ask);
}
#[test]
fn test_side_opposite_ask() {
assert_eq!(Side::Ask.opposite(), Side::Bid);
}
#[test]
fn test_side_opposite_involution() {
assert_eq!(Side::Bid.opposite().opposite(), Side::Bid);
}
#[test]
fn test_price_checked_add_valid() {
let a = Price::new(dec!(100)).unwrap();
let b = Price::new(dec!(50)).unwrap();
assert_eq!(a.checked_add(b).unwrap().value(), dec!(150));
}
#[test]
fn test_price_checked_add_result_validated() {
let a = Price::new(dec!(1)).unwrap();
let b = Price::new(dec!(2)).unwrap();
assert!(a.checked_add(b).is_some());
}
#[test]
fn test_price_lerp_midpoint() {
let a = Price::new(dec!(100)).unwrap();
let b = Price::new(dec!(200)).unwrap();
let mid = a.lerp(b, dec!(0.5)).unwrap();
assert_eq!(mid.value(), dec!(150));
}
#[test]
fn test_price_lerp_at_zero_returns_self() {
let a = Price::new(dec!(100)).unwrap();
let b = Price::new(dec!(200)).unwrap();
assert_eq!(a.lerp(b, Decimal::ZERO).unwrap().value(), dec!(100));
}
#[test]
fn test_price_lerp_at_one_returns_other() {
let a = Price::new(dec!(100)).unwrap();
let b = Price::new(dec!(200)).unwrap();
assert_eq!(a.lerp(b, Decimal::ONE).unwrap().value(), dec!(200));
}
#[test]
fn test_price_lerp_out_of_range_returns_none() {
let a = Price::new(dec!(100)).unwrap();
let b = Price::new(dec!(200)).unwrap();
assert!(a.lerp(b, dec!(1.5)).is_none());
assert!(a.lerp(b, dec!(-0.1)).is_none());
}
#[test]
fn test_quantity_scale_half() {
let q = Quantity::new(dec!(100)).unwrap();
let result = q.scale(dec!(0.5)).unwrap();
assert_eq!(result.value(), dec!(50));
}
#[test]
fn test_quantity_scale_zero_factor() {
let q = Quantity::new(dec!(100)).unwrap();
let result = q.scale(Decimal::ZERO).unwrap();
assert_eq!(result.value(), dec!(0));
}
#[test]
fn test_quantity_scale_negative_factor_returns_none() {
let q = Quantity::new(dec!(100)).unwrap();
assert!(q.scale(dec!(-1)).is_none());
}
#[test]
fn test_nano_timestamp_elapsed_since_positive() {
let earlier = NanoTimestamp::new(1000);
let later = NanoTimestamp::new(3000);
assert_eq!(later.elapsed_since(earlier), 2000);
}
#[test]
fn test_nano_timestamp_elapsed_since_negative() {
let earlier = NanoTimestamp::new(1000);
let later = NanoTimestamp::new(3000);
assert_eq!(earlier.elapsed_since(later), -2000);
}
#[test]
fn test_nano_timestamp_elapsed_since_same_is_zero() {
let ts = NanoTimestamp::new(5000);
assert_eq!(ts.elapsed_since(ts), 0);
}
#[test]
fn test_nano_timestamp_to_seconds_one_second() {
let ts = NanoTimestamp::new(1_000_000_000);
assert!((ts.to_seconds() - 1.0_f64).abs() < 1e-9);
}
#[test]
fn test_nano_timestamp_to_seconds_zero() {
let ts = NanoTimestamp::new(0);
assert_eq!(ts.to_seconds(), 0.0);
}
#[test]
fn test_quantity_split_even() {
let q = Quantity::new(dec!(10)).unwrap();
let parts = q.split(5);
assert_eq!(parts.len(), 5);
let total: Decimal = parts.iter().map(|p| p.value()).sum();
assert_eq!(total, dec!(10));
}
#[test]
fn test_quantity_split_remainder_goes_to_last() {
let q = Quantity::new(dec!(10)).unwrap();
let parts = q.split(3);
assert_eq!(parts.len(), 3);
let total: Decimal = parts.iter().map(|p| p.value()).sum();
assert_eq!(total, dec!(10));
}
#[test]
fn test_quantity_split_zero_n_returns_empty() {
let q = Quantity::new(dec!(10)).unwrap();
assert!(q.split(0).is_empty());
}
#[test]
fn test_quantity_split_one_returns_self() {
let q = Quantity::new(dec!(10)).unwrap();
let parts = q.split(1);
assert_eq!(parts.len(), 1);
assert_eq!(parts[0].value(), dec!(10));
}
#[test]
fn test_price_pct_move_up() {
let p = Price::new(dec!(100)).unwrap();
let result = p.pct_move(dec!(10)).unwrap();
assert_eq!(result.value(), dec!(110));
}
#[test]
fn test_price_pct_move_down() {
let p = Price::new(dec!(100)).unwrap();
let result = p.pct_move(dec!(-10)).unwrap();
assert_eq!(result.value(), dec!(90));
}
#[test]
fn test_price_pct_move_negative_to_invalid() {
let p = Price::new(dec!(100)).unwrap();
assert!(p.pct_move(dec!(-100)).is_none());
}
#[test]
fn test_quantity_proportion_of_half() {
let a = Quantity::new(dec!(5)).unwrap();
let total = Quantity::new(dec!(10)).unwrap();
assert_eq!(a.proportion_of(total), Some(dec!(0.5)));
}
#[test]
fn test_quantity_proportion_of_zero_total_returns_none() {
let a = Quantity::new(dec!(5)).unwrap();
let total = Quantity::zero();
assert!(a.proportion_of(total).is_none());
}
#[test]
fn test_nano_timestamp_duration_millis() {
let a = NanoTimestamp::new(0);
let b = NanoTimestamp::new(1_500_000_000); assert_eq!(b.duration_millis(a), 1500);
}
#[test]
fn test_nano_timestamp_duration_millis_negative() {
let a = NanoTimestamp::new(0);
let b = NanoTimestamp::new(2_000_000_000);
assert_eq!(a.duration_millis(b), -2000);
}
#[test]
fn test_nano_timestamp_minutes_since_positive() {
let a = NanoTimestamp::new(0);
let b = NanoTimestamp::new(3 * 60_000_000_000i64);
assert_eq!(b.minutes_since(a), 3);
}
#[test]
fn test_nano_timestamp_minutes_since_negative() {
let a = NanoTimestamp::new(0);
let b = NanoTimestamp::new(3 * 60_000_000_000i64);
assert_eq!(a.minutes_since(b), -3);
}
#[test]
fn test_nano_timestamp_hours_since_positive() {
let a = NanoTimestamp::new(0);
let b = NanoTimestamp::new(2 * 3_600_000_000_000i64);
assert_eq!(b.hours_since(a), 2);
}
#[test]
fn test_nano_timestamp_hours_since_same_returns_zero() {
let a = NanoTimestamp::new(1_000_000);
assert_eq!(a.hours_since(a), 0);
}
#[test]
fn test_price_is_within_pct_same_price() {
let p = Price::new(dec!(100)).unwrap();
assert!(p.is_within_pct(p, dec!(0)));
}
#[test]
fn test_price_is_within_pct_within_range() {
let p = Price::new(dec!(100)).unwrap();
let q = Price::new(dec!(101)).unwrap();
assert!(p.is_within_pct(q, dec!(2)));
}
#[test]
fn test_price_is_within_pct_outside_range() {
let p = Price::new(dec!(100)).unwrap();
let q = Price::new(dec!(110)).unwrap();
assert!(!p.is_within_pct(q, dec!(5)));
}
#[test]
fn test_price_is_within_pct_negative_pct_returns_false() {
let p = Price::new(dec!(100)).unwrap();
assert!(!p.is_within_pct(p, dec!(-1)));
}
#[test]
fn test_timestamp_is_between_inclusive() {
let ts = NanoTimestamp::new(500);
assert!(ts.is_between(NanoTimestamp::new(100), NanoTimestamp::new(900)));
assert!(ts.is_between(NanoTimestamp::new(500), NanoTimestamp::new(500))); }
#[test]
fn test_timestamp_is_between_outside() {
let ts = NanoTimestamp::new(50);
assert!(!ts.is_between(NanoTimestamp::new(100), NanoTimestamp::new(900)));
let ts2 = NanoTimestamp::new(1000);
assert!(!ts2.is_between(NanoTimestamp::new(100), NanoTimestamp::new(900)));
}
#[test]
fn test_timestamp_to_unix_ms() {
let ts = NanoTimestamp::new(1_500_000_000); assert_eq!(ts.to_unix_ms(), 1500);
}
#[test]
fn test_timestamp_to_unix_ms_truncates() {
let ts = NanoTimestamp::new(1_999_999); assert_eq!(ts.to_unix_ms(), 1);
}
#[test]
fn test_price_round_to_tick_same_as_snap() {
let p = Price::new(dec!(100.7)).unwrap();
assert_eq!(p.round_to_tick(dec!(0.5)), p.snap_to_tick(dec!(0.5)));
}
#[test]
fn test_price_round_to_tick_invalid_tick_returns_none() {
let p = Price::new(dec!(100)).unwrap();
assert!(p.round_to_tick(dec!(0)).is_none());
assert!(p.round_to_tick(dec!(-1)).is_none());
}
#[test]
fn test_nanotimestamp_day_of_week_epoch_is_thursday() {
let ts = NanoTimestamp::new(0);
assert_eq!(ts.day_of_week(), 3);
}
#[test]
fn test_nanotimestamp_day_of_week_next_day() {
let ts = NanoTimestamp::new(86_400 * 1_000_000_000);
assert_eq!(ts.day_of_week(), 4);
}
#[test]
fn test_nanotimestamp_sub_minutes_round_trip() {
let ts = NanoTimestamp::new(3_600_000_000_000); let back = ts.sub_minutes(30);
let forward = back.add_minutes(30);
assert_eq!(forward.nanos(), ts.nanos());
}
#[test]
fn test_nanotimestamp_sub_minutes_by_zero() {
let ts = NanoTimestamp::new(1_000_000_000);
assert_eq!(ts.sub_minutes(0).nanos(), ts.nanos());
}
}