wickra_core/indicators/
ht_trendmode.rs1#![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 smooth_buf: Vec<f64>,
40 detrender_buf: Vec<f64>,
41 q1_buf: Vec<f64>,
42 i1_buf: Vec<f64>,
43 smooth_price: Vec<f64>,
44 prev_i2: f64,
45 prev_q2: f64,
46 prev_re: f64,
47 prev_im: f64,
48 prev_period: f64,
49 prev_smooth_period: f64,
50 prev_dc_phase: f64,
52 prev_sine: f64,
53 prev_lead_sine: f64,
54 days_in_trend: f64,
55 it1: f64,
56 it2: f64,
57 it3: f64,
58 count: usize,
59 last_value: Option<f64>,
60}
61
62impl HtTrendMode {
63 pub fn new() -> Self {
65 Self::default()
66 }
67
68 pub const fn value(&self) -> Option<f64> {
70 self.last_value
71 }
72
73 fn push_front(buf: &mut Vec<f64>, v: f64, cap: usize) {
74 buf.insert(0, v);
75 if buf.len() > cap {
76 buf.truncate(cap);
77 }
78 }
79}
80
81impl Indicator for HtTrendMode {
82 type Input = f64;
83 type Output = f64;
84
85 #[allow(clippy::too_many_lines)]
86 fn update(&mut self, input: f64) -> Option<f64> {
87 if !input.is_finite() {
88 return None;
89 }
90 self.count += 1;
91
92 Self::push_front(&mut self.smooth_buf, input, 7);
93 if self.smooth_buf.len() < 7 {
94 return None;
95 }
96 let smooth = (4.0 * self.smooth_buf[0]
97 + 3.0 * self.smooth_buf[1]
98 + 2.0 * self.smooth_buf[2]
99 + self.smooth_buf[3])
100 / 10.0;
101 Self::push_front(&mut self.smooth_price, smooth, MAX_DC_PERIOD);
102
103 let period = self.prev_period.max(6.0).min(50.0);
104 let adj = 0.075 * period + 0.54;
105
106 let s0 = smooth;
107 let s2 = self.smooth_buf[2];
108 let s4 = self.smooth_buf[4];
109 let s6 = self.smooth_buf[6];
110 let detrender = (0.0962 * s0 + 0.5769 * s2 - 0.5769 * s4 - 0.0962 * s6) * adj;
111 Self::push_front(&mut self.detrender_buf, detrender, 7);
112 if self.detrender_buf.len() < 7 {
113 return None;
114 }
115
116 let q1 = (0.0962 * self.detrender_buf[0] + 0.5769 * self.detrender_buf[2]
117 - 0.5769 * self.detrender_buf[4]
118 - 0.0962 * self.detrender_buf[6])
119 * adj;
120 let i1 = self.detrender_buf[3];
121
122 Self::push_front(&mut self.q1_buf, q1, 7);
123 Self::push_front(&mut self.i1_buf, i1, 7);
124 if self.q1_buf.len() < 7 || self.i1_buf.len() < 7 {
125 return None;
126 }
127
128 let ji = (0.0962 * self.i1_buf[0] + 0.5769 * self.i1_buf[2]
129 - 0.5769 * self.i1_buf[4]
130 - 0.0962 * self.i1_buf[6])
131 * adj;
132 let jq = (0.0962 * self.q1_buf[0] + 0.5769 * self.q1_buf[2]
133 - 0.5769 * self.q1_buf[4]
134 - 0.0962 * self.q1_buf[6])
135 * adj;
136
137 let mut i2 = i1 - jq;
138 let mut q2 = q1 + ji;
139 i2 = 0.2 * i2 + 0.8 * self.prev_i2;
140 q2 = 0.2 * q2 + 0.8 * self.prev_q2;
141
142 let mut re = i2 * self.prev_i2 + q2 * self.prev_q2;
143 let mut im = i2 * self.prev_q2 - q2 * self.prev_i2;
144 re = 0.2 * re + 0.8 * self.prev_re;
145 im = 0.2 * im + 0.8 * self.prev_im;
146
147 self.prev_i2 = i2;
148 self.prev_q2 = q2;
149 self.prev_re = re;
150 self.prev_im = im;
151
152 let mut new_period = if im.abs() > f64::EPSILON && re.abs() > f64::EPSILON {
153 2.0 * PI / im.atan2(re)
154 } else {
155 self.prev_period
156 };
157 new_period = new_period.min(1.5 * self.prev_period);
158 new_period = new_period.max(0.67 * self.prev_period);
159 new_period = new_period.clamp(6.0, 50.0);
160 self.prev_period = 0.2 * new_period + 0.8 * self.prev_period;
161 self.prev_smooth_period = 0.33 * self.prev_period + 0.67 * self.prev_smooth_period;
162
163 let smooth_period = self.prev_smooth_period;
164 let dc_period = ((smooth_period + 0.5) as usize).clamp(1, self.smooth_price.len());
165
166 let mut real_part = 0.0;
168 let mut imag_part = 0.0;
169 for (&(sin, cos), &sp) in dc_phasor::phasor(dc_period).iter().zip(&self.smooth_price) {
170 real_part += sin * sp;
171 imag_part += cos * sp;
172 }
173 let dc_phase = compute_dc_phase(real_part, imag_part, smooth_period);
174
175 let sine = (dc_phase * PI / 180.0).sin();
176 let lead_sine = ((dc_phase + 45.0) * PI / 180.0).sin();
177
178 let mut trend_sum = 0.0;
181 for i in 0..dc_period {
182 trend_sum += self.smooth_price[i];
183 }
184 trend_sum /= dc_period as f64;
185 let trendline = (4.0 * trend_sum + 3.0 * self.it1 + 2.0 * self.it2 + self.it3) / 10.0;
186 self.it3 = self.it2;
187 self.it2 = self.it1;
188 self.it1 = trend_sum;
189
190 let mut trend = 1.0_f64;
192
193 if (sine > lead_sine && self.prev_sine <= self.prev_lead_sine)
195 || (sine < lead_sine && self.prev_sine >= self.prev_lead_sine)
196 {
197 self.days_in_trend = 0.0;
198 trend = 0.0;
199 }
200 self.days_in_trend += 1.0;
201 if self.days_in_trend < 0.5 * smooth_period {
202 trend = 0.0;
203 }
204
205 let delta_phase = dc_phase - self.prev_dc_phase;
207 if smooth_period != 0.0
208 && delta_phase > 0.67 * 360.0 / smooth_period
209 && delta_phase < 1.5 * 360.0 / smooth_period
210 {
211 trend = 0.0;
212 }
213
214 if trendline != 0.0 && ((smooth - trendline) / trendline).abs() >= 0.015 {
216 trend = 1.0;
217 }
218
219 self.prev_dc_phase = dc_phase;
220 self.prev_sine = sine;
221 self.prev_lead_sine = lead_sine;
222
223 if self.count < 50 {
224 return None;
225 }
226 self.last_value = Some(trend);
227 Some(trend)
228 }
229
230 fn reset(&mut self) {
231 self.smooth_buf.clear();
232 self.detrender_buf.clear();
233 self.q1_buf.clear();
234 self.i1_buf.clear();
235 self.smooth_price.clear();
236 self.prev_i2 = 0.0;
237 self.prev_q2 = 0.0;
238 self.prev_re = 0.0;
239 self.prev_im = 0.0;
240 self.prev_period = 0.0;
241 self.prev_smooth_period = 0.0;
242 self.prev_dc_phase = 0.0;
243 self.prev_sine = 0.0;
244 self.prev_lead_sine = 0.0;
245 self.days_in_trend = 0.0;
246 self.it1 = 0.0;
247 self.it2 = 0.0;
248 self.it3 = 0.0;
249 self.count = 0;
250 self.last_value = None;
251 }
252
253 #[inline]
254 fn warmup_period(&self) -> usize {
255 50
256 }
257
258 #[inline]
259 fn is_ready(&self) -> bool {
260 self.last_value.is_some()
261 }
262
263 #[inline]
264 fn name(&self) -> &'static str {
265 "HT_TRENDMODE"
266 }
267}
268
269fn compute_dc_phase(real_part: f64, imag_part: f64, smooth_period: f64) -> f64 {
276 let mut dc_phase = if imag_part.abs() > 0.001 {
277 (real_part / imag_part).atan().to_degrees()
278 } else if real_part < 0.0 {
279 -90.0
280 } else {
281 90.0
282 };
283 dc_phase += 90.0;
284 dc_phase += 360.0 / smooth_period;
285 if imag_part < 0.0 {
286 dc_phase += 180.0;
287 }
288 if dc_phase > 315.0 {
289 dc_phase -= 360.0;
290 }
291 dc_phase
292}
293
294#[cfg(test)]
295mod tests {
296 use super::*;
297 use crate::traits::BatchExt;
298
299 fn mixed_prices() -> Vec<f64> {
301 let mut v = Vec::new();
302 for i in 0..150 {
303 v.push(100.0 + f64::from(i) * 0.8);
304 }
305 for i in 0..200 {
306 v.push(220.0 + (f64::from(i) * 0.45).sin() * 12.0);
307 }
308 v
309 }
310
311 #[test]
312 fn accessors_and_metadata() {
313 let ht = HtTrendMode::new();
314 assert_eq!(ht.warmup_period(), 50);
315 assert_eq!(ht.name(), "HT_TRENDMODE");
316 assert!(!ht.is_ready());
317 assert!(ht.value().is_none());
318 }
319
320 #[test]
321 fn near_zero_imaginary_collapses_to_signed_ninety() {
322 let pos = compute_dc_phase(1.0, 0.0, 20.0);
326 let neg = compute_dc_phase(-1.0, 0.0, 20.0);
327 assert!((pos - 198.0).abs() < 1e-9);
328 assert!((neg - 18.0).abs() < 1e-9);
329 let mid = compute_dc_phase(1.0, 1.0, 20.0);
331 assert!((mid - 153.0).abs() < 1e-9);
332 }
333
334 #[test]
335 fn emits_binary_flag_and_visits_both_modes() {
336 let mut ht = HtTrendMode::new();
337 let out: Vec<Option<f64>> = ht.batch(&mixed_prices());
338 assert_eq!(out[0], None);
339 assert!(ht.is_ready());
340 let mut saw_trend = false;
341 let mut saw_cycle = false;
342 for v in out.into_iter().flatten() {
343 assert!(v == 0.0 || v == 1.0, "trend mode must be binary, got {v}");
344 if v == 1.0 {
345 saw_trend = true;
346 } else {
347 saw_cycle = true;
348 }
349 }
350 assert!(saw_trend, "ramp segment should report trend mode");
351 assert!(saw_cycle, "cycle segment should report cycle mode");
352 }
353
354 #[test]
355 fn ignores_non_finite_input() {
356 let mut ht = HtTrendMode::new();
357 let _ = ht.batch(&mixed_prices());
358 let before = ht.value();
359 assert_eq!(ht.update(f64::NAN), None);
360 assert_eq!(ht.value(), before);
362 }
363
364 #[test]
365 fn batch_equals_streaming() {
366 let prices = mixed_prices();
367 let mut a = HtTrendMode::new();
368 let mut b = HtTrendMode::new();
369 let batch = a.batch(&prices);
370 let streamed: Vec<_> = prices.iter().map(|p| b.update(*p)).collect();
371 assert_eq!(batch, streamed);
372 }
373
374 #[test]
375 fn reset_clears_state() {
376 let mut ht = HtTrendMode::new();
377 let _ = ht.batch(&mixed_prices());
378 assert!(ht.is_ready());
379 ht.reset();
380 assert!(!ht.is_ready());
381 assert_eq!(ht.update(100.0), None);
382 }
383}