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,
39 pub sell_risk: f64,
42}
43
44#[derive(Debug, Clone, Copy)]
47struct ExtremeBar {
48 price: f64,
49 true_range: f64,
50}
51
52#[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 buy_extreme: Option<ExtremeBar>,
63 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 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 pub fn classic() -> Self {
109 Self::new(4, 9).expect("classic TD Risk Level parameters are valid")
110 }
111
112 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 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 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 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 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}