Skip to main content

wickra_core/indicators/
candle_volume.rs

1#![allow(clippy::doc_markdown)]
2//! CandleVolume — candlestick body with a volume-scaled width.
3
4use crate::error::{Error, Result};
5use crate::indicators::sma::Sma;
6use crate::ohlcv::Candle;
7use crate::traits::Indicator;
8
9/// Output of [`CandleVolume`]: the signed candle body and its volume-relative width.
10#[derive(Debug, Clone, Copy, PartialEq)]
11pub struct CandleVolumeOutput {
12    /// Signed body `close − open` (positive = bullish candle).
13    pub body: f64,
14    /// Box width — volume relative to its `period` average (`1.0` = average).
15    pub width: f64,
16}
17
18/// CandleVolume — the candlestick analogue of [`Equivolume`](crate::Equivolume):
19/// each bar's **body** (`close − open`) paired with a **width** proportional to its
20/// volume relative to the recent average.
21///
22/// ```text
23/// body  = close − open                          (signed; + bullish, − bearish)
24/// width = volume / SMA(volume, period)          (1.0 = average volume)
25/// ```
26///
27/// Where Equivolume uses the high-low *range* for the box height, CandleVolume uses
28/// the candlestick *body*, preserving direction: a wide bullish body (long up
29/// candle on heavy volume) is strong demand, a wide bearish body strong supply, and
30/// a narrow body on heavy volume (wide but short) is churn. The signed body plus
31/// the normalised width capture both the move's direction and the participation
32/// behind it.
33///
34/// The first value lands after `period` inputs (to seed the volume average). Each
35/// `update` is O(1).
36///
37/// # Example
38///
39/// ```
40/// use wickra_core::{Candle, Indicator, CandleVolume};
41///
42/// let mut indicator = CandleVolume::new(14).unwrap();
43/// let mut last = None;
44/// for i in 0..40 {
45///     let base = 100.0 + f64::from(i);
46///     let c = Candle::new(base, base + 1.0, base - 1.0, base + 0.5, 1_000.0 + f64::from(i), 0).unwrap();
47///     last = indicator.update(c);
48/// }
49/// assert!(last.is_some());
50/// ```
51#[derive(Debug, Clone)]
52pub struct CandleVolume {
53    period: usize,
54    vol_sma: Sma,
55    last: Option<CandleVolumeOutput>,
56}
57
58impl CandleVolume {
59    /// Construct a CandleVolume with the given volume-averaging `period`.
60    ///
61    /// # Errors
62    ///
63    /// Returns [`Error::PeriodZero`] if `period == 0`.
64    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    /// Configured volume-averaging period.
81    pub const fn period(&self) -> usize {
82        self.period
83    }
84
85    /// Current value if available.
86    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}