Skip to main content

wickra_core/indicators/
td_range_projection.rs

1#![allow(clippy::doc_markdown)]
2
3//! Tom DeMark TD Range Projection — next-bar high/low projection from the
4//! current bar's open/high/low/close (DeMark's "X-projection" pivot).
5//!
6//! After each bar closes, DeMark proposes a projected high and low for the
7//! *next* bar derived from a pivot weighted by the relationship between
8//! the close and the open:
9//!
10//! ```text
11//! if close < open:    pivot_sum = high + 2*low  + close
12//! if close > open:    pivot_sum = 2*high + low  + close
13//! if close == open:   pivot_sum = high + low    + 2*close
14//!
15//! projected_high = pivot_sum / 2 - low
16//! projected_low  = pivot_sum / 2 - high
17//! ```
18//!
19//! The indicator is stateless beyond the current bar — every bar's input
20//! deterministically produces a projection — but it is wrapped in the same
21//! `Indicator` state-machine API as the rest of Wickra so it composes with
22//! the streaming/batch infrastructure.
23
24use crate::ohlcv::Candle;
25use crate::traits::Indicator;
26
27/// Output of [`TdRangeProjection`]: the projected high and low for the
28/// next bar.
29#[derive(Debug, Clone, Copy, PartialEq)]
30pub struct TdRangeProjectionOutput {
31    /// Projected high for the next bar.
32    pub high: f64,
33    /// Projected low for the next bar.
34    pub low: f64,
35}
36
37/// TD Range Projection — next-bar high/low pivot.
38#[derive(Debug, Clone, Default)]
39pub struct TdRangeProjection {
40    last_value: Option<TdRangeProjectionOutput>,
41}
42
43impl TdRangeProjection {
44    /// Construct a new `TdRangeProjection`.
45    pub fn new() -> Self {
46        Self::default()
47    }
48
49    /// Latest projection if available.
50    pub const fn value(&self) -> Option<TdRangeProjectionOutput> {
51        self.last_value
52    }
53}
54
55impl Indicator for TdRangeProjection {
56    type Input = Candle;
57    type Output = TdRangeProjectionOutput;
58
59    #[inline]
60    fn update(&mut self, candle: Candle) -> Option<TdRangeProjectionOutput> {
61        let pivot_sum = if candle.close < candle.open {
62            candle.high + 2.0 * candle.low + candle.close
63        } else if candle.close > candle.open {
64            2.0 * candle.high + candle.low + candle.close
65        } else {
66            candle.high + candle.low + 2.0 * candle.close
67        };
68        let half = pivot_sum / 2.0;
69        let out = TdRangeProjectionOutput {
70            high: half - candle.low,
71            low: half - candle.high,
72        };
73        self.last_value = Some(out);
74        Some(out)
75    }
76
77    fn reset(&mut self) {
78        self.last_value = None;
79    }
80
81    #[inline]
82    fn warmup_period(&self) -> usize {
83        1
84    }
85
86    #[inline]
87    fn is_ready(&self) -> bool {
88        self.last_value.is_some()
89    }
90
91    #[inline]
92    fn name(&self) -> &'static str {
93        "TDRangeProjection"
94    }
95}
96
97#[cfg(test)]
98mod tests {
99    use super::*;
100    use crate::traits::BatchExt;
101    use approx::assert_relative_eq;
102
103    fn c(open: f64, high: f64, low: f64, close: f64, ts: i64) -> Candle {
104        Candle::new_unchecked(open, high, low, close, 0.0, ts)
105    }
106
107    #[test]
108    fn bullish_bar_close_above_open_uses_double_high_pivot() {
109        // open=10, high=12, low=9, close=11 -> close > open
110        // pivot_sum = 2*12 + 9 + 11 = 44; half = 22.
111        // projHigh = 22 - 9 = 13; projLow = 22 - 12 = 10.
112        let mut p = TdRangeProjection::new();
113        let v = p.update(c(10.0, 12.0, 9.0, 11.0, 0)).unwrap();
114        assert_relative_eq!(v.high, 13.0, epsilon = 1e-12);
115        assert_relative_eq!(v.low, 10.0, epsilon = 1e-12);
116    }
117
118    #[test]
119    fn bearish_bar_close_below_open_uses_double_low_pivot() {
120        // open=11, high=12, low=9, close=10 -> close < open
121        // pivot_sum = 12 + 2*9 + 10 = 40; half = 20.
122        // projHigh = 20 - 9 = 11; projLow = 20 - 12 = 8.
123        let mut p = TdRangeProjection::new();
124        let v = p.update(c(11.0, 12.0, 9.0, 10.0, 0)).unwrap();
125        assert_relative_eq!(v.high, 11.0, epsilon = 1e-12);
126        assert_relative_eq!(v.low, 8.0, epsilon = 1e-12);
127    }
128
129    #[test]
130    fn doji_close_equals_open_uses_double_close_pivot() {
131        // open=close=10, high=12, low=9 -> doji branch.
132        // pivot_sum = 12 + 9 + 2*10 = 41; half = 20.5.
133        // projHigh = 20.5 - 9 = 11.5; projLow = 20.5 - 12 = 8.5.
134        let mut p = TdRangeProjection::new();
135        let v = p.update(c(10.0, 12.0, 9.0, 10.0, 0)).unwrap();
136        assert_relative_eq!(v.high, 11.5, epsilon = 1e-12);
137        assert_relative_eq!(v.low, 8.5, epsilon = 1e-12);
138    }
139
140    #[test]
141    fn batch_equals_streaming() {
142        let candles: Vec<Candle> = (0..30)
143            .map(|i| {
144                let m = 100.0 + (f64::from(i) * 0.3).sin() * 5.0;
145                c(m, m + 1.0, m - 1.0, m + 0.3, i64::from(i))
146            })
147            .collect();
148        let mut a = TdRangeProjection::new();
149        let mut b = TdRangeProjection::new();
150        assert_eq!(
151            a.batch(&candles),
152            candles.iter().map(|x| b.update(*x)).collect::<Vec<_>>()
153        );
154    }
155
156    #[test]
157    fn reset_clears_state() {
158        let mut p = TdRangeProjection::new();
159        p.update(c(10.0, 12.0, 9.0, 11.0, 0));
160        assert!(p.is_ready());
161        p.reset();
162        assert!(!p.is_ready());
163        assert_eq!(p.value(), None);
164    }
165
166    #[test]
167    fn accessors_and_metadata() {
168        let p = TdRangeProjection::new();
169        assert_eq!(p.warmup_period(), 1);
170        assert_eq!(p.name(), "TDRangeProjection");
171        assert_eq!(p.value(), None);
172    }
173}