Skip to main content

wickra_core/indicators/
td_pressure.rs

1#![allow(clippy::doc_markdown)]
2
3//! Tom DeMark TD Pressure — volume-weighted buying / selling pressure
4//! oscillator.
5//!
6//! For each bar `i` with strictly positive range:
7//!
8//! ```text
9//! bar_pressure(i) = ((close[i] - open[i]) / (high[i] - low[i])) * volume[i]
10//! ```
11//!
12//! Bars whose range is zero (`high == low`) contribute zero pressure (the
13//! ratio is undefined; DeMark's convention is to treat such bars as neutral).
14//! The output is the SMA of bar pressure normalised by the SMA of volume over
15//! a configurable `period`, scaled by 100:
16//!
17//! ```text
18//! TD_Pressure = 100 * SMA(bar_pressure, period) / SMA(volume, period)
19//! ```
20//!
21//! When the windowed volume is zero (a flat zero-volume window) the
22//! indicator emits `0`. Positive readings indicate net buying pressure;
23//! negative readings indicate net selling pressure. The numerator is bounded
24//! by `± volume_per_bar`, so the result is bounded by `±100`.
25
26use std::collections::VecDeque;
27
28use crate::error::{Error, Result};
29use crate::ohlcv::Candle;
30use crate::traits::Indicator;
31
32/// TD Pressure volume-weighted pressure oscillator.
33#[derive(Debug, Clone)]
34pub struct TdPressure {
35    period: usize,
36    pressures: VecDeque<f64>,
37    volumes: VecDeque<f64>,
38    last_value: Option<f64>,
39}
40
41impl TdPressure {
42    /// Construct a TD Pressure with the given averaging window. A common
43    /// default in DeMark's literature is `period = 5`.
44    ///
45    /// # Errors
46    ///
47    /// Returns [`Error::PeriodZero`] if `period == 0`.
48    pub fn new(period: usize) -> Result<Self> {
49        if period == 0 {
50            return Err(Error::PeriodZero);
51        }
52        if period > crate::error::MAX_PERIOD {
53            return Err(Error::InvalidPeriod {
54                message: crate::error::PERIOD_ABOVE_MAX,
55            });
56        }
57        Ok(Self {
58            period,
59            pressures: VecDeque::with_capacity(period),
60            volumes: VecDeque::with_capacity(period),
61            last_value: None,
62        })
63    }
64
65    /// Configured window.
66    pub const fn period(&self) -> usize {
67        self.period
68    }
69
70    /// Latest emitted value if available.
71    pub const fn value(&self) -> Option<f64> {
72        self.last_value
73    }
74}
75
76impl Indicator for TdPressure {
77    type Input = Candle;
78    type Output = f64;
79
80    #[inline]
81    fn update(&mut self, candle: Candle) -> Option<f64> {
82        let range = candle.high - candle.low;
83        let bar_pressure = if range > 0.0 {
84            ((candle.close - candle.open) / range) * candle.volume
85        } else {
86            0.0
87        };
88
89        if self.pressures.len() == self.period {
90            self.pressures.pop_front();
91            self.volumes.pop_front();
92        }
93        self.pressures.push_back(bar_pressure);
94        self.volumes.push_back(candle.volume);
95        if self.pressures.len() < self.period {
96            return None;
97        }
98        let n = self.period as f64;
99        let mean_p: f64 = self.pressures.iter().sum::<f64>() / n;
100        let mean_v: f64 = self.volumes.iter().sum::<f64>() / n;
101        let v = if mean_v == 0.0 {
102            0.0
103        } else {
104            100.0 * mean_p / mean_v
105        };
106        self.last_value = Some(v);
107        Some(v)
108    }
109
110    fn reset(&mut self) {
111        self.pressures.clear();
112        self.volumes.clear();
113        self.last_value = None;
114    }
115
116    #[inline]
117    fn warmup_period(&self) -> usize {
118        self.period
119    }
120
121    #[inline]
122    fn is_ready(&self) -> bool {
123        self.last_value.is_some()
124    }
125
126    #[inline]
127    fn name(&self) -> &'static str {
128        "TDPressure"
129    }
130}
131
132#[cfg(test)]
133mod tests {
134    use super::*;
135    use crate::traits::BatchExt;
136    use approx::assert_relative_eq;
137
138    fn c(open: f64, high: f64, low: f64, close: f64, volume: f64, ts: i64) -> Candle {
139        Candle::new_unchecked(open, high, low, close, volume, ts)
140    }
141
142    #[test]
143    fn pure_bullish_candles_yield_full_positive_pressure() {
144        // Every bar closes at its high (close == high, open == low), so the
145        // per-bar pressure ratio is +1. Volume cancels in the ratio and the
146        // indicator must read +100.
147        let candles: Vec<Candle> = (0..20)
148            .map(|i| c(9.0, 11.0, 9.0, 11.0, 100.0, i64::from(i)))
149            .collect();
150        let mut p = TdPressure::new(5).unwrap();
151        let last = p.batch(&candles).into_iter().flatten().last().unwrap();
152        assert_relative_eq!(last, 100.0, epsilon = 1e-12);
153    }
154
155    #[test]
156    fn pure_bearish_candles_yield_full_negative_pressure() {
157        let candles: Vec<Candle> = (0..20)
158            .map(|i| c(11.0, 11.0, 9.0, 9.0, 100.0, i64::from(i)))
159            .collect();
160        let mut p = TdPressure::new(5).unwrap();
161        let last = p.batch(&candles).into_iter().flatten().last().unwrap();
162        assert_relative_eq!(last, -100.0, epsilon = 1e-12);
163    }
164
165    #[test]
166    fn neutral_doji_close_eq_open_yields_zero() {
167        let candles: Vec<Candle> = (0..20)
168            .map(|i| c(10.0, 11.0, 9.0, 10.0, 100.0, i64::from(i)))
169            .collect();
170        let mut p = TdPressure::new(5).unwrap();
171        let last = p.batch(&candles).into_iter().flatten().last().unwrap();
172        assert_relative_eq!(last, 0.0, epsilon = 1e-12);
173    }
174
175    #[test]
176    fn zero_range_bars_contribute_zero() {
177        // Mix one zero-range bar with otherwise-bullish bars; the zero-range
178        // bar must be silently skipped (not produce NaN or inf).
179        let mut candles = Vec::new();
180        for i in 0..5 {
181            candles.push(c(9.0, 11.0, 9.0, 11.0, 100.0, i64::from(i)));
182        }
183        // Zero-range, zero-volume bar in the middle.
184        candles.push(c(10.0, 10.0, 10.0, 10.0, 0.0, 5));
185        for i in 6..11 {
186            candles.push(c(9.0, 11.0, 9.0, 11.0, 100.0, i64::from(i)));
187        }
188        let mut p = TdPressure::new(5).unwrap();
189        for v in p.batch(&candles).into_iter().flatten() {
190            assert!(v.is_finite(), "non-finite output: {v}");
191            assert!((-100.0..=100.0).contains(&v), "out of range: {v}");
192        }
193    }
194
195    #[test]
196    fn flat_zero_volume_window_emits_zero() {
197        let candles: Vec<Candle> = (0..10)
198            .map(|i| c(10.0, 11.0, 9.0, 10.5, 0.0, i64::from(i)))
199            .collect();
200        let mut p = TdPressure::new(5).unwrap();
201        // Every bar has zero volume -> per-bar pressure is zero AND the
202        // denominator is zero. The indicator must fall back to 0.
203        let last = p.batch(&candles).into_iter().flatten().last().unwrap();
204        assert_relative_eq!(last, 0.0, epsilon = 1e-12);
205    }
206
207    #[test]
208    fn batch_equals_streaming() {
209        let candles: Vec<Candle> = (0..60)
210            .map(|i| {
211                let m = 100.0 + (f64::from(i) * 0.3).sin() * 5.0;
212                c(m, m + 1.0, m - 1.0, m + 0.3, 100.0, i64::from(i))
213            })
214            .collect();
215        let mut a = TdPressure::new(5).unwrap();
216        let mut b = TdPressure::new(5).unwrap();
217        assert_eq!(
218            a.batch(&candles),
219            candles.iter().map(|x| b.update(*x)).collect::<Vec<_>>()
220        );
221    }
222
223    #[test]
224    fn rejects_zero_period() {
225        assert!(matches!(TdPressure::new(0), Err(Error::PeriodZero)));
226    }
227
228    #[test]
229    fn reset_clears_state() {
230        let candles: Vec<Candle> = (0..20)
231            .map(|i| c(9.0, 11.0, 9.0, 11.0, 100.0, i64::from(i)))
232            .collect();
233        let mut p = TdPressure::new(5).unwrap();
234        p.batch(&candles);
235        assert!(p.is_ready());
236        p.reset();
237        assert!(!p.is_ready());
238        assert_eq!(p.update(candles[0]), None);
239        assert_eq!(p.value(), None);
240    }
241
242    #[test]
243    fn accessors_and_metadata() {
244        let p = TdPressure::new(5).unwrap();
245        assert_eq!(p.period(), 5);
246        assert_eq!(p.warmup_period(), 5);
247        assert_eq!(p.name(), "TDPressure");
248    }
249}