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    fn update(&mut self, candle: Candle) -> Option<f64> {
84        if self.pending_anchor {
85            // Drop the old window before folding in this bar.
86            self.sum_pv = 0.0;
87            self.sum_v = 0.0;
88            self.has_emitted = false;
89            self.pending_anchor = false;
90        }
91        let tp = candle.typical_price();
92        self.sum_pv += tp * candle.volume;
93        self.sum_v += candle.volume;
94        if self.sum_v == 0.0 {
95            return None;
96        }
97        self.has_emitted = true;
98        Some(self.sum_pv / self.sum_v)
99    }
100
101    fn reset(&mut self) {
102        self.sum_pv = 0.0;
103        self.sum_v = 0.0;
104        self.has_emitted = false;
105        self.pending_anchor = false;
106    }
107
108    fn warmup_period(&self) -> usize {
109        1
110    }
111
112    fn is_ready(&self) -> bool {
113        self.has_emitted
114    }
115
116    fn name(&self) -> &'static str {
117        "AnchoredVWAP"
118    }
119}
120
121#[cfg(test)]
122mod tests {
123    use super::*;
124    use crate::traits::BatchExt;
125    use approx::assert_relative_eq;
126
127    fn c(price: f64, volume: f64, ts: i64) -> Candle {
128        Candle::new(price, price, price, price, volume, ts).unwrap()
129    }
130
131    #[test]
132    fn accessors_and_metadata() {
133        let v = AnchoredVwap::new();
134        assert_eq!(v.name(), "AnchoredVWAP");
135        assert_eq!(v.warmup_period(), 1);
136        assert_eq!(v.value(), None);
137    }
138
139    #[test]
140    fn first_bar_with_zero_volume_returns_none() {
141        let mut v = AnchoredVwap::new();
142        assert_eq!(v.update(c(50.0, 0.0, 0)), None);
143        assert!(!v.is_ready());
144        // The next bar with volume still works.
145        assert_relative_eq!(v.update(c(10.0, 4.0, 1)).unwrap(), 10.0, epsilon = 1e-12);
146    }
147
148    #[test]
149    fn equal_volumes_yield_mean_typical_price() {
150        // typical_price of a flat OHLC bar equals the price.
151        let mut v = AnchoredVwap::new();
152        let out = v.batch(&[c(10.0, 1.0, 0), c(20.0, 1.0, 1), c(30.0, 1.0, 2)]);
153        assert_relative_eq!(out[2].unwrap(), 20.0, epsilon = 1e-12);
154    }
155
156    #[test]
157    fn set_anchor_clears_old_window() {
158        // Run a few bars at price 10, then re-anchor and pump in price 100.
159        // After the re-anchor the running mean must be 100, not the mix.
160        let mut v = AnchoredVwap::new();
161        v.batch(&[c(10.0, 1.0, 0), c(10.0, 1.0, 1), c(10.0, 1.0, 2)]);
162        assert_relative_eq!(v.value().unwrap(), 10.0, epsilon = 1e-12);
163        v.set_anchor();
164        let after = v.update(c(100.0, 5.0, 3)).unwrap();
165        assert_relative_eq!(after, 100.0, epsilon = 1e-12);
166    }
167
168    #[test]
169    fn set_anchor_before_first_bar_acts_as_normal_first_bar() {
170        // Calling set_anchor on an empty indicator should be a no-op effect:
171        // the first bar still anchors the window.
172        let mut v = AnchoredVwap::new();
173        v.set_anchor();
174        assert_relative_eq!(v.update(c(42.0, 2.0, 0)).unwrap(), 42.0, epsilon = 1e-12);
175    }
176
177    #[test]
178    fn weighted_average_reference() {
179        // Two bars: 10@1, 20@3 -> (10 + 60) / 4 = 17.5.
180        let mut v = AnchoredVwap::new();
181        let out = v.batch(&[c(10.0, 1.0, 0), c(20.0, 3.0, 1)]);
182        assert_relative_eq!(out[1].unwrap(), 17.5, epsilon = 1e-12);
183    }
184
185    #[test]
186    fn batch_equals_streaming() {
187        let candles: Vec<Candle> = (1..30).map(|i| c(f64::from(i), 1.0, i.into())).collect();
188        let mut a = AnchoredVwap::new();
189        let mut b = AnchoredVwap::new();
190        assert_eq!(
191            a.batch(&candles),
192            candles.iter().map(|x| b.update(*x)).collect::<Vec<_>>()
193        );
194    }
195
196    #[test]
197    fn reset_clears_state() {
198        let mut v = AnchoredVwap::new();
199        v.batch(&[c(10.0, 1.0, 0), c(20.0, 1.0, 1)]);
200        assert!(v.is_ready());
201        v.reset();
202        assert!(!v.is_ready());
203        assert_eq!(v.value(), None);
204        // After reset the first bar acts as the new anchor.
205        assert_relative_eq!(v.update(c(50.0, 1.0, 2)).unwrap(), 50.0, epsilon = 1e-12);
206    }
207}