Skip to main content

wickra_core/indicators/
volume_weighted_sr.rs

1//! Volume-Weighted Support/Resistance — a volume-weighted high/low band.
2
3use std::collections::VecDeque;
4
5use crate::error::{Error, Result};
6use crate::indicators::rolling_moments::RollingSum;
7use crate::ohlcv::Candle;
8use crate::traits::Indicator;
9
10/// Output of [`VolumeWeightedSr`]: the volume-weighted support and resistance
11/// levels over the lookback.
12#[derive(Debug, Clone, Copy, PartialEq)]
13pub struct VolumeWeightedSrOutput {
14    /// Volume-weighted average low — the support level.
15    pub support: f64,
16    /// Volume-weighted average high — the resistance level.
17    pub resistance: f64,
18}
19
20/// Volume-Weighted Support/Resistance — a band whose edges are the
21/// **volume-weighted** average of the recent highs (resistance) and lows
22/// (support), so the levels gravitate toward the prices where trading actually
23/// happened.
24///
25/// ```text
26/// support    = Σ(low_i  · volume_i) / Σ volume_i      over the window
27/// resistance = Σ(high_i · volume_i) / Σ volume_i      over the window
28/// ```
29///
30/// Plain high/low channels (e.g. [`Donchian`](crate::Donchian)) weight every bar
31/// equally, so a thin spike sets the boundary. Volume-weighting pulls the support
32/// and resistance toward the highs and lows that carried real volume — the prices
33/// the market agreed mattered — giving levels that tend to hold better. The
34/// distance between the two is a volume-aware range estimate. If the window's
35/// volume is all zero the band falls back to the equal-weighted average high and
36/// low.
37///
38/// The first value lands after `period` inputs; each `update` is O(1).
39///
40/// # Example
41///
42/// ```
43/// use wickra_core::{Candle, Indicator, VolumeWeightedSr};
44///
45/// let mut indicator = VolumeWeightedSr::new(20).unwrap();
46/// let mut last = None;
47/// for i in 0..40 {
48///     let base = 100.0 + (f64::from(i) * 0.3).sin() * 5.0;
49///     let c = Candle::new(base, base + 2.0, base - 2.0, base, 1_000.0 + f64::from(i), 0).unwrap();
50///     last = indicator.update(c);
51/// }
52/// assert!(last.is_some());
53/// ```
54#[derive(Debug, Clone)]
55pub struct VolumeWeightedSr {
56    period: usize,
57    highs: VecDeque<f64>,
58    lows: VecDeque<f64>,
59    volumes: VecDeque<f64>,
60    sum_hv: RollingSum,
61    sum_lv: RollingSum,
62    sum_v: RollingSum,
63    sum_h: RollingSum,
64    sum_l: RollingSum,
65    last: Option<VolumeWeightedSrOutput>,
66}
67
68impl VolumeWeightedSr {
69    /// Construct a volume-weighted S/R band over `period` bars.
70    ///
71    /// # Errors
72    ///
73    /// Returns [`Error::PeriodZero`] if `period == 0`.
74    pub fn new(period: usize) -> Result<Self> {
75        if period == 0 {
76            return Err(Error::PeriodZero);
77        }
78        if period > crate::error::MAX_PERIOD {
79            return Err(Error::InvalidPeriod {
80                message: crate::error::PERIOD_ABOVE_MAX,
81            });
82        }
83        Ok(Self {
84            period,
85            highs: VecDeque::with_capacity(period),
86            lows: VecDeque::with_capacity(period),
87            volumes: VecDeque::with_capacity(period),
88            sum_hv: RollingSum::new(),
89            sum_lv: RollingSum::new(),
90            sum_v: RollingSum::new(),
91            sum_h: RollingSum::new(),
92            sum_l: RollingSum::new(),
93            last: None,
94        })
95    }
96
97    /// Configured lookback period.
98    pub const fn period(&self) -> usize {
99        self.period
100    }
101
102    /// Current value if available.
103    pub const fn value(&self) -> Option<VolumeWeightedSrOutput> {
104        self.last
105    }
106}
107
108impl Indicator for VolumeWeightedSr {
109    type Input = Candle;
110    type Output = VolumeWeightedSrOutput;
111
112    #[inline]
113    fn update(&mut self, candle: Candle) -> Option<VolumeWeightedSrOutput> {
114        if self.highs.len() == self.period {
115            let h = self.highs.pop_front().expect("non-empty");
116            let l = self.lows.pop_front().expect("non-empty");
117            let v = self.volumes.pop_front().expect("non-empty");
118            self.sum_hv.evict(h * v);
119            self.sum_lv.evict(l * v);
120            self.sum_v.evict(v);
121            self.sum_h.evict(h);
122            self.sum_l.evict(l);
123        }
124        self.highs.push_back(candle.high);
125        self.lows.push_back(candle.low);
126        self.volumes.push_back(candle.volume);
127        self.sum_hv.push(candle.high * candle.volume);
128        self.sum_lv.push(candle.low * candle.volume);
129        self.sum_v.push(candle.volume);
130        self.sum_h.push(candle.high);
131        self.sum_l.push(candle.low);
132        if self.sum_v.needs_reseed(self.period) {
133            let volumes = &self.volumes;
134            self.sum_hv
135                .reseed(self.highs.iter().zip(volumes).map(|(h, v)| h * v));
136            self.sum_lv
137                .reseed(self.lows.iter().zip(volumes).map(|(l, v)| l * v));
138            self.sum_v.reseed(volumes.iter().copied());
139            self.sum_h.reseed(self.highs.iter().copied());
140            self.sum_l.reseed(self.lows.iter().copied());
141        }
142        if self.highs.len() < self.period {
143            return None;
144        }
145        let n = self.period as f64;
146        let total_volume = self.sum_v.value();
147        let (support, resistance) = if total_volume > 0.0 {
148            (
149                self.sum_lv.value() / total_volume,
150                self.sum_hv.value() / total_volume,
151            )
152        } else {
153            (self.sum_l.value() / n, self.sum_h.value() / n)
154        };
155        let out = VolumeWeightedSrOutput {
156            support,
157            resistance,
158        };
159        self.last = Some(out);
160        Some(out)
161    }
162
163    fn reset(&mut self) {
164        self.highs.clear();
165        self.lows.clear();
166        self.volumes.clear();
167        self.sum_hv.reset();
168        self.sum_lv.reset();
169        self.sum_v.reset();
170        self.sum_h.reset();
171        self.sum_l.reset();
172        self.last = None;
173    }
174
175    #[inline]
176    fn warmup_period(&self) -> usize {
177        self.period
178    }
179
180    #[inline]
181    fn is_ready(&self) -> bool {
182        self.last.is_some()
183    }
184
185    #[inline]
186    fn name(&self) -> &'static str {
187        "VolumeWeightedSr"
188    }
189}
190
191#[cfg(test)]
192mod tests {
193    use super::*;
194    use crate::traits::BatchExt;
195    use approx::assert_relative_eq;
196
197    fn c(high: f64, low: f64, volume: f64) -> Candle {
198        Candle::new_unchecked(low, high, low, f64::midpoint(high, low), volume, 0)
199    }
200
201    #[test]
202    fn rejects_zero_period() {
203        assert!(matches!(VolumeWeightedSr::new(0), Err(Error::PeriodZero)));
204    }
205
206    #[test]
207    fn accessors_and_metadata() {
208        let v = VolumeWeightedSr::new(20).unwrap();
209        assert_eq!(v.period(), 20);
210        assert_eq!(v.warmup_period(), 20);
211        assert_eq!(v.name(), "VolumeWeightedSr");
212        assert!(!v.is_ready());
213        assert_eq!(v.value(), None);
214    }
215
216    #[test]
217    fn first_emission_at_warmup_period() {
218        let mut v = VolumeWeightedSr::new(4).unwrap();
219        let candles: Vec<Candle> = (0..6).map(|_| c(102.0, 98.0, 1_000.0)).collect();
220        let out = v.batch(&candles);
221        for o in out.iter().take(3) {
222            assert!(o.is_none());
223        }
224        assert!(out[3].is_some());
225    }
226
227    #[test]
228    fn support_below_resistance() {
229        let mut v = VolumeWeightedSr::new(10).unwrap();
230        let candles: Vec<Candle> = (0..30)
231            .map(|i| {
232                c(
233                    110.0 + (f64::from(i) * 0.3).sin() * 5.0,
234                    90.0 + (f64::from(i) * 0.3).cos() * 5.0,
235                    1_000.0 + f64::from(i),
236                )
237            })
238            .collect();
239        for o in v.batch(&candles).into_iter().flatten() {
240            assert!(o.support <= o.resistance);
241        }
242    }
243
244    #[test]
245    fn weights_toward_high_volume_bars() {
246        // Three low-volume bars at [98,102] and one heavy bar at [108,112]; the
247        // resistance should be pulled toward the heavy bar's high.
248        let mut v = VolumeWeightedSr::new(4).unwrap();
249        let candles = [
250            c(102.0, 98.0, 100.0),
251            c(102.0, 98.0, 100.0),
252            c(102.0, 98.0, 100.0),
253            c(112.0, 108.0, 9_000.0),
254        ];
255        let out = v.batch(&candles).into_iter().flatten().last().unwrap();
256        // Volume-weighted resistance sits much closer to 112 than the simple mean (104.5).
257        assert!(
258            out.resistance > 108.0,
259            "resistance {} should lean to the heavy bar",
260            out.resistance
261        );
262    }
263
264    #[test]
265    fn zero_volume_falls_back_to_equal_weight() {
266        let mut v = VolumeWeightedSr::new(3).unwrap();
267        let candles = [
268            c(102.0, 98.0, 0.0),
269            c(104.0, 96.0, 0.0),
270            c(106.0, 94.0, 0.0),
271        ];
272        let out = v.batch(&candles).into_iter().flatten().last().unwrap();
273        // Equal-weight averages: high mean = 104, low mean = 96.
274        assert_relative_eq!(out.resistance, 104.0, epsilon = 1e-9);
275        assert_relative_eq!(out.support, 96.0, epsilon = 1e-9);
276    }
277
278    #[test]
279    fn reset_clears_state() {
280        let mut v = VolumeWeightedSr::new(4).unwrap();
281        v.batch(&(0..6).map(|_| c(102.0, 98.0, 1_000.0)).collect::<Vec<_>>());
282        assert!(v.is_ready());
283        v.reset();
284        assert!(!v.is_ready());
285        assert_eq!(v.value(), None);
286        assert_eq!(v.update(c(102.0, 98.0, 1_000.0)), None);
287    }
288
289    #[test]
290    fn batch_equals_streaming() {
291        let candles: Vec<Candle> = (0..120)
292            .map(|i| {
293                c(
294                    110.0 + (f64::from(i) * 0.25).sin() * 9.0,
295                    90.0 + (f64::from(i) * 0.25).cos() * 9.0,
296                    1_000.0 + f64::from(i),
297                )
298            })
299            .collect();
300        let batch = VolumeWeightedSr::new(20).unwrap().batch(&candles);
301        let mut b = VolumeWeightedSr::new(20).unwrap();
302        let streamed: Vec<_> = candles.iter().map(|x| b.update(*x)).collect();
303        assert_eq!(batch, streamed);
304    }
305}