Skip to main content

wickra_core/indicators/
anchored_vwap.rs

1//! Anchored Volume-Weighted Average Price.
2
3use crate::ohlcv::Candle;
4use crate::traits::Indicator;
5
6/// Anchored VWAP — a cumulative VWAP whose accumulation begins at a
7/// user-chosen anchor bar rather than the session open.
8///
9/// ```text
10/// AVWAP_t = Σ_{i ≥ anchor} (typical_price_i · volume_i) / Σ_{i ≥ anchor} volume_i
11/// ```
12///
13/// The indicator emits `None` until the first anchored bar has been ingested.
14/// Calling [`AnchoredVwap::set_anchor`] re-anchors at the **next** bar that
15/// arrives, clearing the running sums; this is the conventional behaviour for
16/// "click to anchor" trader workflows where the anchor is set on the close of
17/// a swing point and the next bar starts the new accumulation. The cumulative
18/// total is unbounded; for finite-memory needs use [`crate::RollingVwap`].
19///
20/// Bars where the running volume is still zero (only happens if every anchored
21/// bar so far carried zero volume) return `None` to avoid a zero-division.
22///
23/// # Example
24///
25/// ```
26/// use wickra_core::{AnchoredVwap, Candle, Indicator};
27///
28/// let mut indicator = AnchoredVwap::new();
29/// let mut last = None;
30/// for i in 0..80 {
31///     let base = 100.0 + f64::from(i);
32///     let candle =
33///         Candle::new(base, base + 2.0, base - 2.0, base + 1.0, 10.0, i64::from(i)).unwrap();
34///     // Re-anchor at bar 40 (e.g. a major swing low).
35///     if i == 40 {
36///         indicator.set_anchor();
37///     }
38///     last = indicator.update(candle);
39/// }
40/// assert!(last.is_some());
41/// ```
42#[derive(Debug, Clone, Default)]
43pub struct AnchoredVwap {
44    sum_pv: f64,
45    sum_v: f64,
46    has_emitted: bool,
47    pending_anchor: bool,
48}
49
50impl AnchoredVwap {
51    /// Construct a fresh Anchored VWAP. The first bar to arrive is the anchor.
52    pub const fn new() -> Self {
53        Self {
54            sum_pv: 0.0,
55            sum_v: 0.0,
56            has_emitted: false,
57            pending_anchor: false,
58        }
59    }
60
61    /// Mark a re-anchor: the **next** [`Indicator::update`] call clears the
62    /// running sums before adding its own contribution, effectively starting a
63    /// fresh anchored window.
64    pub fn set_anchor(&mut self) {
65        self.pending_anchor = true;
66    }
67
68    /// Current anchored value if at least one bar with non-zero volume has
69    /// been observed in the current anchor window.
70    pub fn value(&self) -> Option<f64> {
71        if self.sum_v == 0.0 {
72            None
73        } else {
74            Some(self.sum_pv / self.sum_v)
75        }
76    }
77}
78
79impl Indicator for AnchoredVwap {
80    type Input = Candle;
81    type Output = f64;
82
83    #[inline]
84    fn update(&mut self, candle: Candle) -> Option<f64> {
85        if self.pending_anchor {
86            // Drop the old window before folding in this bar.
87            self.sum_pv = 0.0;
88            self.sum_v = 0.0;
89            self.has_emitted = false;
90            self.pending_anchor = false;
91        }
92        let tp = candle.typical_price();
93        self.sum_pv += tp * candle.volume;
94        self.sum_v += candle.volume;
95        if self.sum_v == 0.0 {
96            return None;
97        }
98        self.has_emitted = true;
99        Some(self.sum_pv / self.sum_v)
100    }
101
102    fn reset(&mut self) {
103        self.sum_pv = 0.0;
104        self.sum_v = 0.0;
105        self.has_emitted = false;
106        self.pending_anchor = false;
107    }
108
109    #[inline]
110    fn warmup_period(&self) -> usize {
111        1
112    }
113
114    #[inline]
115    fn is_ready(&self) -> bool {
116        self.has_emitted
117    }
118
119    #[inline]
120    fn name(&self) -> &'static str {
121        "AnchoredVWAP"
122    }
123}
124
125#[cfg(test)]
126mod tests {
127    use super::*;
128    use crate::traits::BatchExt;
129    use approx::assert_relative_eq;
130
131    fn c(price: f64, volume: f64, ts: i64) -> Candle {
132        Candle::new(price, price, price, price, volume, ts).unwrap()
133    }
134
135    #[test]
136    fn accessors_and_metadata() {
137        let v = AnchoredVwap::new();
138        assert_eq!(v.name(), "AnchoredVWAP");
139        assert_eq!(v.warmup_period(), 1);
140        assert_eq!(v.value(), None);
141    }
142
143    #[test]
144    fn first_bar_with_zero_volume_returns_none() {
145        let mut v = AnchoredVwap::new();
146        assert_eq!(v.update(c(50.0, 0.0, 0)), None);
147        assert!(!v.is_ready());
148        // The next bar with volume still works.
149        assert_relative_eq!(v.update(c(10.0, 4.0, 1)).unwrap(), 10.0, epsilon = 1e-12);
150    }
151
152    #[test]
153    fn equal_volumes_yield_mean_typical_price() {
154        // typical_price of a flat OHLC bar equals the price.
155        let mut v = AnchoredVwap::new();
156        let out = v.batch(&[c(10.0, 1.0, 0), c(20.0, 1.0, 1), c(30.0, 1.0, 2)]);
157        assert_relative_eq!(out[2].unwrap(), 20.0, epsilon = 1e-12);
158    }
159
160    #[test]
161    fn set_anchor_clears_old_window() {
162        // Run a few bars at price 10, then re-anchor and pump in price 100.
163        // After the re-anchor the running mean must be 100, not the mix.
164        let mut v = AnchoredVwap::new();
165        v.batch(&[c(10.0, 1.0, 0), c(10.0, 1.0, 1), c(10.0, 1.0, 2)]);
166        assert_relative_eq!(v.value().unwrap(), 10.0, epsilon = 1e-12);
167        v.set_anchor();
168        let after = v.update(c(100.0, 5.0, 3)).unwrap();
169        assert_relative_eq!(after, 100.0, epsilon = 1e-12);
170    }
171
172    #[test]
173    fn set_anchor_before_first_bar_acts_as_normal_first_bar() {
174        // Calling set_anchor on an empty indicator should be a no-op effect:
175        // the first bar still anchors the window.
176        let mut v = AnchoredVwap::new();
177        v.set_anchor();
178        assert_relative_eq!(v.update(c(42.0, 2.0, 0)).unwrap(), 42.0, epsilon = 1e-12);
179    }
180
181    #[test]
182    fn weighted_average_reference() {
183        // Two bars: 10@1, 20@3 -> (10 + 60) / 4 = 17.5.
184        let mut v = AnchoredVwap::new();
185        let out = v.batch(&[c(10.0, 1.0, 0), c(20.0, 3.0, 1)]);
186        assert_relative_eq!(out[1].unwrap(), 17.5, epsilon = 1e-12);
187    }
188
189    #[test]
190    fn batch_equals_streaming() {
191        let candles: Vec<Candle> = (1..30).map(|i| c(f64::from(i), 1.0, i.into())).collect();
192        let mut a = AnchoredVwap::new();
193        let mut b = AnchoredVwap::new();
194        assert_eq!(
195            a.batch(&candles),
196            candles.iter().map(|x| b.update(*x)).collect::<Vec<_>>()
197        );
198    }
199
200    #[test]
201    fn reset_clears_state() {
202        let mut v = AnchoredVwap::new();
203        v.batch(&[c(10.0, 1.0, 0), c(20.0, 1.0, 1)]);
204        assert!(v.is_ready());
205        v.reset();
206        assert!(!v.is_ready());
207        assert_eq!(v.value(), None);
208        // After reset the first bar acts as the new anchor.
209        assert_relative_eq!(v.update(c(50.0, 1.0, 2)).unwrap(), 50.0, epsilon = 1e-12);
210    }
211}