1#![allow(clippy::manual_clamp)]
3
4use std::f64::consts::PI;
5
6use crate::indicators::dc_phasor::{self, MAX_DC_PERIOD};
7use crate::traits::Indicator;
8
9#[derive(Debug, Clone, Default)]
38pub struct HtTrendMode {
39 price_buf: Vec<f64>,
41 smooth_buf: Vec<f64>,
43 detrender_buf: Vec<f64>,
44 q1_buf: Vec<f64>,
45 i1_buf: Vec<f64>,
46 smooth_price: Vec<f64>,
47 raw_price: Vec<f64>,
50 prev_i2: f64,
51 prev_q2: f64,
52 prev_re: f64,
53 prev_im: f64,
54 prev_period: f64,
55 prev_smooth_period: f64,
56 prev_dc_phase: f64,
58 prev_sine: f64,
59 prev_lead_sine: f64,
60 days_in_trend: f64,
61 it1: f64,
62 it2: f64,
63 it3: f64,
64 count: usize,
65 last_value: Option<f64>,
66}
67
68impl HtTrendMode {
69 pub fn new() -> Self {
71 Self::default()
72 }
73
74 pub const fn value(&self) -> Option<f64> {
76 self.last_value
77 }
78
79 fn push_front(buf: &mut Vec<f64>, v: f64, cap: usize) {
80 buf.insert(0, v);
81 if buf.len() > cap {
82 buf.truncate(cap);
83 }
84 }
85}
86
87impl Indicator for HtTrendMode {
88 type Input = f64;
89 type Output = f64;
90
91 #[allow(clippy::too_many_lines)]
92 fn update(&mut self, input: f64) -> Option<f64> {
93 if !input.is_finite() {
94 return None;
95 }
96 self.count += 1;
97
98 Self::push_front(&mut self.raw_price, input, MAX_DC_PERIOD);
99 Self::push_front(&mut self.price_buf, input, 4);
100 if self.price_buf.len() < 4 {
101 return None;
102 }
103 let smooth = (4.0 * self.price_buf[0]
104 + 3.0 * self.price_buf[1]
105 + 2.0 * self.price_buf[2]
106 + self.price_buf[3])
107 / 10.0;
108 Self::push_front(&mut self.smooth_buf, smooth, 7);
109 Self::push_front(&mut self.smooth_price, smooth, MAX_DC_PERIOD);
110
111 let period = self.prev_period.max(6.0).min(50.0);
112 let adj = 0.075 * period + 0.54;
113
114 if self.smooth_buf.len() < 7 {
115 return None;
116 }
117 let s0 = smooth;
118 let s2 = self.smooth_buf[2];
119 let s4 = self.smooth_buf[4];
120 let s6 = self.smooth_buf[6];
121 let detrender = (0.0962 * s0 + 0.5769 * s2 - 0.5769 * s4 - 0.0962 * s6) * adj;
122 Self::push_front(&mut self.detrender_buf, detrender, 7);
123 if self.detrender_buf.len() < 7 {
124 return None;
125 }
126
127 let q1 = (0.0962 * self.detrender_buf[0] + 0.5769 * self.detrender_buf[2]
128 - 0.5769 * self.detrender_buf[4]
129 - 0.0962 * self.detrender_buf[6])
130 * adj;
131 let i1 = self.detrender_buf[3];
132
133 Self::push_front(&mut self.q1_buf, q1, 7);
134 Self::push_front(&mut self.i1_buf, i1, 7);
135 if self.q1_buf.len() < 7 || self.i1_buf.len() < 7 {
136 return None;
137 }
138
139 let ji = (0.0962 * self.i1_buf[0] + 0.5769 * self.i1_buf[2]
140 - 0.5769 * self.i1_buf[4]
141 - 0.0962 * self.i1_buf[6])
142 * adj;
143 let jq = (0.0962 * self.q1_buf[0] + 0.5769 * self.q1_buf[2]
144 - 0.5769 * self.q1_buf[4]
145 - 0.0962 * self.q1_buf[6])
146 * adj;
147
148 let mut i2 = i1 - jq;
149 let mut q2 = q1 + ji;
150 i2 = 0.2 * i2 + 0.8 * self.prev_i2;
151 q2 = 0.2 * q2 + 0.8 * self.prev_q2;
152
153 let mut re = i2 * self.prev_i2 + q2 * self.prev_q2;
154 let mut im = i2 * self.prev_q2 - q2 * self.prev_i2;
155 re = 0.2 * re + 0.8 * self.prev_re;
156 im = 0.2 * im + 0.8 * self.prev_im;
157
158 self.prev_i2 = i2;
159 self.prev_q2 = q2;
160 self.prev_re = re;
161 self.prev_im = im;
162
163 let mut new_period = if im.abs() > f64::EPSILON && re.abs() > f64::EPSILON {
164 2.0 * PI / im.atan2(re)
165 } else {
166 self.prev_period
167 };
168 new_period = new_period.min(1.5 * self.prev_period);
169 new_period = new_period.max(0.67 * self.prev_period);
170 new_period = new_period.clamp(6.0, 50.0);
171 self.prev_period = 0.2 * new_period + 0.8 * self.prev_period;
172 self.prev_smooth_period = 0.33 * self.prev_period + 0.67 * self.prev_smooth_period;
173
174 let smooth_period = self.prev_smooth_period;
175 let dc_period = ((smooth_period + 0.5) as usize).clamp(1, self.smooth_price.len());
176
177 let mut real_part = 0.0;
179 let mut imag_part = 0.0;
180 for (&(sin, cos), &sp) in dc_phasor::phasor(dc_period).iter().zip(&self.smooth_price) {
181 real_part += sin * sp;
182 imag_part += cos * sp;
183 }
184 let dc_phase = compute_dc_phase(real_part, imag_part, smooth_period);
185
186 let sine = (dc_phase * PI / 180.0).sin();
187 let lead_sine = ((dc_phase + 45.0) * PI / 180.0).sin();
188
189 let mut trend_sum = 0.0;
192 for &price in &self.raw_price[..dc_period] {
193 trend_sum += price;
194 }
195 trend_sum /= dc_period as f64;
196 let trendline = (4.0 * trend_sum + 3.0 * self.it1 + 2.0 * self.it2 + self.it3) / 10.0;
197 self.it3 = self.it2;
198 self.it2 = self.it1;
199 self.it1 = trend_sum;
200
201 let mut trend = 1.0_f64;
203
204 if (sine > lead_sine && self.prev_sine <= self.prev_lead_sine)
206 || (sine < lead_sine && self.prev_sine >= self.prev_lead_sine)
207 {
208 self.days_in_trend = 0.0;
209 trend = 0.0;
210 }
211 self.days_in_trend += 1.0;
212 if self.days_in_trend < 0.5 * smooth_period {
213 trend = 0.0;
214 }
215
216 let delta_phase = dc_phase - self.prev_dc_phase;
218 if smooth_period != 0.0
219 && delta_phase > 0.67 * 360.0 / smooth_period
220 && delta_phase < 1.5 * 360.0 / smooth_period
221 {
222 trend = 0.0;
223 }
224
225 if trendline != 0.0 && ((smooth - trendline) / trendline).abs() >= 0.015 {
227 trend = 1.0;
228 }
229
230 self.prev_dc_phase = dc_phase;
231 self.prev_sine = sine;
232 self.prev_lead_sine = lead_sine;
233
234 if self.count < 50 {
235 return None;
236 }
237 self.last_value = Some(trend);
238 Some(trend)
239 }
240
241 fn reset(&mut self) {
242 self.price_buf.clear();
243 self.smooth_buf.clear();
244 self.detrender_buf.clear();
245 self.q1_buf.clear();
246 self.i1_buf.clear();
247 self.smooth_price.clear();
248 self.raw_price.clear();
249 self.prev_i2 = 0.0;
250 self.prev_q2 = 0.0;
251 self.prev_re = 0.0;
252 self.prev_im = 0.0;
253 self.prev_period = 0.0;
254 self.prev_smooth_period = 0.0;
255 self.prev_dc_phase = 0.0;
256 self.prev_sine = 0.0;
257 self.prev_lead_sine = 0.0;
258 self.days_in_trend = 0.0;
259 self.it1 = 0.0;
260 self.it2 = 0.0;
261 self.it3 = 0.0;
262 self.count = 0;
263 self.last_value = None;
264 }
265
266 #[inline]
267 fn warmup_period(&self) -> usize {
268 50
269 }
270
271 #[inline]
272 fn is_ready(&self) -> bool {
273 self.last_value.is_some()
274 }
275
276 #[inline]
277 fn name(&self) -> &'static str {
278 "HT_TRENDMODE"
279 }
280}
281
282fn compute_dc_phase(real_part: f64, imag_part: f64, smooth_period: f64) -> f64 {
289 let mut dc_phase = if imag_part.abs() > 0.001 {
290 (real_part / imag_part).atan().to_degrees()
291 } else if real_part < 0.0 {
292 -90.0
293 } else {
294 90.0
295 };
296 dc_phase += 90.0;
297 dc_phase += 360.0 / smooth_period;
298 if imag_part < 0.0 {
299 dc_phase += 180.0;
300 }
301 if dc_phase > 315.0 {
302 dc_phase -= 360.0;
303 }
304 dc_phase
305}
306
307#[cfg(test)]
308mod tests {
309 use super::*;
310 use crate::traits::BatchExt;
311
312 fn mixed_prices() -> Vec<f64> {
314 let mut v = Vec::new();
315 for i in 0..150 {
316 v.push(100.0 + f64::from(i) * 0.8);
317 }
318 for i in 0..200 {
319 v.push(220.0 + (f64::from(i) * 0.45).sin() * 12.0);
320 }
321 v
322 }
323
324 #[test]
325 fn accessors_and_metadata() {
326 let ht = HtTrendMode::new();
327 assert_eq!(ht.warmup_period(), 50);
328 assert_eq!(ht.name(), "HT_TRENDMODE");
329 assert!(!ht.is_ready());
330 assert!(ht.value().is_none());
331 }
332
333 #[test]
334 fn near_zero_imaginary_collapses_to_signed_ninety() {
335 let pos = compute_dc_phase(1.0, 0.0, 20.0);
339 let neg = compute_dc_phase(-1.0, 0.0, 20.0);
340 assert!((pos - 198.0).abs() < 1e-9);
341 assert!((neg - 18.0).abs() < 1e-9);
342 let mid = compute_dc_phase(1.0, 1.0, 20.0);
344 assert!((mid - 153.0).abs() < 1e-9);
345 }
346
347 #[test]
348 fn emits_binary_flag_and_visits_both_modes() {
349 let mut ht = HtTrendMode::new();
350 let out: Vec<Option<f64>> = ht.batch(&mixed_prices());
351 assert_eq!(out[0], None);
352 assert!(ht.is_ready());
353 let mut saw_trend = false;
354 let mut saw_cycle = false;
355 for v in out.into_iter().flatten() {
356 assert!(v == 0.0 || v == 1.0, "trend mode must be binary, got {v}");
357 if v == 1.0 {
358 saw_trend = true;
359 } else {
360 saw_cycle = true;
361 }
362 }
363 assert!(saw_trend, "ramp segment should report trend mode");
364 assert!(saw_cycle, "cycle segment should report cycle mode");
365 }
366
367 #[test]
368 fn ignores_non_finite_input() {
369 let mut ht = HtTrendMode::new();
370 let _ = ht.batch(&mixed_prices());
371 let before = ht.value();
372 assert_eq!(ht.update(f64::NAN), None);
373 assert_eq!(ht.value(), before);
375 }
376
377 #[test]
378 fn batch_equals_streaming() {
379 let prices = mixed_prices();
380 let mut a = HtTrendMode::new();
381 let mut b = HtTrendMode::new();
382 let batch = a.batch(&prices);
383 let streamed: Vec<_> = prices.iter().map(|p| b.update(*p)).collect();
384 assert_eq!(batch, streamed);
385 }
386
387 #[test]
388 fn reset_clears_state() {
389 let mut ht = HtTrendMode::new();
390 let _ = ht.batch(&mixed_prices());
391 assert!(ht.is_ready());
392 ht.reset();
393 assert!(!ht.is_ready());
394 assert_eq!(ht.update(100.0), None);
395 }
396
397 use crate::traits::BatchNanExt;
398 use approx::assert_relative_eq;
399
400 fn sine_prices(n: u32) -> Vec<f64> {
401 (0..n)
402 .map(|i| 100.0 + (f64::from(i) * 0.4).sin() * 5.0)
403 .collect()
404 }
405
406 #[test]
407 fn first_value_lands_exactly_at_warmup() {
408 let mut ht = HtTrendMode::new();
409 let out = ht.batch(&mixed_prices());
410 let warmup = ht.warmup_period();
411 assert!(out[..warmup - 1].iter().all(Option::is_none));
412 assert!(out[warmup - 1].is_some());
413 }
414
415 #[test]
416 fn reset_replays_identically() {
417 let prices = mixed_prices();
418 let fresh = HtTrendMode::new().batch(&prices);
419 let mut ht = HtTrendMode::new();
420 let first = ht.batch(&prices);
421 ht.reset();
422 let second = ht.batch(&prices);
423 assert_eq!(first, fresh);
424 assert_eq!(second, fresh);
425 }
426
427 #[test]
428 fn batch_nan_paths_match_streaming_bitwise() {
429 let prices = mixed_prices();
430 let mut out = vec![0.0; prices.len()];
431 HtTrendMode::new().batch_nan_into(&prices, &mut out);
432 let nan = HtTrendMode::new().batch_nan(&prices);
433 let fast = HtTrendMode::new().batch_fast(&prices);
434 let mut stream = HtTrendMode::new();
435 let expected: Vec<u64> = prices
436 .iter()
437 .map(|&p| stream.update(p).unwrap_or(f64::NAN).to_bits())
438 .collect();
439 assert!(out.iter().zip(&expected).all(|(v, e)| v.to_bits() == *e));
440 assert!(nan.iter().zip(&expected).all(|(v, e)| v.to_bits() == *e));
441 assert!(fast.iter().zip(&expected).all(|(v, e)| v.to_bits() == *e));
442 }
443
444 #[test]
445 fn wma_of_raw_inputs_feeds_detrender_taps() {
446 let mut ht = HtTrendMode::new();
447 for p in [10.0, 20.0, 30.0, 40.0] {
450 assert_eq!(ht.update(p), None);
451 }
452 assert_eq!(ht.smooth_buf, vec![30.0]);
453 assert_eq!(ht.raw_price, vec![40.0, 30.0, 20.0, 10.0]);
454
455 let mut ht = HtTrendMode::new();
460 let mut series = [0.0; 10];
461 series[7] = 10.0;
462 let _ = ht.batch(&series);
463 assert_eq!(ht.smooth_buf, vec![2.0, 3.0, 4.0, 0.0, 0.0, 0.0, 0.0]);
464 assert_eq!(ht.detrender_buf.len(), 1);
465 assert_relative_eq!(ht.detrender_buf[0], 2.475, epsilon = 1e-12);
466 }
467
468 #[test]
469 fn trendline_averages_raw_not_smoothed_price() {
470 let prices: Vec<f64> = (0..120).map(|k| 100.0 + f64::from(k)).collect();
475 let mut ht = HtTrendMode::new();
476 let _ = ht.batch(&prices);
477 let dc_period = ((ht.prev_smooth_period + 0.5) as usize).clamp(1, ht.smooth_price.len());
478 let n = prices.len();
479 let window = &prices[n - dc_period..];
480 let dc = dc_period as f64;
481 let raw_mean = window.iter().sum::<f64>() / dc;
482 let smooth_mean = ht.smooth_price[..dc_period].iter().sum::<f64>() / dc;
483 assert_relative_eq!(ht.it1, raw_mean, epsilon = 1e-9);
485 assert_relative_eq!(raw_mean - smooth_mean, 1.0, epsilon = 1e-9);
486 assert!(ht
488 .raw_price
489 .iter()
490 .zip(prices.iter().rev())
491 .all(|(a, b)| a.to_bits() == b.to_bits()));
492 assert_eq!(ht.raw_price.len(), MAX_DC_PERIOD);
493 }
494
495 #[test]
496 fn steady_ramp_reports_trend_and_clean_cycle_reports_cycle() {
497 let ramp: Vec<f64> = (0..200).map(|k| 100.0 * 1.02_f64.powi(k)).collect();
500 let mut ht = HtTrendMode::new();
501 let out = ht.batch(&ramp);
502 assert!(out[100..]
503 .iter()
504 .flatten()
505 .all(|v| v.to_bits() == 1.0_f64.to_bits()));
506 let mut ht = HtTrendMode::new();
508 let out = ht.batch(&sine_prices(300));
509 assert!(out
510 .iter()
511 .flatten()
512 .any(|v| v.to_bits() == 0.0_f64.to_bits()));
513 }
514
515 #[test]
516 fn zero_series_skips_trendline_separation_and_settles_in_trend() {
517 let mut ht = HtTrendMode::new();
521 let out = ht.batch(&[0.0; 200]);
522 assert_eq!(out.iter().flatten().count(), 200 - 49);
523 assert_eq!(ht.value(), Some(1.0));
524 }
525
526 #[test]
527 fn dc_phase_quadrant_and_wrap_hand_computed() {
528 assert_relative_eq!(compute_dc_phase(1.0, -1.0, 20.0), 243.0, epsilon = 1e-9);
531 assert_relative_eq!(compute_dc_phase(-1.0, -1.0, 20.0), -27.0, epsilon = 1e-9);
533 }
534}