quant_indicators/
volume_signal.rs1use std::fmt;
8
9use quant_primitives::Candle;
10use rust_decimal::Decimal;
11
12use crate::error::IndicatorError;
13use crate::indicator::ClassificationIndicator;
14
15const SUBDUED_THRESHOLD: Decimal = Decimal::from_parts(5, 0, 0, false, 1);
17
18#[derive(Debug, Clone, PartialEq, Eq)]
20pub enum VolumeAnomaly {
21 Subdued,
23 Normal,
25 Elevated,
27}
28
29impl fmt::Display for VolumeAnomaly {
30 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
31 match self {
32 VolumeAnomaly::Subdued => f.write_str("Subdued"),
33 VolumeAnomaly::Normal => f.write_str("Normal"),
34 VolumeAnomaly::Elevated => f.write_str("Elevated"),
35 }
36 }
37}
38
39#[derive(Debug, Clone)]
41pub struct VolumeSignal {
42 pub anomaly: VolumeAnomaly,
44 pub ratio: Decimal,
46}
47
48pub struct VolSignalConfig {
50 pub lookback: usize,
52 pub elevated_threshold: Decimal,
54}
55
56pub struct VolumeSignalIndicator {
63 config: VolSignalConfig,
64}
65
66impl VolumeSignalIndicator {
67 pub fn new(config: VolSignalConfig) -> Result<Self, IndicatorError> {
73 if config.lookback == 0 {
74 return Err(IndicatorError::InvalidParameter {
75 message: "VolumeSignal lookback must be > 0".to_string(),
76 });
77 }
78 if config.elevated_threshold <= Decimal::ZERO {
79 return Err(IndicatorError::InvalidParameter {
80 message: "VolumeSignal elevated_threshold must be positive".to_string(),
81 });
82 }
83 Ok(Self { config })
84 }
85
86 pub fn compute(&self, candles: &[Candle]) -> Result<Vec<Option<VolumeSignal>>, IndicatorError> {
91 <Self as ClassificationIndicator<VolumeSignal>>::compute(self, candles)
92 }
93}
94
95impl ClassificationIndicator<VolumeSignal> for VolumeSignalIndicator {
96 fn lookback(&self) -> usize {
97 self.config.lookback
98 }
99
100 fn compute(&self, candles: &[Candle]) -> Result<Vec<Option<VolumeSignal>>, IndicatorError> {
101 let mut results = Vec::with_capacity(candles.len());
102
103 for i in 0..candles.len() {
104 if i < self.config.lookback {
105 results.push(None);
106 continue;
107 }
108
109 let window = &candles[(i - self.config.lookback)..i];
111 let sum: Decimal = window.iter().map(Candle::volume).sum();
112 let mean = sum / Decimal::from(self.config.lookback as u64);
113
114 let ratio = if mean.is_zero() {
115 Decimal::ONE
116 } else {
117 candles[i].volume() / mean
118 };
119
120 let anomaly = if ratio >= self.config.elevated_threshold {
121 VolumeAnomaly::Elevated
122 } else if ratio < SUBDUED_THRESHOLD {
123 VolumeAnomaly::Subdued
124 } else {
125 VolumeAnomaly::Normal
126 };
127
128 results.push(Some(VolumeSignal { anomaly, ratio }));
129 }
130
131 Ok(results)
132 }
133}
134
135#[cfg(test)]
136#[path = "volume_signal_tests.rs"]
137mod tests;