Skip to main content

wickra_core/indicators/
vpt.rs

1//! Volume-Price Trend.
2
3use crate::ohlcv::Candle;
4use crate::traits::Indicator;
5
6/// Volume-Price Trend — a cumulative volume line weighted by percentage price
7/// change.
8///
9/// VPT is a close relative of [`Obv`](crate::Obv), but instead of adding the
10/// full bar volume on every up-close it adds volume scaled by the *size* of
11/// the move:
12///
13/// ```text
14/// VPT_t = VPT_{t−1} + volume_t · (close_t − close_{t−1}) / close_{t−1}
15/// ```
16///
17/// A big move on heavy volume moves the line far; a small move on the same
18/// volume barely nudges it. The running total is unbounded — its slope and its
19/// divergence from price are what carry the signal. The first bar establishes
20/// the baseline at `0`.
21///
22/// # Example
23///
24/// ```
25/// use wickra_core::{Candle, Indicator, VolumePriceTrend};
26///
27/// let mut indicator = VolumePriceTrend::new();
28/// let mut last = None;
29/// for i in 0..80 {
30///     let base = 100.0 + f64::from(i);
31///     let candle =
32///         Candle::new(base, base + 2.0, base - 2.0, base + 1.0, 10.0, i64::from(i)).unwrap();
33///     last = indicator.update(candle);
34/// }
35/// assert!(last.is_some());
36/// ```
37#[derive(Debug, Clone, Default)]
38pub struct VolumePriceTrend {
39    prev_close: Option<f64>,
40    total: f64,
41    has_emitted: bool,
42}
43
44impl VolumePriceTrend {
45    /// Construct a new Volume-Price Trend starting at zero.
46    pub const fn new() -> Self {
47        Self {
48            prev_close: None,
49            total: 0.0,
50            has_emitted: false,
51        }
52    }
53
54    /// Current cumulative value if at least one candle has been ingested.
55    pub const fn value(&self) -> Option<f64> {
56        if self.has_emitted {
57            Some(self.total)
58        } else {
59            None
60        }
61    }
62}
63
64impl Indicator for VolumePriceTrend {
65    type Input = Candle;
66    type Output = f64;
67
68    fn update(&mut self, candle: Candle) -> Option<f64> {
69        self.has_emitted = true;
70        let Some(prev) = self.prev_close else {
71            // The first candle establishes the baseline at 0.
72            self.prev_close = Some(candle.close);
73            return Some(self.total);
74        };
75        let roc = if prev == 0.0 {
76            // Undefined ratio against a zero previous close.
77            0.0
78        } else {
79            (candle.close - prev) / prev
80        };
81        self.total += candle.volume * roc;
82        self.prev_close = Some(candle.close);
83        Some(self.total)
84    }
85
86    fn reset(&mut self) {
87        self.prev_close = None;
88        self.total = 0.0;
89        self.has_emitted = false;
90    }
91
92    fn warmup_period(&self) -> usize {
93        1
94    }
95
96    fn is_ready(&self) -> bool {
97        self.has_emitted
98    }
99
100    fn name(&self) -> &'static str {
101        "VPT"
102    }
103}
104
105#[cfg(test)]
106mod tests {
107    use super::*;
108    use crate::traits::BatchExt;
109    use approx::assert_relative_eq;
110
111    fn candle(close: f64, volume: f64, ts: i64) -> Candle {
112        Candle::new(close, close, close, close, volume, ts).unwrap()
113    }
114
115    #[test]
116    fn reference_values() {
117        // closes [10, 11, 9], volumes [100, 200, 300]:
118        //   bar 1: baseline 0.
119        //   bar 2: VPT += 200 · (11-10)/10 = 20  -> 20.
120        //   bar 3: VPT += 300 · (9-11)/11 = -600/11 -> 20 - 600/11.
121        let mut vpt = VolumePriceTrend::new();
122        let out = vpt.batch(&[
123            candle(10.0, 100.0, 0),
124            candle(11.0, 200.0, 1),
125            candle(9.0, 300.0, 2),
126        ]);
127        assert_relative_eq!(out[0].unwrap(), 0.0, epsilon = 1e-12);
128        assert_relative_eq!(out[1].unwrap(), 20.0, epsilon = 1e-12);
129        assert_relative_eq!(out[2].unwrap(), 20.0 - 600.0 / 11.0, epsilon = 1e-12);
130    }
131
132    /// Cover the `value()` Some branch (line 57) and the Indicator-impl
133    /// `name` body (100-102). `reset_clears_state` hits only the None
134    /// branch; the name was never queried.
135    #[test]
136    fn accessors_and_metadata() {
137        let mut vpt = VolumePriceTrend::new();
138        assert_eq!(vpt.name(), "VPT");
139        assert_eq!(vpt.value(), None);
140        vpt.update(candle(100.0, 50.0, 0));
141        assert_eq!(vpt.value(), Some(0.0));
142    }
143
144    /// Cover the `prev == 0.0` defensive branch (line 77) — the previous
145    /// close is exactly 0, making the percentage ROC undefined. The
146    /// indicator must contribute 0 to the running total rather than NaN.
147    #[test]
148    fn zero_previous_close_contributes_zero() {
149        let mut vpt = VolumePriceTrend::new();
150        vpt.update(candle(0.0, 100.0, 0)); // baseline; prev_close = 0
151        let v = vpt.update(candle(50.0, 200.0, 1)).expect("emits");
152        // ROC fallback is 0, so total stays at 0.
153        assert_eq!(v, 0.0);
154    }
155
156    #[test]
157    fn emits_from_first_candle_at_zero() {
158        let mut vpt = VolumePriceTrend::new();
159        assert_eq!(vpt.warmup_period(), 1);
160        assert_eq!(vpt.update(candle(100.0, 50.0, 0)), Some(0.0));
161    }
162
163    #[test]
164    fn constant_close_keeps_line_flat() {
165        // No price change -> no contribution regardless of volume.
166        let mut vpt = VolumePriceTrend::new();
167        let candles: Vec<Candle> = (0..20).map(|i| candle(100.0, 500.0, i)).collect();
168        for v in vpt.batch(&candles).into_iter().flatten() {
169            assert_relative_eq!(v, 0.0, epsilon = 1e-12);
170        }
171    }
172
173    #[test]
174    fn reset_clears_state() {
175        let mut vpt = VolumePriceTrend::new();
176        vpt.batch(&[
177            candle(10.0, 100.0, 0),
178            candle(11.0, 100.0, 1),
179            candle(12.0, 100.0, 2),
180        ]);
181        assert!(vpt.is_ready());
182        vpt.reset();
183        assert!(!vpt.is_ready());
184        assert_eq!(vpt.value(), None);
185    }
186
187    #[test]
188    fn batch_equals_streaming() {
189        let candles: Vec<Candle> = (0..60)
190            .map(|i| {
191                candle(
192                    100.0 + (i as f64 * 0.3).sin() * 8.0,
193                    10.0 + (i % 5) as f64,
194                    i,
195                )
196            })
197            .collect();
198        let batch = VolumePriceTrend::new().batch(&candles);
199        let mut b = VolumePriceTrend::new();
200        let streamed: Vec<_> = candles.iter().map(|c| b.update(*c)).collect();
201        assert_eq!(batch, streamed);
202    }
203}