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    fn update(&mut self, candle: Candle) -> Option<TdRangeProjectionOutput> {
60        let pivot_sum = if candle.close < candle.open {
61            candle.high + 2.0 * candle.low + candle.close
62        } else if candle.close > candle.open {
63            2.0 * candle.high + candle.low + candle.close
64        } else {
65            candle.high + candle.low + 2.0 * candle.close
66        };
67        let half = pivot_sum / 2.0;
68        let out = TdRangeProjectionOutput {
69            high: half - candle.low,
70            low: half - candle.high,
71        };
72        self.last_value = Some(out);
73        Some(out)
74    }
75
76    fn reset(&mut self) {
77        self.last_value = None;
78    }
79
80    fn warmup_period(&self) -> usize {
81        1
82    }
83
84    fn is_ready(&self) -> bool {
85        self.last_value.is_some()
86    }
87
88    fn name(&self) -> &'static str {
89        "TDRangeProjection"
90    }
91}
92
93#[cfg(test)]
94mod tests {
95    use super::*;
96    use crate::traits::BatchExt;
97    use approx::assert_relative_eq;
98
99    fn c(open: f64, high: f64, low: f64, close: f64, ts: i64) -> Candle {
100        Candle::new_unchecked(open, high, low, close, 0.0, ts)
101    }
102
103    #[test]
104    fn bullish_bar_close_above_open_uses_double_high_pivot() {
105        // open=10, high=12, low=9, close=11 -> close > open
106        // pivot_sum = 2*12 + 9 + 11 = 44; half = 22.
107        // projHigh = 22 - 9 = 13; projLow = 22 - 12 = 10.
108        let mut p = TdRangeProjection::new();
109        let v = p.update(c(10.0, 12.0, 9.0, 11.0, 0)).unwrap();
110        assert_relative_eq!(v.high, 13.0, epsilon = 1e-12);
111        assert_relative_eq!(v.low, 10.0, epsilon = 1e-12);
112    }
113
114    #[test]
115    fn bearish_bar_close_below_open_uses_double_low_pivot() {
116        // open=11, high=12, low=9, close=10 -> close < open
117        // pivot_sum = 12 + 2*9 + 10 = 40; half = 20.
118        // projHigh = 20 - 9 = 11; projLow = 20 - 12 = 8.
119        let mut p = TdRangeProjection::new();
120        let v = p.update(c(11.0, 12.0, 9.0, 10.0, 0)).unwrap();
121        assert_relative_eq!(v.high, 11.0, epsilon = 1e-12);
122        assert_relative_eq!(v.low, 8.0, epsilon = 1e-12);
123    }
124
125    #[test]
126    fn doji_close_equals_open_uses_double_close_pivot() {
127        // open=close=10, high=12, low=9 -> doji branch.
128        // pivot_sum = 12 + 9 + 2*10 = 41; half = 20.5.
129        // projHigh = 20.5 - 9 = 11.5; projLow = 20.5 - 12 = 8.5.
130        let mut p = TdRangeProjection::new();
131        let v = p.update(c(10.0, 12.0, 9.0, 10.0, 0)).unwrap();
132        assert_relative_eq!(v.high, 11.5, epsilon = 1e-12);
133        assert_relative_eq!(v.low, 8.5, epsilon = 1e-12);
134    }
135
136    #[test]
137    fn batch_equals_streaming() {
138        let candles: Vec<Candle> = (0..30)
139            .map(|i| {
140                let m = 100.0 + (f64::from(i) * 0.3).sin() * 5.0;
141                c(m, m + 1.0, m - 1.0, m + 0.3, i64::from(i))
142            })
143            .collect();
144        let mut a = TdRangeProjection::new();
145        let mut b = TdRangeProjection::new();
146        assert_eq!(
147            a.batch(&candles),
148            candles.iter().map(|x| b.update(*x)).collect::<Vec<_>>()
149        );
150    }
151
152    #[test]
153    fn reset_clears_state() {
154        let mut p = TdRangeProjection::new();
155        p.update(c(10.0, 12.0, 9.0, 11.0, 0));
156        assert!(p.is_ready());
157        p.reset();
158        assert!(!p.is_ready());
159        assert_eq!(p.value(), None);
160    }
161
162    #[test]
163    fn accessors_and_metadata() {
164        let p = TdRangeProjection::new();
165        assert_eq!(p.warmup_period(), 1);
166        assert_eq!(p.name(), "TDRangeProjection");
167        assert_eq!(p.value(), None);
168    }
169}