wickra_core/indicators/
candle_volume.rs1#![allow(clippy::doc_markdown)]
2use crate::error::{Error, Result};
5use crate::indicators::sma::Sma;
6use crate::ohlcv::Candle;
7use crate::traits::Indicator;
8
9#[derive(Debug, Clone, Copy, PartialEq)]
11pub struct CandleVolumeOutput {
12 pub body: f64,
14 pub width: f64,
16}
17
18#[derive(Debug, Clone)]
52pub struct CandleVolume {
53 period: usize,
54 vol_sma: Sma,
55 last: Option<CandleVolumeOutput>,
56}
57
58impl CandleVolume {
59 pub fn new(period: usize) -> Result<Self> {
65 if period == 0 {
66 return Err(Error::PeriodZero);
67 }
68 Ok(Self {
69 period,
70 vol_sma: Sma::new(period)?,
71 last: None,
72 })
73 }
74
75 pub const fn period(&self) -> usize {
77 self.period
78 }
79
80 pub const fn value(&self) -> Option<CandleVolumeOutput> {
82 self.last
83 }
84}
85
86impl Indicator for CandleVolume {
87 type Input = Candle;
88 type Output = CandleVolumeOutput;
89
90 fn update(&mut self, candle: Candle) -> Option<CandleVolumeOutput> {
91 let avg_vol = self.vol_sma.update(candle.volume)?;
92 let body = candle.close - candle.open;
93 let width = if avg_vol > 0.0 {
94 candle.volume / avg_vol
95 } else {
96 0.0
97 };
98 let out = CandleVolumeOutput { body, width };
99 self.last = Some(out);
100 Some(out)
101 }
102
103 fn reset(&mut self) {
104 self.vol_sma.reset();
105 self.last = None;
106 }
107
108 fn warmup_period(&self) -> usize {
109 self.period
110 }
111
112 fn is_ready(&self) -> bool {
113 self.last.is_some()
114 }
115
116 fn name(&self) -> &'static str {
117 "CandleVolume"
118 }
119}
120
121#[cfg(test)]
122mod tests {
123 use super::*;
124 use crate::traits::BatchExt;
125 use approx::assert_relative_eq;
126
127 fn c(open: f64, close: f64, volume: f64) -> Candle {
128 let high = open.max(close) + 1.0;
129 let low = open.min(close) - 1.0;
130 Candle::new_unchecked(open, high, low, close, volume, 0)
131 }
132
133 #[test]
134 fn rejects_zero_period() {
135 assert!(matches!(CandleVolume::new(0), Err(Error::PeriodZero)));
136 }
137
138 #[test]
139 fn accessors_and_metadata() {
140 let cv = CandleVolume::new(14).unwrap();
141 assert_eq!(cv.period(), 14);
142 assert_eq!(cv.warmup_period(), 14);
143 assert_eq!(cv.name(), "CandleVolume");
144 assert!(!cv.is_ready());
145 assert_eq!(cv.value(), None);
146 }
147
148 #[test]
149 fn first_emission_at_warmup_period() {
150 let mut cv = CandleVolume::new(3).unwrap();
151 let candles: Vec<Candle> = (0..6).map(|_| c(100.0, 101.0, 1_000.0)).collect();
152 let out = cv.batch(&candles);
153 for v in out.iter().take(2) {
154 assert!(v.is_none());
155 }
156 assert!(out[2].is_some());
157 }
158
159 #[test]
160 fn bullish_body_positive() {
161 let mut cv = CandleVolume::new(2).unwrap();
162 let out = cv
163 .batch(&[c(100.0, 103.0, 1_000.0), c(100.0, 103.0, 1_000.0)])
164 .into_iter()
165 .flatten()
166 .last()
167 .unwrap();
168 assert_relative_eq!(out.body, 3.0, epsilon = 1e-9);
169 }
170
171 #[test]
172 fn bearish_body_negative() {
173 let mut cv = CandleVolume::new(2).unwrap();
174 let out = cv
175 .batch(&[c(103.0, 100.0, 1_000.0), c(103.0, 100.0, 1_000.0)])
176 .into_iter()
177 .flatten()
178 .last()
179 .unwrap();
180 assert_relative_eq!(out.body, -3.0, epsilon = 1e-9);
181 }
182
183 #[test]
184 fn heavy_bar_is_wide() {
185 let mut cv = CandleVolume::new(3).unwrap();
186 let candles = [
187 c(100.0, 101.0, 1_000.0),
188 c(100.0, 101.0, 1_000.0),
189 c(100.0, 101.0, 4_000.0),
190 ];
191 let out = cv.batch(&candles).into_iter().flatten().last().unwrap();
192 assert!(out.width > 1.0);
193 }
194
195 #[test]
196 fn reset_clears_state() {
197 let mut cv = CandleVolume::new(3).unwrap();
198 cv.batch(&[c(100.0, 101.0, 1_000.0); 6]);
199 assert!(cv.is_ready());
200 cv.reset();
201 assert!(!cv.is_ready());
202 assert_eq!(cv.value(), None);
203 assert_eq!(cv.update(c(100.0, 101.0, 1_000.0)), None);
204 }
205
206 #[test]
207 fn zero_volume_gives_zero_width() {
208 let mut cv = CandleVolume::new(2).unwrap();
209 let out = cv
210 .batch(&[c(10.0, 11.0, 0.0), c(11.0, 12.0, 0.0), c(12.0, 13.0, 0.0)])
211 .into_iter()
212 .flatten()
213 .last()
214 .unwrap();
215 assert_eq!(out.width, 0.0);
216 }
217
218 #[test]
219 fn batch_equals_streaming() {
220 let candles: Vec<Candle> = (0..80)
221 .map(|i| {
222 let b = 100.0 + (f64::from(i) * 0.25).sin() * 5.0;
223 c(b, b + 0.5, 1_000.0 + f64::from(i))
224 })
225 .collect();
226 let batch = CandleVolume::new(14).unwrap().batch(&candles);
227 let mut b = CandleVolume::new(14).unwrap();
228 let streamed: Vec<_> = candles.iter().map(|x| b.update(*x)).collect();
229 assert_eq!(batch, streamed);
230 }
231}