use std::fmt;
#[cfg(feature = "serde")]
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
#[cfg_attr(
feature = "serde",
derive(Serialize, Deserialize),
serde(rename_all = "lowercase")
)]
pub enum Resolution {
M1,
M5,
M15,
M30,
H1,
H4,
D1,
W1,
}
impl Resolution {
pub fn as_str(&self) -> &'static str {
match self {
Resolution::M1 => "1m",
Resolution::M5 => "5m",
Resolution::M15 => "15m",
Resolution::M30 => "30m",
Resolution::H1 => "1h",
Resolution::H4 => "4h",
Resolution::D1 => "1d",
Resolution::W1 => "1w",
}
}
}
impl fmt::Display for Resolution {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "{}", self.as_str())
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)]
#[cfg_attr(
feature = "serde",
derive(Serialize, Deserialize),
serde(rename_all = "lowercase")
)]
pub enum Source {
#[default]
Close,
Open,
High,
Low,
Hl2,
Hlc3,
Ohlc4,
Volume,
TypicalPrice,
}
impl Source {
pub fn extract(&self, bar: &Bar) -> f64 {
match self {
Source::Close => bar.close,
Source::Open => bar.open,
Source::High => bar.high,
Source::Low => bar.low,
Source::Hl2 => (bar.high + bar.low) / 2.0,
Source::Hlc3 => (bar.high + bar.low + bar.close) / 3.0,
Source::Ohlc4 => (bar.open + bar.high + bar.low + bar.close) / 4.0,
Source::Volume => bar.volume,
Source::TypicalPrice => bar.typical_price(),
}
}
}
#[derive(Debug, Clone, PartialEq)]
#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
pub struct InstrumentMeta {
pub symbol: String,
pub tick_size: f64,
pub price_precision: usize,
pub timezone: String,
}
impl Default for InstrumentMeta {
fn default() -> Self {
Self {
symbol: "GENERIC".to_string(),
tick_size: 0.01,
price_precision: 2,
timezone: "UTC".to_string(),
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum InstrumentMetaError {
NonPositiveTickSize,
NonFiniteTickSize,
ExcessivePricePrecision,
EmptySymbol,
EmptyTimezone,
}
impl fmt::Display for InstrumentMetaError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
let message = match self {
Self::NonPositiveTickSize => "tick_size must be greater than zero",
Self::NonFiniteTickSize => "tick_size must be finite",
Self::ExcessivePricePrecision => "price_precision must be <= 12",
Self::EmptySymbol => "symbol must not be empty",
Self::EmptyTimezone => "timezone must not be empty",
};
f.write_str(message)
}
}
impl std::error::Error for InstrumentMetaError {}
impl InstrumentMeta {
pub fn validate(&self) -> Result<(), InstrumentMetaError> {
if !self.tick_size.is_finite() {
return Err(InstrumentMetaError::NonFiniteTickSize);
}
if self.tick_size <= 0.0 {
return Err(InstrumentMetaError::NonPositiveTickSize);
}
if self.price_precision > 12 {
return Err(InstrumentMetaError::ExcessivePricePrecision);
}
if self.symbol.trim().is_empty() {
return Err(InstrumentMetaError::EmptySymbol);
}
if self.timezone.trim().is_empty() {
return Err(InstrumentMetaError::EmptyTimezone);
}
Ok(())
}
pub fn round_to_tick(&self, price: f64) -> f64 {
if !self.tick_size.is_finite() || self.tick_size <= 0.0 || !price.is_finite() {
return price;
}
(price / self.tick_size).round() * self.tick_size
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
pub struct BarQuality {
pub volume_available: bool,
pub is_synthetic: bool,
pub is_forward_filled: bool,
pub has_gap: bool,
}
impl BarQuality {
pub fn observed() -> Self {
Self {
volume_available: true,
is_synthetic: false,
is_forward_filled: false,
has_gap: false,
}
}
}
#[derive(Debug, Clone, PartialEq)]
#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
pub struct QualifiedBar {
pub bar: Bar,
pub quality: BarQuality,
}
impl QualifiedBar {
pub fn new(bar: Bar, quality: BarQuality) -> Self {
Self { bar, quality }
}
pub fn observed(bar: Bar) -> Self {
Self {
bar,
quality: BarQuality::observed(),
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[cfg_attr(
feature = "serde",
derive(Serialize, Deserialize),
serde(rename_all = "snake_case")
)]
pub enum VolumeKind {
None,
Tick,
RealTurnover,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[cfg_attr(
feature = "serde",
derive(Serialize, Deserialize),
serde(rename_all = "snake_case")
)]
pub enum SessionKind {
Regular,
Extended,
Continuous,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[cfg_attr(
feature = "serde",
derive(Serialize, Deserialize),
serde(rename_all = "snake_case")
)]
pub enum ContinuityKind {
Spot,
SingleContract,
StitchedUnadjusted,
StitchedBackAdjusted,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[cfg_attr(
feature = "serde",
derive(Serialize, Deserialize),
serde(rename_all = "snake_case")
)]
pub enum PriceAdjustment {
Raw,
Split,
SplitAndDividend,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[cfg_attr(
feature = "serde",
derive(Serialize, Deserialize),
serde(rename_all = "snake_case")
)]
pub enum Provenance {
Exchange,
Broker,
Synthetic,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[cfg_attr(
feature = "serde",
derive(Serialize, Deserialize),
serde(rename_all = "snake_case")
)]
pub enum LiquidityTier {
Deep,
Normal,
Thin,
Unknown,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
pub struct SeriesCapabilities {
pub volume: VolumeKind,
pub trade_direction: bool,
pub session: SessionKind,
pub continuity: ContinuityKind,
pub price_adjustment: PriceAdjustment,
pub provenance: Provenance,
pub liquidity_tier: LiquidityTier,
}
#[derive(Debug, Clone, PartialEq, Eq)]
#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
pub struct SeriesIdentity {
pub symbol: String,
pub timeframe: String,
pub session: SessionKind,
pub continuity: ContinuityKind,
pub price_adjustment: PriceAdjustment,
pub provenance: Provenance,
pub contract_code: Option<String>,
}
impl SeriesIdentity {
pub fn new(symbol: impl Into<String>, timeframe: impl Into<String>) -> Self {
Self {
symbol: symbol.into(),
timeframe: timeframe.into(),
session: SessionKind::Regular,
continuity: ContinuityKind::Spot,
price_adjustment: PriceAdjustment::Raw,
provenance: Provenance::Exchange,
contract_code: None,
}
}
pub fn with_session(mut self, session: SessionKind) -> Self {
self.session = session;
self
}
pub fn with_continuity(mut self, continuity: ContinuityKind) -> Self {
self.continuity = continuity;
self
}
pub fn with_price_adjustment(mut self, adj: PriceAdjustment) -> Self {
self.price_adjustment = adj;
self
}
pub fn with_provenance(mut self, prov: Provenance) -> Self {
self.provenance = prov;
self
}
pub fn with_contract_code(mut self, code: impl Into<String>) -> Self {
self.contract_code = Some(code.into());
self
}
pub fn is_compatible(&self, other: &Self) -> bool {
self.symbol == other.symbol
&& self.session == other.session
&& self.continuity == other.continuity
&& self.price_adjustment == other.price_adjustment
&& self.contract_code == other.contract_code
}
}
#[derive(Debug, Clone, PartialEq)]
#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
pub struct Bar {
pub timestamp: i64,
pub open: f64,
pub high: f64,
pub low: f64,
pub close: f64,
pub volume: f64,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum BarValidationError {
NonFiniteValue,
NonPositivePrice,
NegativeVolume,
InvalidPriceRange,
}
impl fmt::Display for BarValidationError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
let message = match self {
Self::NonFiniteValue => "OHLCV values must be finite",
Self::NonPositivePrice => "OHLC prices must be greater than zero",
Self::NegativeVolume => "volume must be non-negative",
Self::InvalidPriceRange => "low/high must contain open and close",
};
f.write_str(message)
}
}
impl std::error::Error for BarValidationError {}
impl Bar {
pub fn new(timestamp: i64, open: f64, high: f64, low: f64, close: f64, volume: f64) -> Self {
Self {
timestamp,
open,
high,
low,
close,
volume,
}
}
pub fn try_new(
timestamp: i64,
open: f64,
high: f64,
low: f64,
close: f64,
volume: f64,
) -> Result<Self, BarValidationError> {
let bar = Self::new(timestamp, open, high, low, close, volume);
bar.validate()?;
Ok(bar)
}
pub fn typical_price(&self) -> f64 {
(self.high + self.low + self.close) / 3.0
}
pub fn validate(&self) -> Result<(), BarValidationError> {
if !self.open.is_finite()
|| !self.high.is_finite()
|| !self.low.is_finite()
|| !self.close.is_finite()
|| !self.volume.is_finite()
{
return Err(BarValidationError::NonFiniteValue);
}
if self.open <= 0.0 || self.high <= 0.0 || self.low <= 0.0 || self.close <= 0.0 {
return Err(BarValidationError::NonPositivePrice);
}
if self.volume < 0.0 {
return Err(BarValidationError::NegativeVolume);
}
let min_oc = self.open.min(self.close);
let max_oc = self.open.max(self.close);
if self.low > min_oc || self.high < max_oc || self.low > self.high {
return Err(BarValidationError::InvalidPriceRange);
}
Ok(())
}
pub fn is_valid(&self) -> bool {
self.validate().is_ok()
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_bar_validation_contract() {
let valid = Bar::new(1000, 100.0, 105.0, 95.0, 104.0, 1000.0);
assert!(valid.is_valid());
assert_eq!(
Bar::try_new(1000, 100.0, 105.0, 95.0, 104.0, 1000.0),
Ok(valid)
);
let neg_price = Bar::new(1000, -100.0, 105.0, 95.0, 104.0, 1000.0);
assert!(!neg_price.is_valid());
let neg_vol = Bar::new(1000, 100.0, 105.0, 95.0, 104.0, -10.0);
assert!(!neg_vol.is_valid());
let bad_high = Bar::new(1000, 100.0, 90.0, 80.0, 95.0, 1000.0);
assert!(!bad_high.is_valid());
let nan_price = Bar::new(1000, f64::NAN, 105.0, 95.0, 104.0, 1000.0);
assert!(!nan_price.is_valid());
assert_eq!(
nan_price.validate(),
Err(BarValidationError::NonFiniteValue)
);
}
#[test]
fn test_instrument_meta_validate() {
assert_eq!(InstrumentMeta::default().validate(), Ok(()));
let bad_tick = InstrumentMeta {
tick_size: 0.0,
..InstrumentMeta::default()
};
assert_eq!(
bad_tick.validate(),
Err(InstrumentMetaError::NonPositiveTickSize)
);
let bad_precision = InstrumentMeta {
price_precision: 13,
..InstrumentMeta::default()
};
assert_eq!(
bad_precision.validate(),
Err(InstrumentMetaError::ExcessivePricePrecision)
);
let empty_symbol = InstrumentMeta {
symbol: "".to_string(),
..InstrumentMeta::default()
};
assert_eq!(
empty_symbol.validate(),
Err(InstrumentMetaError::EmptySymbol)
);
}
#[test]
fn test_instrument_meta_round_to_tick() {
let meta = InstrumentMeta {
tick_size: 0.25,
..InstrumentMeta::default()
};
assert_eq!(meta.round_to_tick(100.10), 100.0);
assert_eq!(meta.round_to_tick(100.13), 100.25);
assert_eq!(meta.round_to_tick(100.125), 100.25);
let invalid_tick = InstrumentMeta {
tick_size: 0.0,
..InstrumentMeta::default()
};
assert_eq!(invalid_tick.round_to_tick(100.10), 100.10);
}
#[test]
fn test_bar_quality_defaults() {
let default_quality = BarQuality::default();
assert!(!default_quality.volume_available);
assert!(!default_quality.is_synthetic);
let observed = BarQuality::observed();
assert!(observed.volume_available);
assert!(!observed.is_synthetic);
assert!(!observed.is_forward_filled);
assert!(!observed.has_gap);
let bar = Bar::new(1000, 100.0, 105.0, 95.0, 104.0, 1000.0);
let qualified = QualifiedBar::observed(bar.clone());
assert_eq!(qualified.bar, bar);
assert_eq!(qualified.quality, BarQuality::observed());
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)]
#[cfg_attr(
feature = "serde",
derive(Serialize, Deserialize),
serde(rename_all = "snake_case")
)]
pub enum MarketRegime {
BullishExpansion,
BearishExpansion,
#[default]
Consolidation,
Transition,
}
impl fmt::Display for MarketRegime {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
MarketRegime::BullishExpansion => write!(f, "Bullish Expansion"),
MarketRegime::BearishExpansion => write!(f, "Bearish Expansion"),
MarketRegime::Consolidation => write!(f, "Consolidation / Range"),
MarketRegime::Transition => write!(f, "Regime Transition"),
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[cfg_attr(
feature = "serde",
derive(Serialize, Deserialize),
serde(rename_all = "snake_case")
)]
pub enum ZoneKind {
Support,
Resistance,
}
#[derive(Debug, Clone, PartialEq)]
#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
pub struct SupportResistanceZone {
pub kind: ZoneKind,
pub price: f64,
pub price_top: f64,
pub price_bottom: f64,
pub strength: f64, pub distance_pct: f64,
pub touches: u32,
}
#[derive(Debug, Clone, PartialEq)]
#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
pub struct RiskPlan {
pub entry: f64,
pub stop_loss: f64,
pub target_1: f64,
pub target_2: f64,
pub risk_reward_ratio: f64,
}
impl RiskPlan {
pub fn rounded_to(&self, instrument: &InstrumentMeta) -> Self {
let entry = instrument.round_to_tick(self.entry);
let stop_loss = instrument.round_to_tick(self.stop_loss);
let target_1 = instrument.round_to_tick(self.target_1);
let target_2 = instrument.round_to_tick(self.target_2);
let risk = (entry - stop_loss).abs();
let reward = (target_2 - entry).abs();
let risk_reward_ratio = if risk > 0.0 {
reward / risk
} else {
self.risk_reward_ratio
};
Self {
entry,
stop_loss,
target_1,
target_2,
risk_reward_ratio,
}
}
}
#[cfg(test)]
mod risk_plan_tests {
use super::*;
#[test]
fn test_risk_plan_rounded_to_tick() {
let plan = RiskPlan {
entry: 100.13,
stop_loss: 98.77,
target_1: 101.5,
target_2: 103.02,
risk_reward_ratio: 2.11,
};
let instrument = InstrumentMeta {
tick_size: 0.25,
..InstrumentMeta::default()
};
let rounded = plan.rounded_to(&instrument);
assert_eq!(rounded.entry, 100.25);
assert_eq!(rounded.stop_loss, 98.75);
assert_eq!(rounded.target_1, 101.5);
assert_eq!(rounded.target_2, 103.0);
let expected_rrr = (103.0f64 - 100.25).abs() / (100.25f64 - 98.75).abs();
assert!((rounded.risk_reward_ratio - expected_rrr).abs() < 1e-9);
}
}