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