Skip to main content

indicators/signal/
engine.rs

1//! Core Indicators Engine — Layers 1–4, 9–11.
2//!
3//! Faithful port of the Python `Indicators` class from `indicators.py`.
4//!
5//! Layers:
6//! - **L1** VWAP (daily reset)
7//! - **L2** EMA (configurable period)
8//! - **L3** ML SuperTrend — KMeans-adaptive ATR multiplier
9//! - **L4** Trend Speed — dynamic EMA + RMA wave tracking + HMA
10//! - **L9** Awesome Oscillator + wave/momentum percentile gates
11//! - **L10** Hurst exponent (R/S analysis, recomputed every 10 bars)
12//! - **L11** Price acceleration (2nd derivative, normalised)
13
14use std::collections::{HashMap, VecDeque};
15
16use chrono::{NaiveDate, TimeZone, Utc};
17
18// Safely importing from your unified config file
19use crate::error::IndicatorError;
20use crate::indicator::{Indicator, IndicatorOutput};
21use crate::indicator_config::IndicatorConfig;
22use crate::registry::param_usize;
23use crate::signal::vol_regime::PercentileTracker;
24use crate::types::Candle;
25
26// ── Indicator wrapper ─────────────────────────────────────────────────────────
27
28/// Batch `Indicator` adapter for [`Indicators`].
29///
30/// Replays candles through the full engine (L1–L4, L9–L11) and emits per-bar
31/// columns for every published field.
32#[derive(Debug, Clone)]
33pub struct EngineIndicator {
34    pub config: IndicatorConfig,
35}
36
37impl EngineIndicator {
38    pub fn new(config: IndicatorConfig) -> Self {
39        Self { config }
40    }
41    pub fn with_defaults() -> Self {
42        Self::new(IndicatorConfig::default())
43    }
44}
45
46impl Indicator for EngineIndicator {
47    fn name(&self) -> &'static str {
48        "Engine"
49    }
50    fn required_len(&self) -> usize {
51        self.config.training_period
52    }
53    fn required_columns(&self) -> &[&'static str] {
54        &["open", "high", "low", "close", "volume"]
55    }
56
57    fn calculate(&self, candles: &[Candle]) -> Result<IndicatorOutput, IndicatorError> {
58        self.check_len(candles)?;
59        let mut ind = Indicators::new(self.config.clone());
60        let n = candles.len();
61        let mut vwap_out = vec![f64::NAN; n];
62        let mut ema_out = vec![f64::NAN; n];
63        let mut st_out = vec![f64::NAN; n];
64        let mut st_dir_out = vec![f64::NAN; n];
65        let mut ts_norm_out = vec![f64::NAN; n];
66        let mut ts_bullish_out = vec![f64::NAN; n];
67        let mut hurst_out = vec![f64::NAN; n];
68        let mut accel_out = vec![f64::NAN; n];
69        let mut ao_out = vec![f64::NAN; n];
70        let mut dominance_out = vec![f64::NAN; n];
71        for (i, c) in candles.iter().enumerate() {
72            ind.update(c);
73            vwap_out[i] = ind.vwap.unwrap_or(f64::NAN);
74            ema_out[i] = ind.ema.unwrap_or(f64::NAN);
75            st_out[i] = ind.st.unwrap_or(f64::NAN);
76            st_dir_out[i] = ind.st_dir_pub as f64;
77            ts_norm_out[i] = ind.ts_norm;
78            ts_bullish_out[i] = if ind.ts_bullish { 1.0 } else { 0.0 };
79            hurst_out[i] = ind.hurst;
80            accel_out[i] = ind.price_accel;
81            ao_out[i] = ind.ao;
82            dominance_out[i] = ind.dominance;
83        }
84        Ok(IndicatorOutput::from_pairs([
85            ("engine_vwap", vwap_out),
86            ("engine_ema", ema_out),
87            ("engine_st", st_out),
88            ("engine_st_dir", st_dir_out),
89            ("engine_ts_norm", ts_norm_out),
90            ("engine_ts_bullish", ts_bullish_out),
91            ("engine_hurst", hurst_out),
92            ("engine_accel", accel_out),
93            ("engine_ao", ao_out),
94            ("engine_dominance", dominance_out),
95        ]))
96    }
97}
98
99// ── Registry factory ──────────────────────────────────────────────────────────
100
101pub fn factory<S: ::std::hash::BuildHasher>(
102    params: &HashMap<String, String, S>,
103) -> Result<Box<dyn Indicator>, IndicatorError> {
104    let training_period = param_usize(params, "training_period", 100)?;
105    let ema_len = param_usize(params, "ema_len", 9)?;
106    let atr_len = param_usize(params, "atr_len", 10)?;
107    let config = IndicatorConfig {
108        ema_len,
109        atr_len,
110        training_period,
111        ..IndicatorConfig::default()
112    };
113    Ok(Box::new(EngineIndicator::new(config)))
114}
115
116// ── Helpers ───────────────────────────────────────────────────────────────────
117
118#[inline]
119fn rma_step(prev: Option<f64>, val: f64, len: usize) -> f64 {
120    let k = 1.0 / len as f64;
121    prev.map_or(val, |p| val * k + p * (1.0 - k))
122}
123
124fn wma(arr: &[f64]) -> f64 {
125    if arr.is_empty() {
126        return 0.0;
127    }
128    let n = arr.len() as f64;
129    let weights_sum = n * (n + 1.0) / 2.0;
130    arr.iter()
131        .enumerate()
132        .map(|(i, &v)| v * (i as f64 + 1.0))
133        .sum::<f64>()
134        / weights_sum
135}
136
137/// R/S Hurst exponent for a single window of closes.
138fn hurst_scalar(closes: &[f64], max_lag: usize) -> f64 {
139    let n = closes.len();
140    if n < max_lag * 2 + 1 {
141        return 0.5;
142    }
143    let mut log_lags: Vec<f64> = Vec::new();
144    let mut log_rs: Vec<f64> = Vec::new();
145
146    for lag in 2..=max_lag {
147        let chunks = n / lag;
148        if chunks < 1 {
149            continue;
150        }
151        let mut rs_vals: Vec<f64> = Vec::new();
152        for ci in 0..chunks {
153            let chunk = &closes[ci * lag..(ci + 1) * lag];
154            if chunk.len() < 2 {
155                continue;
156            }
157            let _mean = chunk.iter().sum::<f64>() / chunk.len() as f64;
158            let rets: Vec<f64> = chunk.windows(2).map(|w| w[1] - w[0]).collect();
159            let ret_mean = rets.iter().sum::<f64>() / rets.len() as f64;
160            let devs: Vec<f64> = {
161                let mut cum = 0.0;
162                rets.iter()
163                    .map(|&r| {
164                        cum += r - ret_mean;
165                        cum
166                    })
167                    .collect()
168            };
169            let r = devs.iter().copied().fold(f64::NEG_INFINITY, f64::max)
170                - devs.iter().copied().fold(f64::INFINITY, f64::min);
171            let ddof = rets.len() as f64 - 1.0;
172            let s = if ddof > 0.0 {
173                let var = rets.iter().map(|&x| (x - ret_mean).powi(2)).sum::<f64>() / ddof;
174                var.sqrt()
175            } else {
176                0.0
177            };
178            if s > 1e-12 {
179                rs_vals.push(r / s);
180            }
181        }
182        if !rs_vals.is_empty() {
183            log_lags.push((lag as f64).ln());
184            log_rs.push(rs_vals.iter().sum::<f64>().ln() - (rs_vals.len() as f64).ln());
185        }
186    }
187
188    if log_lags.len() < 3 {
189        return 0.5;
190    }
191    let n = log_lags.len() as f64;
192    let mx = log_lags.iter().sum::<f64>() / n;
193    let my = log_rs.iter().sum::<f64>() / n;
194    let num: f64 = log_lags
195        .iter()
196        .zip(log_rs.iter())
197        .map(|(&x, &y)| (x - mx) * (y - my))
198        .sum();
199    let den: f64 = log_lags.iter().map(|&x| (x - mx).powi(2)).sum();
200    if den < 1e-12 {
201        return 0.5;
202    }
203    (num / den).clamp(0.0, 1.0)
204}
205
206// ── Indicators ────────────────────────────────────────────────────────────────
207
208/// Full indicator engine (Layers 1–4, 9–11).
209///
210/// Call [`Indicators::update`] once per closed candle.
211/// After `training_period` candles, [`Indicators::st`] and related fields become `Some`.
212pub struct Indicators {
213    cfg: IndicatorConfig,
214    maxlen: usize,
215
216    pub opens: VecDeque<f64>,
217    pub highs: VecDeque<f64>,
218    pub lows: VecDeque<f64>,
219    pub closes: VecDeque<f64>,
220    pub volumes: VecDeque<f64>,
221    pub times: VecDeque<i64>,
222    bar: usize,
223
224    // L1 VWAP
225    vwap_vol: f64,
226    vwap_tpv: f64,
227    vwap_date: Option<NaiveDate>,
228
229    // L2 EMA
230    ema9: Option<f64>,
231
232    // L3 SuperTrend
233    rma_atr: Option<f64>,
234    st_upper: Option<f64>,
235    st_lower: Option<f64>,
236    st_dir: i8,
237    st_value: Option<f64>,
238    kmeans_centroids: Option<[f64; 3]>,
239    kmeans_last_bar: usize,
240
241    // L4 TrendSpeed
242    dyn_ema: Option<f64>,
243    prev_close: Option<f64>,
244    max_abs_buf: VecDeque<f64>,
245    delta_buf: VecDeque<f64>,
246    rma_c: Option<f64>,
247    rma_o: Option<f64>,
248    wave_speed: f64,
249    wave_pos: i8,
250    speed_norm: VecDeque<f64>,
251    hma_buf: VecDeque<f64>,
252    bull_waves: VecDeque<f64>,
253    bear_waves: VecDeque<f64>,
254    wr_tracker: PercentileTracker,
255    mom_tracker: PercentileTracker,
256    cur_ratio: f64,
257
258    // L10 Hurst
259    hurst_last_bar: usize,
260    /// Close price at the last Hurst recompute — used to skip redundant R/S runs.
261    hurst_last_close: f64,
262
263    // L11 Price acceleration
264    vel_buf: VecDeque<f64>,
265
266    // ── Published fields ─────────────────────────────────────────────────────
267    /// Layer 1 — intraday VWAP, resets at UTC midnight.
268    pub vwap: Option<f64>,
269    /// Layer 2 — EMA of configurable period.
270    pub ema: Option<f64>,
271    /// Layer 3 — SuperTrend line value.
272    pub st: Option<f64>,
273    /// Layer 3 — SuperTrend direction: `-1` = bullish (price above ST), `+1` = bearish.
274    pub st_dir_pub: i8,
275    /// Layer 3 — RMA ATR used for SuperTrend.
276    pub atr: Option<f64>,
277    /// Layer 3 — KMeans cluster index (0 = high vol, 1 = mid, 2 = low vol).
278    pub cluster: usize,
279    /// Layer 4 — dynamic EMA.
280    pub dyn_ema_pub: Option<f64>,
281    /// Layer 4 — HMA-smoothed wave speed.
282    pub ts_speed: f64,
283    /// Layer 4 — wave speed normalised 0–1.
284    pub ts_norm: f64,
285    /// Layer 4 — true when wave speed is positive.
286    pub ts_bullish: bool,
287    /// Layer 4 — average bull wave magnitude.
288    pub bull_avg: f64,
289    /// Layer 4 — average bear wave magnitude.
290    pub bear_avg: f64,
291    /// Layer 4 — bull_avg - |bear_avg|.
292    pub dominance: f64,
293    /// Layer 9 — Awesome Oscillator value.
294    pub ao: f64,
295    /// Layer 9 — true when AO is rising.
296    pub ao_rising: bool,
297    /// Layer 9 — wave ratio percentile.
298    pub wr_pct: f64,
299    /// Layer 9 — momentum percentile.
300    pub mom_pct: f64,
301    pub wave_ok_long: bool,
302    pub wave_ok_short: bool,
303    pub mom_ok_long: bool,
304    pub mom_ok_short: bool,
305    /// Layer 10 — Hurst exponent (0.5 = random, >0.52 = trending).
306    pub hurst: f64,
307    /// Layer 11 — normalised price acceleration (−1 to +1).
308    pub price_accel: f64,
309}
310
311impl Indicators {
312    pub fn new(cfg: IndicatorConfig) -> Self {
313        let maxlen = cfg.history_candles.max(cfg.training_period + 50).max(300);
314        let ts_collen = cfg.ts_collen;
315        let ts_lookback = cfg.ts_lookback;
316
317        let mut wr_tracker = PercentileTracker::new(200);
318        for i in 0..100 {
319            wr_tracker.push(if i % 2 == 0 { 0.5 } else { 2.0 });
320        }
321
322        Self {
323            cfg,
324            maxlen,
325            opens: VecDeque::with_capacity(maxlen),
326            highs: VecDeque::with_capacity(maxlen),
327            lows: VecDeque::with_capacity(maxlen),
328            closes: VecDeque::with_capacity(maxlen),
329            volumes: VecDeque::with_capacity(maxlen),
330            times: VecDeque::with_capacity(maxlen),
331            bar: 0,
332            vwap_vol: 0.0,
333            vwap_tpv: 0.0,
334            vwap_date: None,
335            ema9: None,
336            rma_atr: None,
337            st_upper: None,
338            st_lower: None,
339            st_dir: 1,
340            st_value: None,
341            kmeans_centroids: None,
342            kmeans_last_bar: 0,
343            dyn_ema: None,
344            prev_close: None,
345            max_abs_buf: VecDeque::with_capacity(200),
346            delta_buf: VecDeque::with_capacity(200),
347            rma_c: None,
348            rma_o: None,
349            wave_speed: 0.0,
350            wave_pos: 0,
351            speed_norm: VecDeque::with_capacity(ts_collen),
352            hma_buf: VecDeque::new(),
353            bull_waves: VecDeque::with_capacity(ts_lookback * 4),
354            bear_waves: VecDeque::with_capacity(ts_lookback * 4),
355            wr_tracker,
356            mom_tracker: PercentileTracker::seeded(200, 0.5, 0.5),
357            cur_ratio: 0.0,
358            hurst_last_bar: 0,
359            hurst_last_close: f64::NAN,
360            vel_buf: VecDeque::with_capacity(110),
361            vwap: None,
362            ema: None,
363            st: None,
364            st_dir_pub: 1,
365            atr: None,
366            cluster: 1,
367            dyn_ema_pub: None,
368            ts_speed: 0.0,
369            ts_norm: 0.5,
370            ts_bullish: false,
371            bull_avg: 0.0,
372            bear_avg: 0.0,
373            dominance: 0.0,
374            ao: 0.0,
375            ao_rising: false,
376            wr_pct: 0.5,
377            mom_pct: 0.5,
378            wave_ok_long: true,
379            wave_ok_short: true,
380            mom_ok_long: true,
381            mom_ok_short: true,
382            hurst: 0.5,
383            price_accel: 0.0,
384        }
385    }
386
387    // ── L1 VWAP ───────────────────────────────────────────────────────────────
388
389    fn upd_vwap(&mut self, candle: &Candle) -> f64 {
390        let dt = Utc
391            .timestamp_millis_opt(candle.time)
392            .single()
393            .unwrap_or_else(Utc::now)
394            .date_naive();
395        if Some(dt) != self.vwap_date {
396            self.vwap_vol = 0.0;
397            self.vwap_tpv = 0.0;
398            self.vwap_date = Some(dt);
399        }
400        let tp = candle.typical_price();
401        self.vwap_vol += candle.volume;
402        self.vwap_tpv += tp * candle.volume;
403        if self.vwap_vol > 0.0 {
404            self.vwap_tpv / self.vwap_vol
405        } else {
406            candle.close
407        }
408    }
409
410    // ── L3 RMA ATR ────────────────────────────────────────────────────────────
411
412    fn upd_atr(&mut self, candle: &Candle) -> f64 {
413        let prev_c = self
414            .closes
415            .iter()
416            .rev()
417            .nth(1)
418            .copied()
419            .unwrap_or(candle.close);
420        let tr = (candle.high - candle.low)
421            .max((candle.high - prev_c).abs())
422            .max((candle.low - prev_c).abs());
423        let atr = rma_step(self.rma_atr, tr, self.cfg.atr_len);
424        self.rma_atr = Some(atr);
425        atr
426    }
427
428    // ── L3 KMeans ─────────────────────────────────────────────────────────────
429
430    fn kmeans_atr(&mut self, atr_val: f64) -> f64 {
431        let [c_h, c_m, c_l] = match self.kmeans_centroids {
432            Some(c)
433                if (self.bar - self.kmeans_last_bar) < self.cfg.engine.kmeans_recompute_bars =>
434            {
435                c
436            }
437            _ => {
438                let c = self.compute_kmeans_centroids();
439                self.kmeans_centroids = Some(c);
440                self.kmeans_last_bar = self.bar;
441                c
442            }
443        };
444        let dists = [
445            (c_h - atr_val).abs(),
446            (c_m - atr_val).abs(),
447            (c_l - atr_val).abs(),
448        ];
449        self.cluster = dists
450            .iter()
451            .enumerate()
452            .min_by(|a, b| a.1.total_cmp(b.1))
453            .map_or(1, |(i, _)| i);
454        [c_h, c_m, c_l][self.cluster]
455    }
456
457    fn compute_kmeans_centroids(&self) -> [f64; 3] {
458        let n = self.cfg.training_period.min(self.closes.len());
459        if n == 0 {
460            return [0.0; 3];
461        }
462        let ha: Vec<f64> = self.highs.iter().rev().take(n).copied().collect();
463        let la: Vec<f64> = self.lows.iter().rev().take(n).copied().collect();
464        let ca: Vec<f64> = self.closes.iter().rev().take(n).copied().collect();
465
466        let mut trs = vec![ha[0] - la[0]];
467        for i in 1..n {
468            trs.push(
469                (ha[i] - la[i])
470                    .max((ha[i] - ca[i - 1]).abs())
471                    .max((la[i] - ca[i - 1]).abs()),
472            );
473        }
474        let alpha = 1.0 / self.cfg.atr_len as f64;
475        let mut atr_w = vec![trs[0]];
476        for i in 1..trs.len() {
477            atr_w.push(alpha * trs[i] + (1.0 - alpha) * atr_w[i - 1]);
478        }
479
480        let lo = atr_w.iter().copied().fold(f64::INFINITY, f64::min);
481        let hi = atr_w.iter().copied().fold(f64::NEG_INFINITY, f64::max);
482        let rng = if (hi - lo).abs() > 1e-9 {
483            hi - lo
484        } else {
485            1e-9
486        };
487
488        let mut c_h = lo + rng * self.cfg.highvol_pct;
489        let mut c_m = lo + rng * self.cfg.midvol_pct;
490        let mut c_l = lo + rng * self.cfg.lowvol_pct;
491
492        for _ in 0..self.cfg.engine.kmeans_max_iters {
493            let mut g: [Vec<f64>; 3] = [Vec::new(), Vec::new(), Vec::new()];
494            for &v in &atr_w {
495                let dists = [(v - c_h).abs(), (v - c_m).abs(), (v - c_l).abs()];
496                let idx = dists
497                    .iter()
498                    .enumerate()
499                    .min_by(|a, b| a.1.total_cmp(b.1))
500                    .map_or(1, |(i, _)| i);
501                g[idx].push(v);
502            }
503            let nh = if g[0].is_empty() {
504                c_h
505            } else {
506                g[0].iter().sum::<f64>() / g[0].len() as f64
507            };
508            let nm = if g[1].is_empty() {
509                c_m
510            } else {
511                g[1].iter().sum::<f64>() / g[1].len() as f64
512            };
513            let nl = if g[2].is_empty() {
514                c_l
515            } else {
516                g[2].iter().sum::<f64>() / g[2].len() as f64
517            };
518            if (nh - c_h).abs() < 1e-9 && (nm - c_m).abs() < 1e-9 && (nl - c_l).abs() < 1e-9 {
519                break;
520            }
521            c_h = nh;
522            c_m = nm;
523            c_l = nl;
524        }
525        [c_h, c_m, c_l]
526    }
527
528    // ── L3 SuperTrend ─────────────────────────────────────────────────────────
529
530    fn upd_supertrend(&mut self, adaptive_atr: f64, close: f64) -> (f64, i8) {
531        let hl2 = f64::midpoint(
532            self.highs.back().copied().unwrap_or(close),
533            self.lows.back().copied().unwrap_or(close),
534        );
535        let factor = self.cfg.st_factor;
536        let raw_upper = hl2 + factor * adaptive_atr;
537        let raw_lower = hl2 - factor * adaptive_atr;
538
539        let prev_u = self.st_upper.unwrap_or(raw_upper);
540        let prev_l = self.st_lower.unwrap_or(raw_lower);
541        let prev_st = self.st_value.unwrap_or(raw_upper);
542        let prev_c = self.closes.iter().rev().nth(1).copied().unwrap_or(close);
543
544        let lower = if raw_lower > prev_l || prev_c < prev_l {
545            raw_lower
546        } else {
547            prev_l
548        };
549        let upper = if raw_upper < prev_u || prev_c > prev_u {
550            raw_upper
551        } else {
552            prev_u
553        };
554
555        let direction = if prev_st == prev_u {
556            if close > upper { -1 } else { 1 }
557        } else {
558            if close < lower { 1 } else { -1 }
559        };
560
561        let st_val = if direction == -1 { lower } else { upper };
562        self.st_upper = Some(upper);
563        self.st_lower = Some(lower);
564        self.st_dir = direction;
565        self.st_value = Some(st_val);
566        (st_val, direction)
567    }
568
569    // ── L4 Trend Speed ────────────────────────────────────────────────────────
570
571    fn upd_trend_speed(&mut self, candle: &Candle) {
572        let cl = candle.close;
573        let op = candle.open;
574
575        let abs_cd = (cl - op).abs();
576        if self.max_abs_buf.len() == 200 {
577            self.max_abs_buf.pop_front();
578        }
579        self.max_abs_buf.push_back(abs_cd);
580        let max_abs = self
581            .max_abs_buf
582            .iter()
583            .copied()
584            .fold(f64::NEG_INFINITY, f64::max)
585            .max(1.0);
586        let cd_norm = (abs_cd + max_abs) / (2.0 * max_abs);
587        let dyn_len = 5.0 + cd_norm * (self.cfg.ts_max_length as f64 - 5.0);
588
589        let prev_c = self.prev_close.unwrap_or(cl);
590        let delta = (cl - prev_c).abs();
591        if self.delta_buf.len() == 200 {
592            self.delta_buf.pop_front();
593        }
594        self.delta_buf.push_back(delta);
595        let max_d = self
596            .delta_buf
597            .iter()
598            .copied()
599            .fold(f64::NEG_INFINITY, f64::max)
600            .max(1.0);
601        let accel = delta / max_d;
602
603        let alpha = (2.0 / (dyn_len + 1.0) * (1.0 + accel * self.cfg.ts_accel_mult)).min(1.0);
604        let trend = match self.dyn_ema {
605            None => cl,
606            Some(prev) => alpha * cl + (1.0 - alpha) * prev,
607        };
608        self.dyn_ema = Some(trend);
609        self.dyn_ema_pub = self.dyn_ema;
610
611        self.rma_c = Some(rma_step(self.rma_c, cl, self.cfg.ts_rma_len));
612        self.rma_o = Some(rma_step(self.rma_o, op, self.cfg.ts_rma_len));
613
614        let prev_cl = self.closes.iter().rev().nth(1).copied().unwrap_or(cl);
615        let c_rma = self.rma_c.unwrap_or(0.0);
616        let o_rma = self.rma_o.unwrap_or(0.0);
617        let lookback_cap = self.cfg.ts_lookback * 4;
618
619        if cl > trend && prev_cl <= trend {
620            if self.wave_pos != 0 {
621                if self.bear_waves.len() == lookback_cap {
622                    self.bear_waves.pop_front();
623                }
624                self.bear_waves.push_back(self.wave_speed);
625            }
626            self.wave_pos = 1;
627            self.wave_speed = c_rma - o_rma;
628        } else if cl < trend && prev_cl >= trend {
629            if self.wave_pos != 0 {
630                if self.bull_waves.len() == lookback_cap {
631                    self.bull_waves.pop_front();
632                }
633                self.bull_waves.push_back(self.wave_speed);
634            }
635            self.wave_pos = -1;
636            self.wave_speed = c_rma - o_rma;
637        } else {
638            self.wave_speed += c_rma - o_rma;
639        }
640
641        if self.speed_norm.len() == self.cfg.ts_collen {
642            self.speed_norm.pop_front();
643        }
644        self.speed_norm.push_back(self.wave_speed);
645
646        self.ts_speed = self.hma_smooth(self.cfg.ts_hma_len);
647        self.ts_bullish = self.ts_speed > 0.0;
648
649        let sp_min = self
650            .speed_norm
651            .iter()
652            .copied()
653            .fold(f64::INFINITY, f64::min);
654        let sp_max = self
655            .speed_norm
656            .iter()
657            .copied()
658            .fold(f64::NEG_INFINITY, f64::max);
659        let sp_rng = if (sp_max - sp_min).abs() > 1e-9 {
660            sp_max - sp_min
661        } else {
662            1.0
663        };
664        self.ts_norm = (self.wave_speed - sp_min) / sp_rng;
665
666        let lb = self.cfg.ts_lookback;
667        let bull_r: Vec<f64> = self.bull_waves.iter().rev().take(lb).copied().collect();
668        let bear_r: Vec<f64> = self.bear_waves.iter().rev().take(lb).copied().collect();
669        self.bull_avg = if bull_r.is_empty() {
670            0.0
671        } else {
672            bull_r.iter().sum::<f64>() / bull_r.len() as f64
673        };
674        self.bear_avg = if bear_r.is_empty() {
675            0.0
676        } else {
677            bear_r.iter().sum::<f64>() / bear_r.len() as f64
678        };
679        self.dominance = self.bull_avg - self.bear_avg.abs();
680        self.prev_close = Some(cl);
681
682        let bear_abs = self.bear_avg.abs().max(1e-9);
683        let wave_ratio = if self.bull_avg > 0.0 {
684            self.bull_avg / bear_abs
685        } else {
686            1.0 / bear_abs
687        };
688        self.wr_tracker.push(wave_ratio);
689        self.wr_pct = self.wr_tracker.pct(wave_ratio);
690
691        self.cur_ratio = if self.wave_speed > 0.0 && self.bull_avg > 0.0 {
692            self.wave_speed / self.bull_avg
693        } else if self.wave_speed < 0.0 && bear_abs > 0.0 {
694            -self.wave_speed.abs() / bear_abs
695        } else {
696            0.0
697        };
698        self.mom_tracker.push(self.cur_ratio.abs());
699        self.mom_pct = self.mom_tracker.pct(self.cur_ratio.abs());
700
701        let wl = self.cfg.wave_pct_l.clamp(0.01, 0.99);
702        let ws = (1.0 - self.cfg.wave_pct_s).clamp(0.01, 0.99);
703        let ml = self.cfg.mom_pct_min.clamp(0.01, 0.99);
704
705        self.wave_ok_long = self.wr_pct >= wl;
706        self.wave_ok_short = self.wr_pct <= ws;
707        self.mom_ok_long = self.mom_pct >= ml && self.cur_ratio > 0.0;
708        self.mom_ok_short = self.mom_pct >= ml && self.cur_ratio < 0.0;
709    }
710
711    /// HMA: 2*WMA(n/2) - WMA(n), then WMA(√n) of that.
712    fn hma_smooth(&mut self, length: usize) -> f64 {
713        let sn: Vec<f64> = self.speed_norm.iter().copied().collect();
714        if sn.len() < 2 {
715            return *sn.last().unwrap_or(&0.0);
716        }
717        let half = (length / 2).max(1);
718        let sqrt_n = (length as f64).sqrt().round() as usize;
719        let raw = 2.0 * wma(&sn[sn.len().saturating_sub(half)..])
720            - wma(&sn[sn.len().saturating_sub(length)..]);
721        if self.hma_buf.len() == sqrt_n {
722            self.hma_buf.pop_front();
723        }
724        self.hma_buf.push_back(raw);
725        let hma_arr: Vec<f64> = self.hma_buf.iter().copied().collect();
726        wma(&hma_arr)
727    }
728
729    // ── L9 Awesome Oscillator ─────────────────────────────────────────────────
730
731    fn upd_ao(&mut self) {
732        if self.highs.len() < 34 {
733            return;
734        }
735        let hs: Vec<f64> = self.highs.iter().copied().collect();
736        let ls: Vec<f64> = self.lows.iter().copied().collect();
737        let hl2: Vec<f64> = hs
738            .iter()
739            .zip(ls.iter())
740            .map(|(h, l)| (h + l) / 2.0)
741            .collect();
742        let n = hl2.len();
743        let ao_new =
744            hl2[n - 5..].iter().sum::<f64>() / 5.0 - hl2[n - 34..].iter().sum::<f64>() / 34.0;
745        self.ao_rising = ao_new > self.ao;
746        self.ao = ao_new;
747    }
748
749    // ── L10 Hurst ─────────────────────────────────────────────────────────────
750
751    fn upd_hurst(&mut self) {
752        let lb = self.cfg.hurst_lookback;
753        let min_bars = lb * 2 + 1;
754        if self.closes.len() < min_bars
755            || (self.bar - self.hurst_last_bar) < self.cfg.engine.hurst_recompute_bars
756        {
757            return;
758        }
759        // Skip the O(N log N) R/S loop when the close has barely moved since
760        // the last recompute (< 0.01 % change).  The Hurst exponent is stable
761        // over such tiny price moves, so the cached value remains valid.
762        let cur_close = *self.closes.back().unwrap_or(&0.0);
763        let pct_move = if self.hurst_last_close.is_nan() || self.hurst_last_close.abs() < 1e-10 {
764            f64::INFINITY // force first compute
765        } else {
766            (cur_close - self.hurst_last_close).abs() / self.hurst_last_close
767        };
768        if pct_move < 1e-4 {
769            // Price hasn't moved enough to shift the Hurst estimate — reuse cache.
770            self.hurst_last_bar = self.bar;
771            return;
772        }
773        let cl_arr: Vec<f64> = self.closes.iter().rev().take(min_bars).copied().collect();
774        self.hurst = hurst_scalar(&cl_arr, lb);
775        self.hurst_last_bar = self.bar;
776        self.hurst_last_close = cur_close;
777    }
778
779    // ── L11 Price acceleration ────────────────────────────────────────────────
780
781    fn upd_accel(&mut self) {
782        let k = 3usize;
783        let n = self.closes.len();
784        if n <= k * 2 {
785            return;
786        }
787        let cl: Vec<f64> = self.closes.iter().copied().collect();
788        let vel_now = (cl[n - 1] - cl[n - 1 - k]) / (cl[n - 1 - k] + 1e-10);
789        let vel_prev = (cl[n - 1 - k] - cl[n - 1 - k * 2]) / (cl[n - 1 - k * 2] + 1e-10);
790        if self.vel_buf.len() == 110 {
791            self.vel_buf.pop_front();
792        }
793        self.vel_buf.push_back(vel_now);
794        let accel = vel_now - vel_prev;
795        let vel_std = if self.vel_buf.len() > 1 {
796            let vv: Vec<f64> = self.vel_buf.iter().copied().collect();
797            let mean = vv.iter().sum::<f64>() / vv.len() as f64;
798            let var = vv.iter().map(|x| (x - mean).powi(2)).sum::<f64>() / vv.len() as f64;
799            var.sqrt()
800        } else {
801            1.0
802        };
803        self.price_accel = (accel / (vel_std + 1e-10) / 3.0).clamp(-1.0, 1.0);
804    }
805
806    // ── Main update ───────────────────────────────────────────────────────────
807
808    /// Feed one closed candle. Returns `true` once SuperTrend is ready.
809    pub fn update(&mut self, candle: &Candle) -> bool {
810        let cap = self.maxlen;
811        macro_rules! push {
812            ($buf:expr, $val:expr) => {
813                if $buf.len() == cap {
814                    $buf.pop_front();
815                }
816                $buf.push_back($val);
817            };
818        }
819        push!(self.opens, candle.open);
820        push!(self.highs, candle.high);
821        push!(self.lows, candle.low);
822        push!(self.closes, candle.close);
823        push!(self.volumes, candle.volume);
824        push!(self.times, candle.time);
825        self.bar += 1;
826
827        self.vwap = Some(self.upd_vwap(candle));
828
829        let k = 2.0 / (self.cfg.ema_len as f64 + 1.0);
830        self.ema9 = Some(match self.ema9 {
831            None => candle.close,
832            Some(e) => candle.close * k + e * (1.0 - k),
833        });
834        self.ema = self.ema9;
835
836        let atr_val = self.upd_atr(candle);
837        self.atr = Some(atr_val);
838
839        self.upd_trend_speed(candle);
840        self.upd_ao();
841        self.upd_hurst();
842        self.upd_accel();
843
844        if self.closes.len() < self.cfg.training_period {
845            return false;
846        }
847
848        let adaptive_atr = self.kmeans_atr(atr_val);
849        let (st, dir) = self.upd_supertrend(adaptive_atr, candle.close);
850        self.st = Some(st);
851        self.st_dir_pub = dir;
852
853        true
854    }
855
856    /// Returns `true` if a speed-exit condition is triggered for the given position.
857    ///
858    /// `position`: `+1` = long, `-1` = short.
859    /// Returns `false` when `ts_speed_exit_threshold` is `None`.
860    pub fn check_speed_exit(&self, position: i32) -> bool {
861        let Some(thr) = self.cfg.ts_speed_exit_threshold else {
862            return false;
863        };
864        if position > 0 && self.ts_speed < -thr.abs() {
865            return true;
866        }
867        position < 0 && self.ts_speed > thr.abs()
868    }
869}