1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
//! Volume anomaly (VolumeSignal) indicator.
//!
//! Detects abnormal volume activity relative to a rolling baseline.
//! Implements [`ClassificationIndicator<VolumeSignal>`] — returns `None`
//! for candles within the warmup window, `Some(VolumeSignal)` thereafter.
use std::fmt;
use quant_primitives::Candle;
use rust_decimal::Decimal;
use crate::error::IndicatorError;
use crate::indicator::ClassificationIndicator;
/// Ratio below which volume is classified as Subdued (0.5).
const SUBDUED_THRESHOLD: Decimal = Decimal::from_parts(5, 0, 0, false, 1);
/// Classification of volume activity relative to rolling baseline.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum VolumeAnomaly {
/// Volume is significantly below the rolling average (ratio < 0.5).
Subdued,
/// Volume is within the normal range.
Normal,
/// Volume is significantly above the rolling average (ratio >= elevated_threshold).
Elevated,
}
impl fmt::Display for VolumeAnomaly {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
VolumeAnomaly::Subdued => f.write_str("Subdued"),
VolumeAnomaly::Normal => f.write_str("Normal"),
VolumeAnomaly::Elevated => f.write_str("Elevated"),
}
}
}
/// Output of the VolumeSignal indicator for a single candle.
#[derive(Debug, Clone)]
pub struct VolumeSignal {
/// Classification of the volume anomaly.
pub anomaly: VolumeAnomaly,
/// Ratio of candle volume to rolling mean (candle.volume / mean).
pub ratio: Decimal,
}
/// Configuration for the VolumeSignal indicator.
pub struct VolSignalConfig {
/// Number of prior candles used to compute the rolling mean.
pub lookback: usize,
/// Volume ratio at or above which volume is classified as Elevated.
pub elevated_threshold: Decimal,
}
/// Volume anomaly indicator.
///
/// Classifies each candle's volume relative to a rolling mean of the prior
/// `lookback` candles. Returns `None` until sufficient history is available.
///
/// Implements [`ClassificationIndicator<VolumeSignal>`].
pub struct VolumeSignalIndicator {
config: VolSignalConfig,
}
impl VolumeSignalIndicator {
/// Create a new VolumeSignal indicator.
///
/// # Errors
///
/// Returns `InvalidParameter` if `lookback` is 0 or `elevated_threshold` is not positive.
pub fn new(config: VolSignalConfig) -> Result<Self, IndicatorError> {
if config.lookback == 0 {
return Err(IndicatorError::InvalidParameter {
message: "VolumeSignal lookback must be > 0".to_string(),
});
}
if config.elevated_threshold <= Decimal::ZERO {
return Err(IndicatorError::InvalidParameter {
message: "VolumeSignal elevated_threshold must be positive".to_string(),
});
}
Ok(Self { config })
}
/// Convenience wrapper — delegates to the [`ClassificationIndicator`] trait impl.
///
/// Callers that hold a concrete `VolumeSignalIndicator` can call `.compute()`
/// without importing the trait.
pub fn compute(&self, candles: &[Candle]) -> Result<Vec<Option<VolumeSignal>>, IndicatorError> {
<Self as ClassificationIndicator<VolumeSignal>>::compute(self, candles)
}
}
impl ClassificationIndicator<VolumeSignal> for VolumeSignalIndicator {
fn lookback(&self) -> usize {
self.config.lookback
}
fn compute(&self, candles: &[Candle]) -> Result<Vec<Option<VolumeSignal>>, IndicatorError> {
let mut results = Vec::with_capacity(candles.len());
for i in 0..candles.len() {
if i < self.config.lookback {
results.push(None);
continue;
}
// Rolling mean of the prior `lookback` candles (exclusive of current).
let window = &candles[(i - self.config.lookback)..i];
let sum: Decimal = window.iter().map(Candle::volume).sum();
let mean = sum / Decimal::from(self.config.lookback as u64);
let ratio = if mean.is_zero() {
Decimal::ONE
} else {
candles[i].volume() / mean
};
let anomaly = if ratio >= self.config.elevated_threshold {
VolumeAnomaly::Elevated
} else if ratio < SUBDUED_THRESHOLD {
VolumeAnomaly::Subdued
} else {
VolumeAnomaly::Normal
};
results.push(Some(VolumeSignal { anomaly, ratio }));
}
Ok(results)
}
}
#[cfg(test)]
#[path = "volume_signal_tests.rs"]
mod tests;