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        Ok(Self {
53            period,
54            pressures: VecDeque::with_capacity(period),
55            volumes: VecDeque::with_capacity(period),
56            last_value: None,
57        })
58    }
59
60    /// Configured window.
61    pub const fn period(&self) -> usize {
62        self.period
63    }
64
65    /// Latest emitted value if available.
66    pub const fn value(&self) -> Option<f64> {
67        self.last_value
68    }
69}
70
71impl Indicator for TdPressure {
72    type Input = Candle;
73    type Output = f64;
74
75    fn update(&mut self, candle: Candle) -> Option<f64> {
76        let range = candle.high - candle.low;
77        let bar_pressure = if range > 0.0 {
78            ((candle.close - candle.open) / range) * candle.volume
79        } else {
80            0.0
81        };
82
83        if self.pressures.len() == self.period {
84            self.pressures.pop_front();
85            self.volumes.pop_front();
86        }
87        self.pressures.push_back(bar_pressure);
88        self.volumes.push_back(candle.volume);
89        if self.pressures.len() < self.period {
90            return None;
91        }
92        let n = self.period as f64;
93        let mean_p: f64 = self.pressures.iter().sum::<f64>() / n;
94        let mean_v: f64 = self.volumes.iter().sum::<f64>() / n;
95        let v = if mean_v == 0.0 {
96            0.0
97        } else {
98            100.0 * mean_p / mean_v
99        };
100        self.last_value = Some(v);
101        Some(v)
102    }
103
104    fn reset(&mut self) {
105        self.pressures.clear();
106        self.volumes.clear();
107        self.last_value = None;
108    }
109
110    fn warmup_period(&self) -> usize {
111        self.period
112    }
113
114    fn is_ready(&self) -> bool {
115        self.last_value.is_some()
116    }
117
118    fn name(&self) -> &'static str {
119        "TDPressure"
120    }
121}
122
123#[cfg(test)]
124mod tests {
125    use super::*;
126    use crate::traits::BatchExt;
127    use approx::assert_relative_eq;
128
129    fn c(open: f64, high: f64, low: f64, close: f64, volume: f64, ts: i64) -> Candle {
130        Candle::new_unchecked(open, high, low, close, volume, ts)
131    }
132
133    #[test]
134    fn pure_bullish_candles_yield_full_positive_pressure() {
135        // Every bar closes at its high (close == high, open == low), so the
136        // per-bar pressure ratio is +1. Volume cancels in the ratio and the
137        // indicator must read +100.
138        let candles: Vec<Candle> = (0..20)
139            .map(|i| c(9.0, 11.0, 9.0, 11.0, 100.0, i64::from(i)))
140            .collect();
141        let mut p = TdPressure::new(5).unwrap();
142        let last = p.batch(&candles).into_iter().flatten().last().unwrap();
143        assert_relative_eq!(last, 100.0, epsilon = 1e-12);
144    }
145
146    #[test]
147    fn pure_bearish_candles_yield_full_negative_pressure() {
148        let candles: Vec<Candle> = (0..20)
149            .map(|i| c(11.0, 11.0, 9.0, 9.0, 100.0, i64::from(i)))
150            .collect();
151        let mut p = TdPressure::new(5).unwrap();
152        let last = p.batch(&candles).into_iter().flatten().last().unwrap();
153        assert_relative_eq!(last, -100.0, epsilon = 1e-12);
154    }
155
156    #[test]
157    fn neutral_doji_close_eq_open_yields_zero() {
158        let candles: Vec<Candle> = (0..20)
159            .map(|i| c(10.0, 11.0, 9.0, 10.0, 100.0, i64::from(i)))
160            .collect();
161        let mut p = TdPressure::new(5).unwrap();
162        let last = p.batch(&candles).into_iter().flatten().last().unwrap();
163        assert_relative_eq!(last, 0.0, epsilon = 1e-12);
164    }
165
166    #[test]
167    fn zero_range_bars_contribute_zero() {
168        // Mix one zero-range bar with otherwise-bullish bars; the zero-range
169        // bar must be silently skipped (not produce NaN or inf).
170        let mut candles = Vec::new();
171        for i in 0..5 {
172            candles.push(c(9.0, 11.0, 9.0, 11.0, 100.0, i64::from(i)));
173        }
174        // Zero-range, zero-volume bar in the middle.
175        candles.push(c(10.0, 10.0, 10.0, 10.0, 0.0, 5));
176        for i in 6..11 {
177            candles.push(c(9.0, 11.0, 9.0, 11.0, 100.0, i64::from(i)));
178        }
179        let mut p = TdPressure::new(5).unwrap();
180        for v in p.batch(&candles).into_iter().flatten() {
181            assert!(v.is_finite(), "non-finite output: {v}");
182            assert!((-100.0..=100.0).contains(&v), "out of range: {v}");
183        }
184    }
185
186    #[test]
187    fn flat_zero_volume_window_emits_zero() {
188        let candles: Vec<Candle> = (0..10)
189            .map(|i| c(10.0, 11.0, 9.0, 10.5, 0.0, i64::from(i)))
190            .collect();
191        let mut p = TdPressure::new(5).unwrap();
192        // Every bar has zero volume -> per-bar pressure is zero AND the
193        // denominator is zero. The indicator must fall back to 0.
194        let last = p.batch(&candles).into_iter().flatten().last().unwrap();
195        assert_relative_eq!(last, 0.0, epsilon = 1e-12);
196    }
197
198    #[test]
199    fn batch_equals_streaming() {
200        let candles: Vec<Candle> = (0..60)
201            .map(|i| {
202                let m = 100.0 + (f64::from(i) * 0.3).sin() * 5.0;
203                c(m, m + 1.0, m - 1.0, m + 0.3, 100.0, i64::from(i))
204            })
205            .collect();
206        let mut a = TdPressure::new(5).unwrap();
207        let mut b = TdPressure::new(5).unwrap();
208        assert_eq!(
209            a.batch(&candles),
210            candles.iter().map(|x| b.update(*x)).collect::<Vec<_>>()
211        );
212    }
213
214    #[test]
215    fn rejects_zero_period() {
216        assert!(matches!(TdPressure::new(0), Err(Error::PeriodZero)));
217    }
218
219    #[test]
220    fn reset_clears_state() {
221        let candles: Vec<Candle> = (0..20)
222            .map(|i| c(9.0, 11.0, 9.0, 11.0, 100.0, i64::from(i)))
223            .collect();
224        let mut p = TdPressure::new(5).unwrap();
225        p.batch(&candles);
226        assert!(p.is_ready());
227        p.reset();
228        assert!(!p.is_ready());
229        assert_eq!(p.update(candles[0]), None);
230        assert_eq!(p.value(), None);
231    }
232
233    #[test]
234    fn accessors_and_metadata() {
235        let p = TdPressure::new(5).unwrap();
236        assert_eq!(p.period(), 5);
237        assert_eq!(p.warmup_period(), 5);
238        assert_eq!(p.name(), "TDPressure");
239    }
240}