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