Skip to main content

wickra_core/indicators/
equivolume.rs

1//! Equivolume — the price box height and its volume-scaled width.
2
3use crate::error::{Error, Result};
4use crate::indicators::sma::Sma;
5use crate::ohlcv::Candle;
6use crate::traits::Indicator;
7
8/// Output of [`Equivolume`]: the box's price height and its volume-relative width.
9#[derive(Debug, Clone, Copy, PartialEq)]
10pub struct EquivolumeOutput {
11    /// Box height — the bar's price range `high − low`.
12    pub height: f64,
13    /// Box width — volume relative to its `period` average (`1.0` = average).
14    pub width: f64,
15}
16
17/// Equivolume — Richard Arms' charting style rendered as numbers: each bar is a
18/// "box" whose **height** is its price range and whose **width** is its volume
19/// relative to the recent average.
20///
21/// ```text
22/// height = high − low
23/// width  = volume / SMA(volume, period)        (1.0 = average volume)
24/// ```
25///
26/// Equivolume discards time and substitutes volume for the horizontal axis: a tall
27/// narrow box is an easy move (big range on light volume), while a short wide box
28/// is churn (small range on heavy volume) that often marks support/resistance.
29/// Reporting the two dimensions lets you reconstruct that shape programmatically:
30/// the height/width relationship is Arms' "ease of movement" read. The width is
31/// normalised by the volume SMA so it self-scales across instruments.
32///
33/// The first value lands after `period` inputs (to seed the volume average). Each
34/// `update` is O(1).
35///
36/// # Example
37///
38/// ```
39/// use wickra_core::{Candle, Indicator, Equivolume};
40///
41/// let mut indicator = Equivolume::new(14).unwrap();
42/// let mut last = None;
43/// for i in 0..40 {
44///     let base = 100.0 + f64::from(i);
45///     let c = Candle::new(base, base + 2.0, base - 2.0, base, 1_000.0 + f64::from(i), 0).unwrap();
46///     last = indicator.update(c);
47/// }
48/// assert!(last.is_some());
49/// ```
50#[derive(Debug, Clone)]
51pub struct Equivolume {
52    period: usize,
53    vol_sma: Sma,
54    last: Option<EquivolumeOutput>,
55}
56
57impl Equivolume {
58    /// Construct an Equivolume with the given volume-averaging `period`.
59    ///
60    /// # Errors
61    ///
62    /// Returns [`Error::PeriodZero`] if `period == 0`.
63    pub fn new(period: usize) -> Result<Self> {
64        if period == 0 {
65            return Err(Error::PeriodZero);
66        }
67        if period > crate::error::MAX_PERIOD {
68            return Err(Error::InvalidPeriod {
69                message: crate::error::PERIOD_ABOVE_MAX,
70            });
71        }
72        Ok(Self {
73            period,
74            vol_sma: Sma::new(period)?,
75            last: None,
76        })
77    }
78
79    /// Configured volume-averaging period.
80    pub const fn period(&self) -> usize {
81        self.period
82    }
83
84    /// Current value if available.
85    pub const fn value(&self) -> Option<EquivolumeOutput> {
86        self.last
87    }
88}
89
90impl Indicator for Equivolume {
91    type Input = Candle;
92    type Output = EquivolumeOutput;
93
94    #[inline]
95    fn update(&mut self, candle: Candle) -> Option<EquivolumeOutput> {
96        let avg_vol = self.vol_sma.update(candle.volume)?;
97        let height = candle.high - candle.low;
98        let width = if avg_vol > 0.0 {
99            candle.volume / avg_vol
100        } else {
101            0.0
102        };
103        let out = EquivolumeOutput { height, width };
104        self.last = Some(out);
105        Some(out)
106    }
107
108    fn reset(&mut self) {
109        self.vol_sma.reset();
110        self.last = None;
111    }
112
113    #[inline]
114    fn warmup_period(&self) -> usize {
115        self.period
116    }
117
118    #[inline]
119    fn is_ready(&self) -> bool {
120        self.last.is_some()
121    }
122
123    #[inline]
124    fn name(&self) -> &'static str {
125        "Equivolume"
126    }
127}
128
129#[cfg(test)]
130mod tests {
131    use super::*;
132    use crate::traits::BatchExt;
133    use approx::assert_relative_eq;
134
135    fn c(high: f64, low: f64, volume: f64) -> Candle {
136        Candle::new_unchecked(low, high, low, f64::midpoint(high, low), volume, 0)
137    }
138
139    #[test]
140    fn rejects_zero_period() {
141        assert!(matches!(Equivolume::new(0), Err(Error::PeriodZero)));
142    }
143
144    #[test]
145    fn accessors_and_metadata() {
146        let e = Equivolume::new(14).unwrap();
147        assert_eq!(e.period(), 14);
148        assert_eq!(e.warmup_period(), 14);
149        assert_eq!(e.name(), "Equivolume");
150        assert!(!e.is_ready());
151        assert_eq!(e.value(), None);
152    }
153
154    #[test]
155    fn first_emission_at_warmup_period() {
156        let mut e = Equivolume::new(3).unwrap();
157        let candles: Vec<Candle> = (0..6).map(|_| c(102.0, 98.0, 1_000.0)).collect();
158        let out = e.batch(&candles);
159        for v in out.iter().take(2) {
160            assert!(v.is_none());
161        }
162        assert!(out[2].is_some());
163    }
164
165    #[test]
166    fn height_is_range() {
167        let mut e = Equivolume::new(2).unwrap();
168        let out = e
169            .batch(&[c(105.0, 100.0, 1_000.0), c(105.0, 100.0, 1_000.0)])
170            .into_iter()
171            .flatten()
172            .last()
173            .unwrap();
174        assert_relative_eq!(out.height, 5.0, epsilon = 1e-9);
175    }
176
177    #[test]
178    fn average_volume_width_is_one() {
179        let mut e = Equivolume::new(3).unwrap();
180        let out = e
181            .batch(&[c(102.0, 98.0, 1_000.0); 6])
182            .into_iter()
183            .flatten()
184            .last()
185            .unwrap();
186        assert_relative_eq!(out.width, 1.0, epsilon = 1e-9);
187    }
188
189    #[test]
190    fn heavy_bar_is_wide() {
191        let mut e = Equivolume::new(3).unwrap();
192        let candles = [
193            c(102.0, 98.0, 1_000.0),
194            c(102.0, 98.0, 1_000.0),
195            c(102.0, 98.0, 4_000.0),
196        ];
197        let out = e.batch(&candles).into_iter().flatten().last().unwrap();
198        assert!(
199            out.width > 1.0,
200            "a heavy bar should be wider than average, got {}",
201            out.width
202        );
203    }
204
205    #[test]
206    fn reset_clears_state() {
207        let mut e = Equivolume::new(3).unwrap();
208        e.batch(&[c(102.0, 98.0, 1_000.0); 6]);
209        assert!(e.is_ready());
210        e.reset();
211        assert!(!e.is_ready());
212        assert_eq!(e.value(), None);
213        assert_eq!(e.update(c(102.0, 98.0, 1_000.0)), None);
214    }
215
216    #[test]
217    fn zero_volume_gives_zero_width() {
218        let mut e = Equivolume::new(2).unwrap();
219        let out = e
220            .batch(&[c(11.0, 9.0, 0.0), c(12.0, 10.0, 0.0), c(13.0, 11.0, 0.0)])
221            .into_iter()
222            .flatten()
223            .last()
224            .unwrap();
225        assert_eq!(out.width, 0.0);
226    }
227
228    #[test]
229    fn batch_equals_streaming() {
230        let candles: Vec<Candle> = (0..80)
231            .map(|i| {
232                c(
233                    110.0 + (f64::from(i) * 0.25).sin() * 5.0,
234                    90.0,
235                    1_000.0 + f64::from(i),
236                )
237            })
238            .collect();
239        let batch = Equivolume::new(14).unwrap().batch(&candles);
240        let mut b = Equivolume::new(14).unwrap();
241        let streamed: Vec<_> = candles.iter().map(|x| b.update(*x)).collect();
242        assert_eq!(batch, streamed);
243    }
244}