use std::{cmp::Ordering, fmt, str::FromStr};
use chrono::{DateTime, NaiveDate, NaiveTime, Timelike, Utc};
use chrono_tz::Tz;
use polars::prelude::{Expr, col, lit};
use serde::{Deserialize, Serialize};
use strum::{AsRefStr, Display, EnumIter, IntoStaticStr};
use strum_macros::EnumString;
use crate::{
error::{ChapatyError, DataError, TransportError},
generated::chapaty::{
bq_exporter::v1::{
EconomicCategory as RpcEconomicCategory, EconomicImportance as RpcEconomicImportance,
},
data::v1::DataBroker as RpcDataBroker,
},
transport::schema::CanonicalCol,
};
#[derive(Debug, Clone, Copy, PartialEq, PartialOrd, Default, Serialize, Deserialize)]
pub struct Price(pub f64);
impl_from_primitive!(Price, f64);
impl_add_sub_mul_div_primitive!(Price, f64);
impl_neg_primitive!(Price, f64);
impl_abs_primitive!(Price, f64);
impl_min_max_primitive!(Price, f64);
#[derive(Debug, Clone, Copy, PartialEq, PartialOrd, Default, Serialize, Deserialize)]
pub struct PriceDelta(pub f64);
impl_from_primitive!(PriceDelta, f64);
impl_add_sub_mul_div_primitive!(PriceDelta, f64);
impl_neg_primitive!(PriceDelta, f64);
impl_abs_primitive!(PriceDelta, f64);
impl_min_max_primitive!(PriceDelta, f64);
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Default, Serialize, Deserialize)]
pub struct Tick(pub i64);
impl_from_primitive!(Tick, i64);
impl_add_sub_mul_div_primitive!(Tick, i64);
impl_neg_primitive!(Tick, i64);
impl_abs_primitive!(Tick, i64);
#[derive(Debug, Clone, Copy, PartialEq, PartialOrd, Default, Serialize, Deserialize)]
pub struct Quantity(pub f64);
impl_from_primitive!(Quantity, f64);
impl_add_sub_mul_div_primitive!(Quantity, f64);
impl_min_max_primitive!(Quantity, f64);
pub type Volume = Quantity;
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Default, Serialize, Deserialize)]
pub struct Count(pub i64);
impl_from_primitive!(Count, i64);
#[derive(
Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord, Default, Serialize, Deserialize,
)]
pub struct TradeId(pub i64);
impl_from_primitive!(TradeId, i64);
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize)]
pub struct TimeframeIdx(pub u32);
impl_from_primitive!(TimeframeIdx, u32);
#[derive(Debug, Clone, Copy, PartialEq, PartialOrd, Default, Serialize, Deserialize)]
pub struct EconomicValue(pub f64);
impl_from_primitive!(EconomicValue, f64);
impl_add_sub_mul_div_primitive!(EconomicValue, f64);
impl_min_max_primitive!(EconomicValue, f64);
#[derive(
Debug,
Clone,
Copy,
PartialEq,
PartialOrd,
Eq,
Hash,
Serialize,
Deserialize,
EnumString,
EnumIter,
)]
#[strum(serialize_all = "lowercase")]
pub enum CandleDirection {
Bullish,
Bearish,
Doji,
}
#[derive(
Debug,
Clone,
Copy,
PartialEq,
PartialOrd,
Eq,
Hash,
Serialize,
Deserialize,
EnumString,
EnumIter,
)]
#[strum(serialize_all = "lowercase")]
pub enum TradeSide {
Buy,
Sell,
}
#[derive(
Debug,
Clone,
Copy,
PartialEq,
PartialOrd,
Eq,
Hash,
Serialize,
Deserialize,
EnumString,
EnumIter,
)]
#[strum(serialize_all = "lowercase")]
pub enum LiquiditySide {
Bid,
Ask,
}
impl LiquiditySide {
#[must_use]
pub const fn trade_side(&self) -> TradeSide {
match self {
Self::Bid => TradeSide::Sell,
Self::Ask => TradeSide::Buy,
}
}
}
impl From<bool> for LiquiditySide {
fn from(value: bool) -> Self {
if value { Self::Bid } else { Self::Ask }
}
}
impl From<&LiquiditySide> for bool {
fn from(value: &LiquiditySide) -> Self {
match value {
LiquiditySide::Bid => true,
LiquiditySide::Ask => false,
}
}
}
impl From<LiquiditySide> for bool {
fn from(value: LiquiditySide) -> Self {
(&value).into()
}
}
#[derive(
Debug, Clone, Copy, Serialize, Deserialize, Default, PartialEq, Eq, PartialOrd, Ord, Hash,
)]
pub enum AggregatedPrice {
#[default]
Hlc3,
Hl2,
Ohlc4,
Close,
}
impl AggregatedPrice {
pub(crate) fn to_expr(self) -> Expr {
match self {
Self::Close => col(CanonicalCol::Close),
Self::Hl2 => (col(CanonicalCol::High) + col(CanonicalCol::Low)) / lit(2.0),
Self::Ohlc4 => {
(col(CanonicalCol::Open)
+ col(CanonicalCol::High)
+ col(CanonicalCol::Low)
+ col(CanonicalCol::Close))
/ lit(4.0)
}
Self::Hlc3 => {
(col(CanonicalCol::High) + col(CanonicalCol::Low) + col(CanonicalCol::Close))
/ lit(3.0)
}
}
}
}
#[derive(
Debug,
Clone,
Copy,
PartialEq,
PartialOrd,
Eq,
Hash,
Serialize,
Deserialize,
EnumString,
EnumIter,
)]
#[strum(serialize_all = "lowercase")]
pub enum ExecutionDepth {
TopOfBook,
BookSweep,
}
impl From<bool> for ExecutionDepth {
fn from(is_best_match: bool) -> Self {
if is_best_match {
Self::TopOfBook
} else {
Self::BookSweep
}
}
}
impl From<&ExecutionDepth> for bool {
fn from(trade_match_quality: &ExecutionDepth) -> Self {
match trade_match_quality {
ExecutionDepth::TopOfBook => true,
ExecutionDepth::BookSweep => false,
}
}
}
impl From<ExecutionDepth> for bool {
fn from(value: ExecutionDepth) -> Self {
(&value).into()
}
}
#[derive(
Copy,
Clone,
Debug,
EnumString,
EnumIter,
Display,
PartialEq,
Eq,
Hash,
Deserialize,
Serialize,
PartialOrd,
Ord,
)]
#[strum(serialize_all = "lowercase")]
pub enum DataBroker {
NinjaTrader,
Binance,
InvestingCom,
}
impl DataBroker {
#[must_use]
pub const fn supports_economic_calendar(&self) -> bool {
matches!(self, Self::InvestingCom)
}
}
impl From<&DataBroker> for RpcDataBroker {
fn from(broker: &DataBroker) -> Self {
match broker {
DataBroker::Binance => Self::Binance,
DataBroker::NinjaTrader => Self::NinjaTrader,
DataBroker::InvestingCom => Self::InvestingCom,
}
}
}
impl From<DataBroker> for RpcDataBroker {
fn from(broker: DataBroker) -> Self {
(&broker).into()
}
}
impl TryFrom<RpcDataBroker> for DataBroker {
type Error = ChapatyError;
fn try_from(proto: RpcDataBroker) -> Result<Self, Self::Error> {
match proto {
RpcDataBroker::Binance => Ok(Self::Binance),
RpcDataBroker::NinjaTrader => Ok(Self::NinjaTrader),
RpcDataBroker::InvestingCom => Ok(Self::InvestingCom),
RpcDataBroker::Unspecified => Err(TransportError::RpcTypeNotFound(
"Broker cannot be unspecified in this context".to_string(),
)
.into()),
}
}
}
#[derive(
Copy,
Clone,
Debug,
EnumString,
Display,
PartialEq,
Eq,
Hash,
Deserialize,
Serialize,
PartialOrd,
Ord,
)]
#[strum(serialize_all = "lowercase")]
pub enum Exchange {
Cme,
Binance,
}
impl TryFrom<DataBroker> for Exchange {
type Error = ChapatyError;
fn try_from(broker: DataBroker) -> Result<Self, Self::Error> {
match broker {
DataBroker::NinjaTrader => Ok(Self::Cme),
DataBroker::Binance => Ok(Self::Binance),
DataBroker::InvestingCom => Err(DataError::UnexpectedEnumVariant(format!(
"{broker} does not map to an exchange"
))
.into()),
}
}
}
#[derive(
Copy,
Clone,
Debug,
EnumString,
Display,
PartialEq,
Eq,
Hash,
Deserialize,
Serialize,
PartialOrd,
Ord,
)]
#[strum(serialize_all = "lowercase")]
pub enum EconomicDataSource {
InvestingCom,
}
impl TryFrom<DataBroker> for EconomicDataSource {
type Error = ChapatyError;
fn try_from(broker: DataBroker) -> Result<Self, Self::Error> {
match broker {
DataBroker::InvestingCom => Ok(Self::InvestingCom),
DataBroker::NinjaTrader | DataBroker::Binance => Err(DataError::UnexpectedEnumVariant(
format!("{broker} does not map to an economic data source"),
)
.into()),
}
}
}
#[derive(
Copy,
Clone,
Debug,
Hash,
PartialEq,
Eq,
Deserialize,
Serialize,
PartialOrd,
Ord,
EnumIter,
EnumString,
Display,
AsRefStr,
IntoStaticStr,
Default,
)]
pub enum PriceSource {
#[default]
HighLow,
OpenClose,
}
#[derive(
Copy,
Clone,
Debug,
Hash,
PartialEq,
Eq,
Deserialize,
Serialize,
PartialOrd,
Ord,
EnumIter,
EnumString,
Display,
IntoStaticStr,
)]
#[strum(serialize_all = "lowercase")]
pub enum Period {
#[strum(serialize = "{0}h")]
Hour(u8),
#[strum(serialize = "{0}m")]
Minute(u8),
#[strum(serialize = "{0}d")]
Day(u8),
#[strum(serialize = "{0}mo")]
Month(u8),
#[strum(serialize = "{0}s")]
Second(u8),
#[strum(serialize = "{0}w")]
Week(u8),
}
#[derive(
Copy,
Clone,
Debug,
Hash,
PartialEq,
Eq,
Deserialize,
Serialize,
PartialOrd,
Ord,
EnumIter,
EnumString,
Display,
IntoStaticStr,
)]
#[strum(serialize_all = "lowercase")]
pub enum MarketType {
Spot,
Future,
}
impl From<Symbol> for MarketType {
fn from(value: Symbol) -> Self {
match value {
Symbol::Future(_) => Self::Future,
Symbol::Spot(_) => Self::Spot,
}
}
}
#[derive(
Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, PartialOrd, Ord, IntoStaticStr,
)]
pub enum Symbol {
Spot(SpotPair),
Future(FutureContract),
}
impl fmt::Display for Symbol {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::Spot(s) => write!(f, "{s}"),
Self::Future(s) => write!(f, "{s}"),
}
}
}
impl FromStr for Symbol {
type Err = ChapatyError;
fn from_str(s: &str) -> Result<Self, Self::Err> {
if let Ok(spot) = SpotPair::from_str(s) {
return Ok(Self::Spot(spot));
}
if let Ok(future) = FutureContract::from_str(s) {
return Ok(Self::Future(future));
}
Err(DataError::InvalidSymbol(s.to_string()).into())
}
}
impl Symbol {
#[must_use]
pub fn market_type(&self) -> MarketType {
(*self).into()
}
}
#[derive(
Copy,
Clone,
Debug,
PartialEq,
Eq,
Hash,
Display,
EnumString,
Serialize,
Deserialize,
PartialOrd,
Ord,
IntoStaticStr,
)]
#[strum(serialize_all = "kebab-case")]
pub enum SpotPair {
BtcUsdt,
BnbUsdt,
EthUsdt,
SolUsdt,
XrpUsdt,
TrxUsdt,
AdaUsdt,
XlmUsdt,
}
#[derive(
Clone,
Copy,
Debug,
Display,
EnumString,
PartialEq,
Eq,
Hash,
Serialize,
Deserialize,
PartialOrd,
Ord,
)]
#[strum(serialize_all = "lowercase")]
pub enum ContractMonth {
#[strum(serialize = "f")]
January = 1,
#[strum(serialize = "g")]
February = 2,
#[strum(serialize = "h")]
March = 3,
#[strum(serialize = "j")]
April = 4,
#[strum(serialize = "k")]
May = 5,
#[strum(serialize = "m")]
June = 6,
#[strum(serialize = "n")]
July = 7,
#[strum(serialize = "q")]
August = 8,
#[strum(serialize = "u")]
September = 9,
#[strum(serialize = "v")]
October = 10,
#[strum(serialize = "x")]
November = 11,
#[strum(serialize = "z")]
December = 12,
}
#[derive(
Debug,
Clone,
Copy,
PartialEq,
Eq,
Hash,
Display,
EnumString,
Serialize,
Deserialize,
PartialOrd,
Ord,
)]
#[strum(serialize_all = "lowercase")]
pub enum FutureRoot {
#[strum(serialize = "6a")]
AudUsd,
#[strum(serialize = "6b")]
GbpUsd,
#[strum(serialize = "6c")]
CadUsd,
#[strum(serialize = "6e")]
EurUsd,
#[strum(serialize = "6j")]
JpyUsd,
#[strum(serialize = "6n")]
NzdUsd,
#[strum(serialize = "btc")]
Btc,
#[strum(serialize = "es")]
EminiSp500,
#[strum(serialize = "nq")]
EminiNasdaq100,
}
#[derive(
Debug,
Clone,
Copy,
PartialEq,
Eq,
Hash,
Display,
EnumString,
Serialize,
Deserialize,
PartialOrd,
Ord,
)]
#[strum(serialize_all = "lowercase")]
pub enum ContractYear {
#[strum(serialize = "0")]
Y0 = 0,
#[strum(serialize = "1")]
Y1 = 1,
#[strum(serialize = "2")]
Y2 = 2,
#[strum(serialize = "3")]
Y3 = 3,
#[strum(serialize = "4")]
Y4 = 4,
#[strum(serialize = "5")]
Y5 = 5,
#[strum(serialize = "6")]
Y6 = 6,
#[strum(serialize = "7")]
Y7 = 7,
#[strum(serialize = "8")]
Y8 = 8,
#[strum(serialize = "9")]
Y9 = 9,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, PartialOrd, Ord)]
pub struct FutureContract {
pub root: FutureRoot,
pub month: ContractMonth,
pub year: ContractYear,
}
impl fmt::Display for FutureContract {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "{}{}{}", self.root, self.month, self.year)
}
}
impl FromStr for FutureContract {
type Err = ChapatyError;
fn from_str(s: &str) -> Result<Self, Self::Err> {
let s = s.to_lowercase();
if !s.is_ascii() {
return Err(DataError::InvalidSymbol(format!(
"Future contract string is not ASCII: {s}"
))
.into());
}
if s.len() < 3 {
return Err(
DataError::InvalidSymbol(format!("Future contract string too short: {s}")).into(),
);
}
let (root, remainder) = if s.len() >= 3 && FutureRoot::from_str(&s[..2]).is_ok() {
(&s[..2], &s[2..])
} else if s.len() >= 4 && FutureRoot::from_str(&s[..3]).is_ok() {
(&s[..3], &s[3..])
} else {
return Err(DataError::InvalidSymbol(format!("Invalid future root in: {s}")).into());
};
if remainder.len() != 2 {
return Err(
DataError::InvalidSymbol(format!("Invalid future contract format: {s}")).into(),
);
}
let root = FutureRoot::from_str(root).map_err(DataError::ParseEnum)?;
let month = ContractMonth::from_str(&remainder[..1]).map_err(DataError::ParseEnum)?;
let year = ContractYear::from_str(&remainder[1..]).map_err(DataError::ParseEnum)?;
Ok(Self { root, month, year })
}
}
#[derive(
PartialEq,
Copy,
Clone,
Debug,
Display,
EnumString,
EnumIter,
Hash,
Eq,
PartialOrd,
Ord,
Serialize,
Deserialize,
)]
#[strum(serialize_all = "camelCase")]
pub enum EconomicCategory {
Employment = 1,
EconomicActivity = 2,
Inflation = 3,
Credit = 4,
CentralBanks = 5,
ConfidenceIndex = 6,
Balance = 7,
#[strum(serialize = "Bonds")]
Bonds = 8,
}
impl From<&EconomicCategory> for RpcEconomicCategory {
fn from(category: &EconomicCategory) -> Self {
match category {
EconomicCategory::Employment => Self::Employment,
EconomicCategory::EconomicActivity => Self::EconomicActivity,
EconomicCategory::Inflation => Self::Inflation,
EconomicCategory::Credit => Self::Credit,
EconomicCategory::CentralBanks => Self::CentralBanks,
EconomicCategory::ConfidenceIndex => Self::ConfidenceIndex,
EconomicCategory::Balance => Self::Balance,
EconomicCategory::Bonds => Self::Bonds,
}
}
}
impl From<EconomicCategory> for RpcEconomicCategory {
fn from(category: EconomicCategory) -> Self {
(&category).into()
}
}
impl TryFrom<RpcEconomicCategory> for EconomicCategory {
type Error = ChapatyError;
fn try_from(proto: RpcEconomicCategory) -> Result<Self, Self::Error> {
match proto {
RpcEconomicCategory::Employment => Ok(Self::Employment),
RpcEconomicCategory::EconomicActivity => Ok(Self::EconomicActivity),
RpcEconomicCategory::Inflation => Ok(Self::Inflation),
RpcEconomicCategory::Credit => Ok(Self::Credit),
RpcEconomicCategory::CentralBanks => Ok(Self::CentralBanks),
RpcEconomicCategory::ConfidenceIndex => Ok(Self::ConfidenceIndex),
RpcEconomicCategory::Balance => Ok(Self::Balance),
RpcEconomicCategory::Bonds => Ok(Self::Bonds),
RpcEconomicCategory::Unspecified => Err(TransportError::RpcTypeNotFound(
"Economic category cannot be unspecified in this context".to_string(),
)
.into()),
}
}
}
#[derive(
Debug,
Clone,
Copy,
Hash,
PartialEq,
Eq,
EnumIter,
Display,
Serialize,
Deserialize,
PartialOrd,
Ord,
)]
#[strum(serialize_all = "lowercase")]
pub enum EconomicEventImpact {
Low = 1,
Medium = 2,
High = 3,
}
impl From<&EconomicEventImpact> for RpcEconomicImportance {
fn from(value: &EconomicEventImpact) -> Self {
match value {
EconomicEventImpact::Low => Self::Low,
EconomicEventImpact::Medium => Self::Moderate,
EconomicEventImpact::High => Self::High,
}
}
}
impl From<EconomicEventImpact> for RpcEconomicImportance {
fn from(value: EconomicEventImpact) -> Self {
(&value).into()
}
}
impl TryFrom<RpcEconomicImportance> for EconomicEventImpact {
type Error = ChapatyError;
fn try_from(proto: RpcEconomicImportance) -> Result<Self, Self::Error> {
match proto {
RpcEconomicImportance::Low => Ok(Self::Low),
RpcEconomicImportance::Moderate => Ok(Self::Medium),
RpcEconomicImportance::High => Ok(Self::High),
RpcEconomicImportance::Unspecified => Err(TransportError::RpcTypeNotFound(
"Economic importance cannot be unspecified in this context".to_string(),
)
.into()),
}
}
}
#[derive(
PartialEq,
Copy,
Clone,
Debug,
Display,
EnumIter,
EnumString,
Hash,
Eq,
PartialOrd,
Ord,
Serialize,
Deserialize,
)]
#[strum(serialize_all = "UPPERCASE")]
pub enum CountryCode {
Au,
Br,
Ca,
Cn,
Ez,
Gb,
In,
Jp,
Nz,
Us,
}
pub trait Instrument {
fn tick_size(&self) -> f64;
fn tick_value_usd(&self) -> f64;
#[expect(
clippy::cast_precision_loss,
clippy::cast_possible_truncation,
reason = "raw_ticks is rounded and asserted to be finite and within i64 bounds before the cast, so the conversion is exact"
)]
fn usd_to_ticks(&self, usd: f64) -> Tick {
let raw_ticks = (usd / self.tick_value_usd()).round();
assert!(
raw_ticks.is_finite()
&& raw_ticks >= (i64::MIN as f64)
&& raw_ticks <= (i64::MAX as f64),
"USD conversion overflowed signed i64 bounds or resulted in NaN"
);
let ticks = raw_ticks as i64;
Tick(ticks)
}
fn ticks_to_usd(&self, ticks: Tick) -> f64 {
#[expect(
clippy::expect_used,
reason = "Tick spaces for valid assets realistically never exceed i32 max (~2.1B ticks); \
a panic here implies highly corrupted input data."
)]
let tick_count = i32::try_from(ticks.0).expect("tick count exceeds i32 range");
f64::from(tick_count) * self.tick_value_usd()
}
#[expect(
clippy::cast_precision_loss,
reason = "The standard i64 bounds (±9.22e18) fit completely within the safe \
lossless precision range of an f64 (~±9.0e15) for all realistic financial prices."
)]
#[expect(
clippy::cast_possible_truncation,
reason = "The upstream `.round()` call ensures the floating-point value is an exact \
mathematical integer, meaning no fractional data is truncated during the `as i64` cast."
)]
fn price_to_ticks(&self, price_dist: Price) -> Tick {
let raw_ticks = (price_dist.0 / self.tick_size()).round();
assert!(
raw_ticks.is_finite()
&& raw_ticks >= (i64::MIN as f64)
&& raw_ticks <= (i64::MAX as f64),
"Price distance conversion overflowed signed i64 bounds or resulted in NaN"
);
let ticks = raw_ticks as i64;
Tick(ticks)
}
fn ticks_to_price(&self, ticks: Tick) -> Price {
#[expect(
clippy::expect_used,
reason = "Tick movements never realistically exceed i32 max (~2.1B ticks); \
an overflow here implies critical data corruption."
)]
let tick_count = i32::try_from(ticks.0).expect("tick count exceeds i32 range");
Price(f64::from(tick_count) * self.tick_size())
}
fn usd_to_price_dist(&self, usd: f64) -> Price {
let ticks = self.usd_to_ticks(usd);
self.ticks_to_price(ticks)
}
fn normalize_price(&self, price: f64) -> f64 {
let ticks = (price / self.tick_size()).round();
ticks * self.tick_size()
}
}
impl Instrument for SpotPair {
fn tick_size(&self) -> f64 {
match self {
Self::BtcUsdt | Self::BnbUsdt | Self::EthUsdt | Self::SolUsdt => 0.01,
Self::XrpUsdt | Self::TrxUsdt | Self::AdaUsdt | Self::XlmUsdt => 0.0001,
}
}
fn tick_value_usd(&self) -> f64 {
self.tick_size()
}
}
impl Instrument for FutureRoot {
fn tick_size(&self) -> f64 {
match self {
Self::AudUsd | Self::CadUsd | Self::EurUsd | Self::NzdUsd => 0.00005,
Self::GbpUsd => 0.0001,
Self::JpyUsd => 0.000_000_5,
Self::Btc => 5.0,
Self::EminiSp500 | Self::EminiNasdaq100 => 0.25,
}
}
fn tick_value_usd(&self) -> f64 {
match self {
Self::EurUsd | Self::GbpUsd | Self::JpyUsd => 6.25,
Self::AudUsd | Self::CadUsd | Self::NzdUsd | Self::EminiNasdaq100 => 5.0,
Self::EminiSp500 => 12.50,
Self::Btc => 25.0,
}
}
}
impl Instrument for Symbol {
fn tick_size(&self) -> f64 {
match self {
Self::Spot(spot) => spot.tick_size(),
Self::Future(future) => future.root.tick_size(),
}
}
fn tick_value_usd(&self) -> f64 {
match self {
Self::Spot(spot) => spot.tick_value_usd(),
Self::Future(future) => future.root.tick_value_usd(),
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub struct SessionWindow {
pub timezone: Tz,
pub start: NaiveTime,
pub end: NaiveTime,
}
impl PartialOrd for SessionWindow {
fn partial_cmp(&self, other: &Self) -> Option<Ordering> {
Some(self.cmp(other))
}
}
impl Ord for SessionWindow {
fn cmp(&self, other: &Self) -> Ordering {
(self.start, self.end, self.timezone as usize).cmp(&(
other.start,
other.end,
other.timezone as usize,
))
}
}
#[expect(
clippy::expect_used,
reason = "Used inside a `const fn` constructor where inputs are hardcoded, static literals. \
Any out-of-bounds inputs (e.g., hour > 23) will fail to compile immediately at build time."
)]
const fn hm(h: u32, m: u32) -> NaiveTime {
NaiveTime::from_hms_opt(h, m, 0).expect("invalid hour or minute")
}
impl SessionWindow {
#[must_use]
pub const fn new(timezone: Tz, start: NaiveTime, end: NaiveTime) -> Self {
Self {
timezone,
start,
end,
}
}
#[must_use]
pub const fn us_core_session() -> Self {
Self::new(Tz::America__New_York, hm(9, 30), hm(16, 0))
}
#[must_use]
pub const fn london_core_session() -> Self {
Self::new(Tz::Europe__London, hm(8, 0), hm(16, 30))
}
#[must_use]
pub const fn us_europe_overlap() -> Self {
Self::new(Tz::Europe__London, hm(13, 0), hm(17, 0))
}
#[must_use]
pub const fn singapore_core_session() -> Self {
Self::new(Tz::Asia__Singapore, hm(9, 0), hm(17, 0))
}
#[must_use]
pub const fn sydney_core_session() -> Self {
Self::new(Tz::Australia__Sydney, hm(10, 0), hm(16, 0))
}
#[must_use]
pub const fn us_overnight() -> Self {
Self::new(Tz::America__New_York, hm(16, 0), hm(9, 30))
}
#[must_use]
pub const fn us_extended_overnight() -> Self {
Self::new(Tz::America__New_York, hm(18, 0), hm(9, 30))
}
#[must_use]
pub const fn tokyo_core_session() -> Self {
Self::new(Tz::Asia__Tokyo, hm(9, 0), hm(15, 0))
}
#[must_use]
pub const fn asia_institutional_core() -> Self {
Self::new(Tz::Asia__Singapore, hm(9, 0), hm(17, 0))
}
#[must_use]
pub const fn hong_kong_core_session() -> Self {
Self::new(Tz::Asia__Hong_Kong, hm(9, 30), hm(16, 0))
}
#[must_use]
pub const fn apac_overnight() -> Self {
Self::new(Tz::Asia__Singapore, hm(17, 0), hm(8, 0))
}
#[must_use]
pub fn pl_time_zone(&self) -> polars::datatypes::TimeZone {
polars::datatypes::TimeZone::from_chrono(&self.timezone)
}
#[must_use]
pub fn start_nanos_since_midnight(&self) -> i64 {
(i64::from(self.start.num_seconds_from_midnight()) * 1_000_000_000)
+ i64::from(self.start.nanosecond())
}
#[must_use]
pub fn end_nanos_since_midnight(&self) -> i64 {
(i64::from(self.end.num_seconds_from_midnight()) * 1_000_000_000)
+ i64::from(self.end.nanosecond())
}
#[must_use]
pub fn window_kind(&self) -> WindowKind {
match self.start.cmp(&self.end) {
Ordering::Less => WindowKind::Intraday,
Ordering::Equal | Ordering::Greater => WindowKind::Overnight,
}
}
#[must_use]
pub fn classify(&self, utc_ts: DateTime<Utc>) -> WindowPosition {
let local = utc_ts.with_timezone(&self.timezone);
let date = local.date_naive();
let now = local.time();
match self.window_kind() {
WindowKind::Intraday => {
if (self.start..self.end).contains(&now) {
WindowPosition::Within(SessionDate(date))
} else {
WindowPosition::Outside
}
}
WindowKind::Overnight => {
if now >= self.start {
WindowPosition::Within(SessionDate(date))
} else if now < self.end {
#[expect(
clippy::expect_used,
reason = "Active trading market data timestamps are bound to modern historical/real-time \
eras and cannot reasonably trigger Chrono's absolute minimum date boundary."
)]
let anchor_date = date.pred_opt().expect(
"Market data timestamp violates Chrono's minimum representable date",
);
WindowPosition::Within(SessionDate(anchor_date))
} else {
WindowPosition::Outside
}
}
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum WindowPosition {
Within(SessionDate),
Outside,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum WindowKind {
Intraday,
Overnight,
}
impl WindowKind {
#[must_use]
pub const fn is_intraday(&self) -> bool {
matches!(self, Self::Intraday)
}
#[must_use]
pub const fn is_overnight(&self) -> bool {
matches!(self, Self::Overnight)
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
pub struct SessionDate(pub NaiveDate);
#[cfg(test)]
mod tests {
#![expect(
clippy::unwrap_used,
reason = "tests assert against known-valid fixtures; unwrap surfaces failures as panics that fail the test"
)]
use chrono::TimeZone;
use chrono_tz::America::New_York;
use super::*;
fn local_to_utc(
tz: Tz,
year: i32,
month: u32,
day: u32,
hour: u32,
min: u32,
sec: u32,
) -> DateTime<Utc> {
tz.with_ymd_and_hms(year, month, day, hour, min, sec)
.unwrap()
.with_timezone(&Utc)
}
fn date(year: i32, month: u32, day: u32) -> NaiveDate {
NaiveDate::from_ymd_opt(year, month, day).unwrap()
}
#[test]
fn test_intraday_classify() {
let session = SessionWindow::us_core_session();
let test_date = date(2026, 6, 10);
let expected_session = WindowPosition::Within(SessionDate(test_date));
let before = local_to_utc(New_York, 2026, 6, 10, 9, 29, 59);
assert_eq!(session.classify(before), WindowPosition::Outside);
let start = local_to_utc(New_York, 2026, 6, 10, 9, 30, 0);
assert_eq!(session.classify(start), expected_session);
let mid = local_to_utc(New_York, 2026, 6, 10, 12, 0, 0);
assert_eq!(session.classify(mid), expected_session);
let end = local_to_utc(New_York, 2026, 6, 10, 16, 0, 0);
assert_eq!(session.classify(end), WindowPosition::Outside);
let after = local_to_utc(New_York, 2026, 6, 10, 16, 0, 1);
assert_eq!(session.classify(after), WindowPosition::Outside);
}
#[test]
fn test_overnight_classify() {
let session = SessionWindow::us_extended_overnight();
let anchor = date(2026, 6, 10); let expected_session = WindowPosition::Within(SessionDate(anchor));
let before_eve = local_to_utc(New_York, 2026, 6, 10, 17, 59, 59);
assert_eq!(session.classify(before_eve), WindowPosition::Outside);
let eve_start = local_to_utc(New_York, 2026, 6, 10, 18, 0, 0);
assert_eq!(session.classify(eve_start), expected_session);
let eve_mid = local_to_utc(New_York, 2026, 6, 10, 23, 0, 0);
assert_eq!(session.classify(eve_mid), expected_session);
let morn_start = local_to_utc(New_York, 2026, 6, 11, 0, 0, 0);
assert_eq!(session.classify(morn_start), expected_session);
let morn_mid = local_to_utc(New_York, 2026, 6, 11, 8, 0, 0);
assert_eq!(session.classify(morn_mid), expected_session);
let morn_end = local_to_utc(New_York, 2026, 6, 11, 9, 30, 0);
assert_eq!(session.classify(morn_end), WindowPosition::Outside);
let day_mid = local_to_utc(New_York, 2026, 6, 11, 12, 0, 0);
assert_eq!(session.classify(day_mid), WindowPosition::Outside);
}
#[test]
fn test_24_hour_classify() {
let session = SessionWindow::new(New_York, hm(17, 0), hm(17, 0));
let anchor = date(2026, 6, 10);
let expected_session = WindowPosition::Within(SessionDate(anchor));
let start = local_to_utc(New_York, 2026, 6, 10, 17, 0, 0);
assert_eq!(session.classify(start), expected_session);
let eve = local_to_utc(New_York, 2026, 6, 10, 23, 59, 0);
assert_eq!(session.classify(eve), expected_session);
let morn = local_to_utc(New_York, 2026, 6, 11, 8, 0, 0);
assert_eq!(session.classify(morn), expected_session);
let before_wrap = local_to_utc(New_York, 2026, 6, 11, 16, 59, 59);
assert_eq!(session.classify(before_wrap), expected_session);
let next_anchor = date(2026, 6, 11);
let next_expected_session = WindowPosition::Within(SessionDate(next_anchor));
let restart = local_to_utc(New_York, 2026, 6, 11, 17, 0, 0);
assert_eq!(session.classify(restart), next_expected_session);
}
#[test]
fn future_contracts_format_as_root_month_year() {
let cases = [
(
FutureRoot::EurUsd,
ContractMonth::December,
ContractYear::Y5,
"6ez5",
),
(
FutureRoot::GbpUsd,
ContractMonth::March,
ContractYear::Y4,
"6bh4",
),
(
FutureRoot::AudUsd,
ContractMonth::June,
ContractYear::Y3,
"6am3",
),
(
FutureRoot::CadUsd,
ContractMonth::September,
ContractYear::Y2,
"6cu2",
),
(
FutureRoot::JpyUsd,
ContractMonth::January,
ContractYear::Y1,
"6jf1",
),
(
FutureRoot::NzdUsd,
ContractMonth::July,
ContractYear::Y0,
"6nn0",
),
(
FutureRoot::Btc,
ContractMonth::November,
ContractYear::Y9,
"btcx9",
),
];
for (root, month, year, expected) in cases {
let contract = FutureContract { root, month, year };
let symbol = Symbol::Future(contract);
assert_eq!(symbol.to_string(), expected, "Failed for {contract:?}");
}
}
#[test]
fn parses_future_contracts_case_insensitive() {
let cases = [
(
"6ez5",
FutureRoot::EurUsd,
ContractMonth::December,
ContractYear::Y5,
),
(
"6EZ5",
FutureRoot::EurUsd,
ContractMonth::December,
ContractYear::Y5,
),
(
"6bh4",
FutureRoot::GbpUsd,
ContractMonth::March,
ContractYear::Y4,
),
(
"btcx9",
FutureRoot::Btc,
ContractMonth::November,
ContractYear::Y9,
),
(
"BTCX9",
FutureRoot::Btc,
ContractMonth::November,
ContractYear::Y9,
),
];
for (input, root, month, year) in cases {
let parsed: Symbol = input
.parse()
.unwrap_or_else(|_| panic!("Failed to parse '{input}'"));
let expected = Symbol::Future(FutureContract { root, month, year });
assert_eq!(parsed, expected, "Mismatch for '{input}'");
}
}
#[test]
fn parses_all_future_roots() {
let cases = [
("6az5", FutureRoot::AudUsd),
("6bz5", FutureRoot::GbpUsd),
("6cz5", FutureRoot::CadUsd),
("6ez5", FutureRoot::EurUsd),
("6jz5", FutureRoot::JpyUsd),
("6nz5", FutureRoot::NzdUsd),
("btcz5", FutureRoot::Btc),
];
for (input, expected_root) in cases {
let parsed: Symbol = input
.parse()
.unwrap_or_else(|_| panic!("Failed to parse '{input}'"));
match parsed {
Symbol::Future(contract) => assert_eq!(contract.root, expected_root),
Symbol::Spot(_) => panic!("Expected Future variant for '{input}'"),
}
}
}
#[test]
fn rejects_invalid_symbols() {
let invalid = [
"", "invalid", "btc", "6e", "6ez", "btc-", "-usdt", "btcusdt", "6ez55", "xxz5", ];
for input in invalid {
let result: Result<Symbol, _> = input.parse();
assert!(result.is_err(), "Expected '{input}' to fail parsing");
}
}
#[test]
fn future_contracts_survive_round_trip() {
let contracts = [
FutureContract {
root: FutureRoot::EurUsd,
month: ContractMonth::December,
year: ContractYear::Y5,
},
FutureContract {
root: FutureRoot::Btc,
month: ContractMonth::March,
year: ContractYear::Y0,
},
FutureContract {
root: FutureRoot::JpyUsd,
month: ContractMonth::September,
year: ContractYear::Y9,
},
];
for contract in contracts {
let original = Symbol::Future(contract);
let serialized = original.to_string();
let deserialized: Symbol = serialized.parse().unwrap();
assert_eq!(original, deserialized, "Round-trip failed for {contract:?}");
}
}
#[test]
fn canonical_strings_parse_back_unchanged() {
let canonical = ["btc-usdt", "eth-usdt", "6ez5", "6bh4", "btcx9"];
for input in canonical {
let parsed: Symbol = input.parse().unwrap();
let output = parsed.to_string();
assert_eq!(input, output, "Canonical form changed for '{input}'");
}
}
#[test]
fn importance_numeration() {
assert_eq!(EconomicEventImpact::Low as u8, 1, "Low should be 1");
assert_eq!(EconomicEventImpact::Medium as u8, 2, "Medium should be 2");
assert_eq!(EconomicEventImpact::High as u8, 3, "High should be 3");
}
fn future_sym(root: FutureRoot) -> Symbol {
Symbol::Future(FutureContract {
root,
month: ContractMonth::December, year: ContractYear::Y5, })
}
#[test]
fn test_quant_math_eur_usd() {
let eur = future_sym(FutureRoot::EurUsd);
assert_f64_eq!(eur.tick_size(), 0.00005);
assert_f64_eq!(eur.tick_value_usd(), 6.25);
let risk_dist = eur.usd_to_price_dist(100.0);
assert!((risk_dist.0 - 0.0008).abs() < f64::EPSILON);
let norm = eur.normalize_price(1.00003);
assert!((norm - 1.00005).abs() < f64::EPSILON);
}
#[test]
fn test_quant_math_btc_future() {
let btc = future_sym(FutureRoot::Btc);
let entry = 50_000.0;
let exit = 50_100.0;
let ticks = btc.price_to_ticks(Price(exit - entry));
let pnl = btc.ticks_to_usd(ticks);
assert_eq!(ticks.0, 20);
assert_f64_eq!(pnl, 500.0);
}
#[test]
fn handling_floating_point_artifacts() {
let eur = future_sym(FutureRoot::EurUsd);
let valid_price = 1.10000;
let dirty_high = valid_price + 0.000_000_01;
let norm_high = eur.normalize_price(dirty_high);
assert!(
(norm_high - valid_price).abs() < f64::EPSILON,
"Failed to round down dirty high input: {dirty_high:.8} -> {norm_high:.8}"
);
let dirty_low = valid_price - 0.000_000_01;
let norm_low = eur.normalize_price(dirty_low);
assert!(
(norm_low - valid_price).abs() < f64::EPSILON,
"Failed to round up dirty low input: {dirty_low:.8} -> {norm_low:.8}"
);
}
#[test]
fn tick_conversion_ignores_noise() {
let eur = future_sym(FutureRoot::EurUsd);
let clean_dist = 0.00050;
let expected_ticks = 10;
let noisy_dist = Price(clean_dist + 0.000_000_01);
let ticks = eur.price_to_ticks(noisy_dist);
assert_eq!(
ticks.0, expected_ticks,
"Positive noise caused tick mismatch"
);
let noisy_dist_neg = Price(clean_dist - 0.000_000_01);
let ticks_neg = eur.price_to_ticks(noisy_dist_neg);
assert_eq!(
ticks_neg.0, expected_ticks,
"Negative noise caused tick mismatch"
);
}
}