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