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    ///
39    /// The two levels are established independently, so one can be a real price
40    /// while the other is still `NAN`. `update` withholds the output entirely
41    /// (`None`) until at least one of them exists, so a returned value always
42    /// carries at least one level. `NAN` is the encoding because the C ABI
43    /// mirrors this struct as two plain `double`s.
44    pub buy_risk: f64,
45    /// Protective-stop level for a short position taken on a completed
46    /// sell setup. `NAN` until the first sell setup completes.
47    ///
48    /// See [`Self::buy_risk`] for when a level can be `NAN`.
49    pub sell_risk: f64,
50}
51
52/// Track the bar making the running extreme of the current run, together
53/// with its true range.
54#[derive(Debug, Clone, Copy)]
55struct ExtremeBar {
56    price: f64,
57    true_range: f64,
58}
59
60/// TD Risk Level — setup-derived protective-stop levels.
61/// # Example
62///
63/// ```
64/// use wickra_core::{TdRiskLevel, Candle, Indicator};
65///
66/// let mut indicator = TdRiskLevel::new(4, 9).unwrap();
67/// // `None` during warmup, then `Some(_)` once enough bars are seen.
68/// let mut out = None;
69/// for i in 0..40i64 {
70///     let p = 100.0 + (i as f64 * 0.4).sin() * 5.0;
71///     let candle = Candle::new(p, p + 1.5, p - 1.5, p + 0.3, 1_000.0, i).unwrap();
72///     out = indicator.update(candle);
73/// }
74/// let _ = out;
75/// ```
76#[derive(Debug, Clone)]
77pub struct TdRiskLevel {
78    lookback: usize,
79    target: usize,
80    closes: VecDeque<f64>,
81    prev: Option<Candle>,
82    buy_count: usize,
83    sell_count: usize,
84    /// Extreme (lowest low) bar of the active buy-setup run.
85    buy_extreme: Option<ExtremeBar>,
86    /// Extreme (highest high) bar of the active sell-setup run.
87    sell_extreme: Option<ExtremeBar>,
88    /// Set once a buy setup completes; `None` means no level exists yet. Kept as
89    /// an `Option` rather than a `NAN` sentinel so "unset" is not inferred from
90    /// the bit pattern of a value that is supposed to be a price.
91    buy_risk: Option<f64>,
92    /// Set once a sell setup completes; `None` means no level exists yet.
93    sell_risk: Option<f64>,
94    ready: bool,
95}
96
97fn true_range(candle: Candle, prev: Option<Candle>) -> f64 {
98    let hl = candle.high - candle.low;
99    if let Some(p) = prev {
100        let hc = (candle.high - p.close).abs();
101        let lc = (candle.low - p.close).abs();
102        hl.max(hc).max(lc)
103    } else {
104        hl
105    }
106}
107
108impl TdRiskLevel {
109    /// Construct a TD Risk Level with explicit lookback and target. The
110    /// canonical DeMark configuration is `lookback = 4`, `target = 9`.
111    ///
112    /// # Errors
113    ///
114    /// Returns [`Error::PeriodZero`] if either argument is zero.
115    pub fn new(lookback: usize, target: usize) -> Result<Self> {
116        if lookback == 0 || target == 0 {
117            return Err(Error::PeriodZero);
118        }
119        Ok(Self {
120            lookback,
121            target,
122            closes: VecDeque::with_capacity(lookback + 1),
123            prev: None,
124            buy_count: 0,
125            sell_count: 0,
126            buy_extreme: None,
127            sell_extreme: None,
128            buy_risk: None,
129            sell_risk: None,
130            ready: false,
131        })
132    }
133
134    /// DeMark's classic configuration: `lookback = 4`, `target = 9`.
135    pub fn classic() -> Self {
136        Self::new(4, 9).expect("classic TD Risk Level parameters are valid")
137    }
138
139    /// Configured `(lookback, target)`.
140    pub const fn params(&self) -> (usize, usize) {
141        (self.lookback, self.target)
142    }
143}
144
145impl Indicator for TdRiskLevel {
146    type Input = Candle;
147    type Output = TdRiskLevelOutput;
148
149    fn update(&mut self, candle: Candle) -> Option<TdRiskLevelOutput> {
150        let tr = true_range(candle, self.prev);
151        if self.closes.len() > self.lookback {
152            self.closes.pop_front();
153        }
154        if self.closes.len() < self.lookback {
155            self.closes.push_back(candle.close);
156            self.prev = Some(candle);
157            return None;
158        }
159        let reference = *self.closes.front().expect("non-empty after the guard");
160        self.closes.push_back(candle.close);
161
162        if candle.close < reference {
163            // Buy setup run.
164            let new_extreme = ExtremeBar {
165                price: candle.low,
166                true_range: tr,
167            };
168            self.buy_extreme = Some(match self.buy_extreme {
169                Some(e) if e.price <= candle.low => e,
170                _ => new_extreme,
171            });
172            self.buy_count = (self.buy_count + 1).min(self.target);
173            self.sell_count = 0;
174            self.sell_extreme = None;
175            if self.buy_count == self.target {
176                let e = self.buy_extreme.expect("set above when buy_count > 0");
177                self.buy_risk = Some(e.price - e.true_range);
178            }
179        } else if candle.close > reference {
180            // Sell setup run.
181            let new_extreme = ExtremeBar {
182                price: candle.high,
183                true_range: tr,
184            };
185            self.sell_extreme = Some(match self.sell_extreme {
186                Some(e) if e.price >= candle.high => e,
187                _ => new_extreme,
188            });
189            self.sell_count = (self.sell_count + 1).min(self.target);
190            self.buy_count = 0;
191            self.buy_extreme = None;
192            if self.sell_count == self.target {
193                let e = self.sell_extreme.expect("set above when sell_count > 0");
194                self.sell_risk = Some(e.price + e.true_range);
195            }
196        } else {
197            self.buy_count = 0;
198            self.sell_count = 0;
199            self.buy_extreme = None;
200            self.sell_extreme = None;
201        }
202
203        self.prev = Some(candle);
204        // Neither level established means there is nothing to report yet. The
205        // trait defines `None` as "insufficient inputs to produce a defined
206        // value", and a pair of NaNs is exactly that — on a flat series the old
207        // code emitted it on every bar forever, and it reached the bindings as
208        // two NaNs in a flat output buffer.
209        if self.buy_risk.is_none() && self.sell_risk.is_none() {
210            return None;
211        }
212        self.ready = true;
213        Some(TdRiskLevelOutput {
214            buy_risk: self.buy_risk.unwrap_or(f64::NAN),
215            sell_risk: self.sell_risk.unwrap_or(f64::NAN),
216        })
217    }
218
219    fn reset(&mut self) {
220        self.closes.clear();
221        self.prev = None;
222        self.buy_count = 0;
223        self.sell_count = 0;
224        self.buy_extreme = None;
225        self.sell_extreme = None;
226        self.buy_risk = None;
227        self.sell_risk = None;
228        self.ready = false;
229    }
230
231    /// Lower bound only: a risk level needs a *completed* setup, which depends
232    /// on the data, so the first value can arrive arbitrarily later than this —
233    /// and on a series that never completes a setup, never.
234    #[inline]
235    fn warmup_period(&self) -> usize {
236        self.lookback + 1
237    }
238
239    #[inline]
240    fn is_ready(&self) -> bool {
241        self.ready
242    }
243
244    #[inline]
245    fn name(&self) -> &'static str {
246        "TDRiskLevel"
247    }
248}
249
250#[cfg(test)]
251mod tests {
252    use super::*;
253    use crate::traits::BatchExt;
254    use approx::assert_relative_eq;
255
256    fn c(high: f64, low: f64, close: f64, ts: i64) -> Candle {
257        Candle::new_unchecked(close, high, low, close, 0.0, ts)
258    }
259
260    #[test]
261    fn uptrend_sets_sell_risk_above_highest_high_of_setup() {
262        // Strictly rising closes -> sell setup completes at idx 12.
263        // The sell run starts at idx 4 (first bar that has close >
264        // close[i-4]). The highest high during the run is the bar at
265        // idx 12 (since the series is strictly increasing).
266        let candles: Vec<Candle> = (1..=20)
267            .map(|i| {
268                c(
269                    f64::from(i) + 0.5,
270                    f64::from(i) - 0.5,
271                    f64::from(i),
272                    i64::from(i),
273                )
274            })
275            .collect();
276        let mut td = TdRiskLevel::classic();
277        let out = td.batch(&candles);
278        let after = out[12].expect("ready");
279        assert!(after.buy_risk.is_nan());
280        // High at idx 12 is 13.5; the true range there is 1.0 (1.0 vs
281        // |13.5-12|=1.5 vs |12.5-12|=0.5 -> max=1.5). So sell_risk =
282        // 13.5 + 1.5 = 15.0.
283        assert_relative_eq!(after.sell_risk, 15.0, epsilon = 1e-12);
284    }
285
286    #[test]
287    fn flat_series_never_emits() {
288        // Neither setup advances, so no level ever exists and nothing is
289        // emitted. This used to yield `Some` with two NaNs on every bar.
290        let candles: Vec<Candle> = (0..30).map(|i| c(10.5, 9.5, 10.0, i64::from(i))).collect();
291        let mut td = TdRiskLevel::classic();
292        let out = td.batch(&candles);
293        assert!(out.iter().all(Option::is_none));
294        assert!(!td.is_ready());
295    }
296
297    #[test]
298    fn batch_equals_streaming() {
299        let candles: Vec<Candle> = (0..80)
300            .map(|i| {
301                let m = 100.0 + (f64::from(i) * 0.3).sin() * 5.0;
302                c(m + 1.0, m - 1.0, m, i64::from(i))
303            })
304            .collect();
305        let mut a = TdRiskLevel::classic();
306        let mut b = TdRiskLevel::classic();
307        let av = a.batch(&candles);
308        let bv: Vec<_> = candles.iter().map(|x| b.update(*x)).collect();
309        assert_eq!(av.len(), bv.len());
310        for (i, (x, y)) in av.iter().zip(bv.iter()).enumerate() {
311            assert_eq!(x.is_some(), y.is_some(), "row {i} option mismatch");
312            if let (Some(a), Some(b)) = (x, y) {
313                assert_eq!(a.buy_risk.is_nan(), b.buy_risk.is_nan());
314                assert_eq!(a.sell_risk.is_nan(), b.sell_risk.is_nan());
315                if !a.buy_risk.is_nan() {
316                    assert_relative_eq!(a.buy_risk, b.buy_risk, epsilon = 1e-12);
317                }
318                if !a.sell_risk.is_nan() {
319                    assert_relative_eq!(a.sell_risk, b.sell_risk, epsilon = 1e-12);
320                }
321            }
322        }
323    }
324
325    #[test]
326    fn rejects_invalid_params() {
327        assert!(matches!(TdRiskLevel::new(0, 9), Err(Error::PeriodZero)));
328        assert!(matches!(TdRiskLevel::new(4, 0), Err(Error::PeriodZero)));
329    }
330
331    #[test]
332    fn reset_clears_state() {
333        let candles: Vec<Candle> = (1..=20)
334            .map(|i| {
335                c(
336                    f64::from(i) + 0.5,
337                    f64::from(i) - 0.5,
338                    f64::from(i),
339                    i64::from(i),
340                )
341            })
342            .collect();
343        let mut td = TdRiskLevel::classic();
344        td.batch(&candles);
345        assert!(td.is_ready());
346        td.reset();
347        assert!(!td.is_ready());
348        assert_eq!(td.update(candles[0]), None);
349    }
350
351    #[test]
352    fn accessors_and_metadata() {
353        let td = TdRiskLevel::classic();
354        assert_eq!(td.params(), (4, 9));
355        assert_eq!(td.warmup_period(), 5);
356        assert_eq!(td.name(), "TDRiskLevel");
357    }
358}