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