Skip to main content

wickra_core/indicators/
td_risk_level.rs

1#![allow(clippy::doc_markdown)]
2
3//! Tom DeMark TD Risk Level — protective-stop levels derived from setup
4//! extremes.
5//!
6//! DeMark proposes a quantitative stop level for trades taken on the back
7//! of a completed setup. The risk level is computed from the bar that
8//! made the most-extreme price during the setup run and that bar's true
9//! range:
10//!
11//! - **Buy risk** (the protective stop for a long position taken on a
12//!   completed buy setup) is `low_extreme_bar.low - true_range_extreme_bar`.
13//!   `low_extreme_bar` is the bar with the lowest low among the setup's
14//!   bars; `true_range_extreme_bar` is its true range
15//!   (`max(high - low, |high - prev_close|, |low - prev_close|)`).
16//! - **Sell risk** (the protective stop for a short position taken on a
17//!   completed sell setup) is `high_extreme_bar.high +
18//!   true_range_extreme_bar`.
19//!
20//! The level is set the moment a setup completes and stays at that value
21//! until the next setup in that direction completes. Either field is
22//! `f64::NAN` until the first setup in that direction completes.
23
24use std::collections::VecDeque;
25
26use crate::error::{Error, Result};
27use crate::ohlcv::Candle;
28use crate::traits::Indicator;
29
30/// Output of [`TdRiskLevel`]: the latest buy- and sell-side protective
31/// stop levels derived from the most-recently-completed setup in each
32/// direction. Either field is `f64::NAN` until the first setup in that
33/// direction completes.
34#[derive(Debug, Clone, Copy, PartialEq)]
35pub struct TdRiskLevelOutput {
36    /// Protective-stop level for a long position taken on a completed
37    /// buy setup. `NAN` until the first buy setup completes.
38    pub buy_risk: f64,
39    /// Protective-stop level for a short position taken on a completed
40    /// sell setup. `NAN` until the first sell setup completes.
41    pub sell_risk: f64,
42}
43
44/// Track the bar making the running extreme of the current run, together
45/// with its true range.
46#[derive(Debug, Clone, Copy)]
47struct ExtremeBar {
48    price: f64,
49    true_range: f64,
50}
51
52/// TD Risk Level — setup-derived protective-stop levels.
53#[derive(Debug, Clone)]
54pub struct TdRiskLevel {
55    lookback: usize,
56    target: usize,
57    closes: VecDeque<f64>,
58    prev: Option<Candle>,
59    buy_count: usize,
60    sell_count: usize,
61    /// Extreme (lowest low) bar of the active buy-setup run.
62    buy_extreme: Option<ExtremeBar>,
63    /// Extreme (highest high) bar of the active sell-setup run.
64    sell_extreme: Option<ExtremeBar>,
65    buy_risk: f64,
66    sell_risk: f64,
67    ready: bool,
68}
69
70fn true_range(candle: Candle, prev: Option<Candle>) -> f64 {
71    let hl = candle.high - candle.low;
72    if let Some(p) = prev {
73        let hc = (candle.high - p.close).abs();
74        let lc = (candle.low - p.close).abs();
75        hl.max(hc).max(lc)
76    } else {
77        hl
78    }
79}
80
81impl TdRiskLevel {
82    /// Construct a TD Risk Level with explicit lookback and target. The
83    /// canonical DeMark configuration is `lookback = 4`, `target = 9`.
84    ///
85    /// # Errors
86    ///
87    /// Returns [`Error::PeriodZero`] if either argument is zero.
88    pub fn new(lookback: usize, target: usize) -> Result<Self> {
89        if lookback == 0 || target == 0 {
90            return Err(Error::PeriodZero);
91        }
92        Ok(Self {
93            lookback,
94            target,
95            closes: VecDeque::with_capacity(lookback + 1),
96            prev: None,
97            buy_count: 0,
98            sell_count: 0,
99            buy_extreme: None,
100            sell_extreme: None,
101            buy_risk: f64::NAN,
102            sell_risk: f64::NAN,
103            ready: false,
104        })
105    }
106
107    /// DeMark's classic configuration: `lookback = 4`, `target = 9`.
108    pub fn classic() -> Self {
109        Self::new(4, 9).expect("classic TD Risk Level parameters are valid")
110    }
111
112    /// Configured `(lookback, target)`.
113    pub const fn params(&self) -> (usize, usize) {
114        (self.lookback, self.target)
115    }
116}
117
118impl Indicator for TdRiskLevel {
119    type Input = Candle;
120    type Output = TdRiskLevelOutput;
121
122    fn update(&mut self, candle: Candle) -> Option<TdRiskLevelOutput> {
123        let tr = true_range(candle, self.prev);
124        if self.closes.len() > self.lookback {
125            self.closes.pop_front();
126        }
127        if self.closes.len() < self.lookback {
128            self.closes.push_back(candle.close);
129            self.prev = Some(candle);
130            return None;
131        }
132        let reference = *self.closes.front().expect("non-empty after the guard");
133        self.closes.push_back(candle.close);
134
135        if candle.close < reference {
136            // Buy setup run.
137            let new_extreme = ExtremeBar {
138                price: candle.low,
139                true_range: tr,
140            };
141            self.buy_extreme = Some(match self.buy_extreme {
142                Some(e) if e.price <= candle.low => e,
143                _ => new_extreme,
144            });
145            self.buy_count = (self.buy_count + 1).min(self.target);
146            self.sell_count = 0;
147            self.sell_extreme = None;
148            if self.buy_count == self.target {
149                let e = self.buy_extreme.expect("set above when buy_count > 0");
150                self.buy_risk = e.price - e.true_range;
151            }
152        } else if candle.close > reference {
153            // Sell setup run.
154            let new_extreme = ExtremeBar {
155                price: candle.high,
156                true_range: tr,
157            };
158            self.sell_extreme = Some(match self.sell_extreme {
159                Some(e) if e.price >= candle.high => e,
160                _ => new_extreme,
161            });
162            self.sell_count = (self.sell_count + 1).min(self.target);
163            self.buy_count = 0;
164            self.buy_extreme = None;
165            if self.sell_count == self.target {
166                let e = self.sell_extreme.expect("set above when sell_count > 0");
167                self.sell_risk = e.price + e.true_range;
168            }
169        } else {
170            self.buy_count = 0;
171            self.sell_count = 0;
172            self.buy_extreme = None;
173            self.sell_extreme = None;
174        }
175
176        self.prev = Some(candle);
177        self.ready = true;
178        Some(TdRiskLevelOutput {
179            buy_risk: self.buy_risk,
180            sell_risk: self.sell_risk,
181        })
182    }
183
184    fn reset(&mut self) {
185        self.closes.clear();
186        self.prev = None;
187        self.buy_count = 0;
188        self.sell_count = 0;
189        self.buy_extreme = None;
190        self.sell_extreme = None;
191        self.buy_risk = f64::NAN;
192        self.sell_risk = f64::NAN;
193        self.ready = false;
194    }
195
196    fn warmup_period(&self) -> usize {
197        self.lookback + 1
198    }
199
200    fn is_ready(&self) -> bool {
201        self.ready
202    }
203
204    fn name(&self) -> &'static str {
205        "TDRiskLevel"
206    }
207}
208
209#[cfg(test)]
210mod tests {
211    use super::*;
212    use crate::traits::BatchExt;
213    use approx::assert_relative_eq;
214
215    fn c(high: f64, low: f64, close: f64, ts: i64) -> Candle {
216        Candle::new_unchecked(close, high, low, close, 0.0, ts)
217    }
218
219    #[test]
220    fn uptrend_sets_sell_risk_above_highest_high_of_setup() {
221        // Strictly rising closes -> sell setup completes at idx 12.
222        // The sell run starts at idx 4 (first bar that has close >
223        // close[i-4]). The highest high during the run is the bar at
224        // idx 12 (since the series is strictly increasing).
225        let candles: Vec<Candle> = (1..=20)
226            .map(|i| {
227                c(
228                    f64::from(i) + 0.5,
229                    f64::from(i) - 0.5,
230                    f64::from(i),
231                    i64::from(i),
232                )
233            })
234            .collect();
235        let mut td = TdRiskLevel::classic();
236        let out = td.batch(&candles);
237        let after = out[12].expect("ready");
238        assert!(after.buy_risk.is_nan());
239        // High at idx 12 is 13.5; the true range there is 1.0 (1.0 vs
240        // |13.5-12|=1.5 vs |12.5-12|=0.5 -> max=1.5). So sell_risk =
241        // 13.5 + 1.5 = 15.0.
242        assert_relative_eq!(after.sell_risk, 15.0, epsilon = 1e-12);
243    }
244
245    #[test]
246    fn flat_series_never_sets_levels() {
247        let candles: Vec<Candle> = (0..30).map(|i| c(10.5, 9.5, 10.0, i64::from(i))).collect();
248        let mut td = TdRiskLevel::classic();
249        for v in td.batch(&candles).into_iter().flatten() {
250            assert!(v.buy_risk.is_nan());
251            assert!(v.sell_risk.is_nan());
252        }
253    }
254
255    #[test]
256    fn batch_equals_streaming() {
257        let candles: Vec<Candle> = (0..80)
258            .map(|i| {
259                let m = 100.0 + (f64::from(i) * 0.3).sin() * 5.0;
260                c(m + 1.0, m - 1.0, m, i64::from(i))
261            })
262            .collect();
263        let mut a = TdRiskLevel::classic();
264        let mut b = TdRiskLevel::classic();
265        let av = a.batch(&candles);
266        let bv: Vec<_> = candles.iter().map(|x| b.update(*x)).collect();
267        assert_eq!(av.len(), bv.len());
268        for (i, (x, y)) in av.iter().zip(bv.iter()).enumerate() {
269            assert_eq!(x.is_some(), y.is_some(), "row {i} option mismatch");
270            if let (Some(a), Some(b)) = (x, y) {
271                assert_eq!(a.buy_risk.is_nan(), b.buy_risk.is_nan());
272                assert_eq!(a.sell_risk.is_nan(), b.sell_risk.is_nan());
273                if !a.buy_risk.is_nan() {
274                    assert_relative_eq!(a.buy_risk, b.buy_risk, epsilon = 1e-12);
275                }
276                if !a.sell_risk.is_nan() {
277                    assert_relative_eq!(a.sell_risk, b.sell_risk, epsilon = 1e-12);
278                }
279            }
280        }
281    }
282
283    #[test]
284    fn rejects_invalid_params() {
285        assert!(matches!(TdRiskLevel::new(0, 9), Err(Error::PeriodZero)));
286        assert!(matches!(TdRiskLevel::new(4, 0), Err(Error::PeriodZero)));
287    }
288
289    #[test]
290    fn reset_clears_state() {
291        let candles: Vec<Candle> = (1..=20)
292            .map(|i| {
293                c(
294                    f64::from(i) + 0.5,
295                    f64::from(i) - 0.5,
296                    f64::from(i),
297                    i64::from(i),
298                )
299            })
300            .collect();
301        let mut td = TdRiskLevel::classic();
302        td.batch(&candles);
303        assert!(td.is_ready());
304        td.reset();
305        assert!(!td.is_ready());
306        assert_eq!(td.update(candles[0]), None);
307    }
308
309    #[test]
310    fn accessors_and_metadata() {
311        let td = TdRiskLevel::classic();
312        assert_eq!(td.params(), (4, 9));
313        assert_eq!(td.warmup_period(), 5);
314        assert_eq!(td.name(), "TDRiskLevel");
315    }
316}