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)]
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 buy_extreme: Option<ExtremeBar>,
78 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 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 pub fn classic() -> Self {
124 Self::new(4, 9).expect("classic TD Risk Level parameters are valid")
125 }
126
127 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 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 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 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 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}