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/// # Example
54///
55/// ```
56/// use wickra_core::{TdRiskLevel, Candle, Indicator};
57///
58/// let mut indicator = TdRiskLevel::new(4, 9).unwrap();
59/// // `None` during warmup, then `Some(_)` once enough bars are seen.
60/// let mut out = None;
61/// for i in 0..40i64 {
62///     let p = 100.0 + (i as f64 * 0.4).sin() * 5.0;
63///     let candle = Candle::new(p, p + 1.5, p - 1.5, p + 0.3, 1_000.0, i).unwrap();
64///     out = indicator.update(candle);
65/// }
66/// let _ = out;
67/// ```
68#[derive(Debug, Clone)]
69pub struct TdRiskLevel {
70    lookback: usize,
71    target: usize,
72    closes: VecDeque<f64>,
73    prev: Option<Candle>,
74    buy_count: usize,
75    sell_count: usize,
76    /// Extreme (lowest low) bar of the active buy-setup run.
77    buy_extreme: Option<ExtremeBar>,
78    /// Extreme (highest high) bar of the active sell-setup run.
79    sell_extreme: Option<ExtremeBar>,
80    buy_risk: f64,
81    sell_risk: f64,
82    ready: bool,
83}
84
85fn true_range(candle: Candle, prev: Option<Candle>) -> f64 {
86    let hl = candle.high - candle.low;
87    if let Some(p) = prev {
88        let hc = (candle.high - p.close).abs();
89        let lc = (candle.low - p.close).abs();
90        hl.max(hc).max(lc)
91    } else {
92        hl
93    }
94}
95
96impl TdRiskLevel {
97    /// Construct a TD Risk Level with explicit lookback and target. The
98    /// canonical DeMark configuration is `lookback = 4`, `target = 9`.
99    ///
100    /// # Errors
101    ///
102    /// Returns [`Error::PeriodZero`] if either argument is zero.
103    pub fn new(lookback: usize, target: usize) -> Result<Self> {
104        if lookback == 0 || target == 0 {
105            return Err(Error::PeriodZero);
106        }
107        Ok(Self {
108            lookback,
109            target,
110            closes: VecDeque::with_capacity(lookback + 1),
111            prev: None,
112            buy_count: 0,
113            sell_count: 0,
114            buy_extreme: None,
115            sell_extreme: None,
116            buy_risk: f64::NAN,
117            sell_risk: f64::NAN,
118            ready: false,
119        })
120    }
121
122    /// DeMark's classic configuration: `lookback = 4`, `target = 9`.
123    pub fn classic() -> Self {
124        Self::new(4, 9).expect("classic TD Risk Level parameters are valid")
125    }
126
127    /// Configured `(lookback, target)`.
128    pub const fn params(&self) -> (usize, usize) {
129        (self.lookback, self.target)
130    }
131}
132
133impl Indicator for TdRiskLevel {
134    type Input = Candle;
135    type Output = TdRiskLevelOutput;
136
137    fn update(&mut self, candle: Candle) -> Option<TdRiskLevelOutput> {
138        let tr = true_range(candle, self.prev);
139        if self.closes.len() > self.lookback {
140            self.closes.pop_front();
141        }
142        if self.closes.len() < self.lookback {
143            self.closes.push_back(candle.close);
144            self.prev = Some(candle);
145            return None;
146        }
147        let reference = *self.closes.front().expect("non-empty after the guard");
148        self.closes.push_back(candle.close);
149
150        if candle.close < reference {
151            // Buy setup run.
152            let new_extreme = ExtremeBar {
153                price: candle.low,
154                true_range: tr,
155            };
156            self.buy_extreme = Some(match self.buy_extreme {
157                Some(e) if e.price <= candle.low => e,
158                _ => new_extreme,
159            });
160            self.buy_count = (self.buy_count + 1).min(self.target);
161            self.sell_count = 0;
162            self.sell_extreme = None;
163            if self.buy_count == self.target {
164                let e = self.buy_extreme.expect("set above when buy_count > 0");
165                self.buy_risk = e.price - e.true_range;
166            }
167        } else if candle.close > reference {
168            // Sell setup run.
169            let new_extreme = ExtremeBar {
170                price: candle.high,
171                true_range: tr,
172            };
173            self.sell_extreme = Some(match self.sell_extreme {
174                Some(e) if e.price >= candle.high => e,
175                _ => new_extreme,
176            });
177            self.sell_count = (self.sell_count + 1).min(self.target);
178            self.buy_count = 0;
179            self.buy_extreme = None;
180            if self.sell_count == self.target {
181                let e = self.sell_extreme.expect("set above when sell_count > 0");
182                self.sell_risk = e.price + e.true_range;
183            }
184        } else {
185            self.buy_count = 0;
186            self.sell_count = 0;
187            self.buy_extreme = None;
188            self.sell_extreme = None;
189        }
190
191        self.prev = Some(candle);
192        self.ready = true;
193        Some(TdRiskLevelOutput {
194            buy_risk: self.buy_risk,
195            sell_risk: self.sell_risk,
196        })
197    }
198
199    fn reset(&mut self) {
200        self.closes.clear();
201        self.prev = None;
202        self.buy_count = 0;
203        self.sell_count = 0;
204        self.buy_extreme = None;
205        self.sell_extreme = None;
206        self.buy_risk = f64::NAN;
207        self.sell_risk = f64::NAN;
208        self.ready = false;
209    }
210
211    fn warmup_period(&self) -> usize {
212        self.lookback + 1
213    }
214
215    fn is_ready(&self) -> bool {
216        self.ready
217    }
218
219    fn name(&self) -> &'static str {
220        "TDRiskLevel"
221    }
222}
223
224#[cfg(test)]
225mod tests {
226    use super::*;
227    use crate::traits::BatchExt;
228    use approx::assert_relative_eq;
229
230    fn c(high: f64, low: f64, close: f64, ts: i64) -> Candle {
231        Candle::new_unchecked(close, high, low, close, 0.0, ts)
232    }
233
234    #[test]
235    fn uptrend_sets_sell_risk_above_highest_high_of_setup() {
236        // Strictly rising closes -> sell setup completes at idx 12.
237        // The sell run starts at idx 4 (first bar that has close >
238        // close[i-4]). The highest high during the run is the bar at
239        // idx 12 (since the series is strictly increasing).
240        let candles: Vec<Candle> = (1..=20)
241            .map(|i| {
242                c(
243                    f64::from(i) + 0.5,
244                    f64::from(i) - 0.5,
245                    f64::from(i),
246                    i64::from(i),
247                )
248            })
249            .collect();
250        let mut td = TdRiskLevel::classic();
251        let out = td.batch(&candles);
252        let after = out[12].expect("ready");
253        assert!(after.buy_risk.is_nan());
254        // High at idx 12 is 13.5; the true range there is 1.0 (1.0 vs
255        // |13.5-12|=1.5 vs |12.5-12|=0.5 -> max=1.5). So sell_risk =
256        // 13.5 + 1.5 = 15.0.
257        assert_relative_eq!(after.sell_risk, 15.0, epsilon = 1e-12);
258    }
259
260    #[test]
261    fn flat_series_never_sets_levels() {
262        let candles: Vec<Candle> = (0..30).map(|i| c(10.5, 9.5, 10.0, i64::from(i))).collect();
263        let mut td = TdRiskLevel::classic();
264        for v in td.batch(&candles).into_iter().flatten() {
265            assert!(v.buy_risk.is_nan());
266            assert!(v.sell_risk.is_nan());
267        }
268    }
269
270    #[test]
271    fn batch_equals_streaming() {
272        let candles: Vec<Candle> = (0..80)
273            .map(|i| {
274                let m = 100.0 + (f64::from(i) * 0.3).sin() * 5.0;
275                c(m + 1.0, m - 1.0, m, i64::from(i))
276            })
277            .collect();
278        let mut a = TdRiskLevel::classic();
279        let mut b = TdRiskLevel::classic();
280        let av = a.batch(&candles);
281        let bv: Vec<_> = candles.iter().map(|x| b.update(*x)).collect();
282        assert_eq!(av.len(), bv.len());
283        for (i, (x, y)) in av.iter().zip(bv.iter()).enumerate() {
284            assert_eq!(x.is_some(), y.is_some(), "row {i} option mismatch");
285            if let (Some(a), Some(b)) = (x, y) {
286                assert_eq!(a.buy_risk.is_nan(), b.buy_risk.is_nan());
287                assert_eq!(a.sell_risk.is_nan(), b.sell_risk.is_nan());
288                if !a.buy_risk.is_nan() {
289                    assert_relative_eq!(a.buy_risk, b.buy_risk, epsilon = 1e-12);
290                }
291                if !a.sell_risk.is_nan() {
292                    assert_relative_eq!(a.sell_risk, b.sell_risk, epsilon = 1e-12);
293                }
294            }
295        }
296    }
297
298    #[test]
299    fn rejects_invalid_params() {
300        assert!(matches!(TdRiskLevel::new(0, 9), Err(Error::PeriodZero)));
301        assert!(matches!(TdRiskLevel::new(4, 0), Err(Error::PeriodZero)));
302    }
303
304    #[test]
305    fn reset_clears_state() {
306        let candles: Vec<Candle> = (1..=20)
307            .map(|i| {
308                c(
309                    f64::from(i) + 0.5,
310                    f64::from(i) - 0.5,
311                    f64::from(i),
312                    i64::from(i),
313                )
314            })
315            .collect();
316        let mut td = TdRiskLevel::classic();
317        td.batch(&candles);
318        assert!(td.is_ready());
319        td.reset();
320        assert!(!td.is_ready());
321        assert_eq!(td.update(candles[0]), None);
322    }
323
324    #[test]
325    fn accessors_and_metadata() {
326        let td = TdRiskLevel::classic();
327        assert_eq!(td.params(), (4, 9));
328        assert_eq!(td.warmup_period(), 5);
329        assert_eq!(td.name(), "TDRiskLevel");
330    }
331}