1#![allow(clippy::doc_markdown)]
2
3use std::collections::VecDeque;
25
26use crate::error::{Error, Result};
27use crate::ohlcv::Candle;
28use crate::traits::Indicator;
29
30#[derive(Debug, Clone, Copy, PartialEq)]
35pub struct TdRiskLevelOutput {
36 pub buy_risk: f64,
45 pub sell_risk: f64,
50}
51
52#[derive(Debug, Clone, Copy)]
55struct ExtremeBar {
56 price: f64,
57 true_range: f64,
58}
59
60#[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 buy_extreme: Option<ExtremeBar>,
86 sell_extreme: Option<ExtremeBar>,
88 buy_risk: Option<f64>,
92 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 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 pub fn classic() -> Self {
136 Self::new(4, 9).expect("classic TD Risk Level parameters are valid")
137 }
138
139 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 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 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 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 #[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 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 assert_relative_eq!(after.sell_risk, 15.0, epsilon = 1e-12);
284 }
285
286 #[test]
287 fn flat_series_never_emits() {
288 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}