Skip to main content

kestrel_chartkit/analytics/
trend_persistence.rs

1//! Stateless trend persistence: R², efficiency, ADX strength/slope and fractal dimension.
2//! Fixed 34-bar sensors, ADX 14, and a seeded EMA over the final five composite samples.
3//! This preserves the snapshot model; it is not the streaming trend-quality indicator or a probability.
4//! Classification has no state hysteresis.
5
6use crate::Bar;
7#[cfg(feature = "serde")]
8use serde::Serialize;
9
10use super::efficiency_ratio;
11use crate::indicator::adx::Adx;
12use crate::Indicator;
13
14const LEN: usize = 34;
15const ADX_LEN: usize = 14;
16const SMOOTH_LEN: usize = 5;
17
18const WEIGHT_R2: f64 = 40.0;
19const WEIGHT_ER: f64 = 25.0;
20const WEIGHT_ADX: f64 = 20.0;
21const WEIGHT_FDI: f64 = 15.0;
22
23const ADX_LO: f64 = 12.0;
24const ADX_HI: f64 = 35.0;
25const FDI_TREND_ANCHOR: f64 = 1.20;
26const FDI_RANGE_ANCHOR: f64 = 1.65;
27
28const DEAD_THRESH: f64 = 20.0;
29const ADX_DAMP: f64 = 0.35;
30
31const LVL_STRONG: f64 = 75.0;
32const LVL_HEALTHY: f64 = 60.0;
33const LVL_TRANS: f64 = 45.0;
34const LVL_WEAK: f64 = 30.0;
35
36const DIRECTION_THRESHOLD: f64 = 0.15;
37
38#[derive(Debug, Clone, Copy, PartialEq, Eq)]
39#[cfg_attr(feature = "serde", derive(Serialize))]
40#[cfg_attr(feature = "serde", serde(rename_all = "snake_case"))]
41pub enum TrendPersistenceState {
42    Strong,
43    Healthy,
44    Transition,
45    Weak,
46    Dead,
47}
48
49#[derive(Debug, Clone, Copy, PartialEq, Eq)]
50#[cfg_attr(feature = "serde", derive(Serialize))]
51#[cfg_attr(feature = "serde", serde(rename_all = "snake_case"))]
52pub enum TrendPersistenceDirection {
53    Up,
54    Down,
55    Flat,
56}
57
58/// Which of the four sensors currently drives (`driver`) or drags down
59/// (`drag`) the composite score, to explain a change of the score.
60#[derive(Debug, Clone, Copy, PartialEq, Eq)]
61#[cfg_attr(feature = "serde", derive(Serialize))]
62#[cfg_attr(feature = "serde", serde(rename_all = "snake_case"))]
63pub enum TrendPersistenceSensor {
64    Regression,
65    Efficiency,
66    Adx,
67    Fractal,
68}
69
70#[derive(Debug, Clone, Copy, PartialEq)]
71#[cfg_attr(feature = "serde", derive(Serialize))]
72pub struct TrendPersistenceReading {
73    /// The composite score, 0..100 — higher means a cleaner, more durable
74    /// trend (in either direction).
75    pub score: f64,
76    pub state: TrendPersistenceState,
77    /// Correlation-sign direction — visual context only, not scored.
78    pub direction: TrendPersistenceDirection,
79    /// Derived inverse of `score` blended with the Efficiency sensor: how
80    /// exposed the current trend is to breaking down, not a break detector.
81    pub transition_risk: f64,
82    pub r2_score: f64,
83    pub er_score: f64,
84    pub adx_score: f64,
85    pub fdi_score: f64,
86    pub driver: TrendPersistenceSensor,
87    pub drag: TrendPersistenceSensor,
88}
89
90use crate::indicator::smoothing::Ema as SeededEma;
91
92fn normalize(x: f64, lo: f64, hi: f64) -> f64 {
93    if hi == lo {
94        return 0.0;
95    }
96    ((x - lo) / (hi - lo)).clamp(0.0, 1.0)
97}
98
99/// Pearson correlation between `closes` and its own bar index (0..len).
100fn correlation_with_index(closes: &[f64]) -> f64 {
101    let n = closes.len() as f64;
102    let mean_y = (n - 1.0) / 2.0; // mean of 0..n-1
103    let mean_x = closes.iter().sum::<f64>() / n;
104
105    let mut cov = 0.0;
106    let mut var_x = 0.0;
107    let mut var_y = 0.0;
108    for (i, &x) in closes.iter().enumerate() {
109        let dx = x - mean_x;
110        let dy = i as f64 - mean_y;
111        cov += dx * dy;
112        var_x += dx * dx;
113        var_y += dy * dy;
114    }
115    if var_x <= 0.0 || var_y <= 0.0 {
116        return 0.0;
117    }
118    cov / (var_x.sqrt() * var_y.sqrt())
119}
120
121struct SubScores {
122    r2_score: f64,
123    er_score: f64,
124    fdi_score: f64,
125    corr: f64,
126}
127
128/// R², Efficiency Ratio and Fractal Dimension sensors at `bars[idx]`, each
129/// over its own trailing `LEN`-bar window. Requires `idx >= LEN`.
130fn sub_scores_at(bars: &[Bar], idx: usize) -> SubScores {
131    let window = &bars[idx - LEN..=idx]; // LEN+1 bars
132    let closes: Vec<f64> = window.iter().map(|b| b.close).collect();
133
134    let corr = correlation_with_index(&closes[1..]); // last LEN closes
135    let r2_score = corr * corr * 100.0;
136
137    let er_score = efficiency_ratio(&bars[..=idx], LEN).unwrap_or(0.0) * 100.0;
138
139    let highest_high = window
140        .iter()
141        .map(|b| b.high)
142        .fold(f64::NEG_INFINITY, f64::max);
143    let lowest_low = window.iter().map(|b| b.low).fold(f64::INFINITY, f64::min);
144    let range_hl = highest_high - lowest_low;
145    let path_length: f64 = closes.windows(2).map(|w| (w[1] - w[0]).abs()).sum();
146    let fdi_raw = if range_hl > 0.0 && path_length > 0.0 {
147        (path_length / range_hl).ln() / (LEN as f64).ln() + 1.0
148    } else {
149        1.5
150    };
151    let fdi_score = (1.0 - normalize(fdi_raw, FDI_TREND_ANCHOR, FDI_RANGE_ANCHOR)) * 100.0;
152
153    SubScores {
154        r2_score,
155        er_score,
156        fdi_score,
157        corr,
158    }
159}
160
161/// ADX strength+slope sub-score (undamped — the structure-dead damp depends
162/// on the R²/ER sensors, computed separately at each needed index) for every
163/// bar, via the `Adx` indicator fed from the start of `bars`.
164/// `None` until the indicator has warmed up *and* a previous ADX value
165/// exists to derive the slope from.
166fn adx_score_series(bars: &[Bar]) -> Vec<Option<f64>> {
167    let mut adx = Adx::new(ADX_LEN, ADX_LEN, 3, 20.0);
168    let mut slope_ema = SeededEma::new(3);
169    let mut prev_adx: Option<f64> = None;
170    let mut out = vec![None; bars.len()];
171
172    for (i, bar) in bars.iter().enumerate() {
173        let Some(output) = adx.on_bar(bar) else {
174            continue;
175        };
176        let raw = output.value;
177        if let Some(prev) = prev_adx {
178            // Default first-sample seed: this emits from the first slope on. The `else` branch
179            // skips the bar rather than substituting a slope that was never observed.
180            let Some(slope) = slope_ema.update(raw - prev) else {
181                prev_adx = Some(raw);
182                continue;
183            };
184            let adx_strength = normalize(raw, ADX_LO, ADX_HI);
185            let adx_slope_norm = normalize(slope, -1.0, 1.5);
186            out[i] = Some((adx_strength * 0.7 + adx_slope_norm * 0.3) * 100.0);
187        }
188        prev_adx = Some(raw);
189    }
190    out
191}
192
193fn classify_state(score: f64) -> TrendPersistenceState {
194    if score >= LVL_STRONG {
195        TrendPersistenceState::Strong
196    } else if score >= LVL_HEALTHY {
197        TrendPersistenceState::Healthy
198    } else if score >= LVL_TRANS {
199        TrendPersistenceState::Transition
200    } else if score >= LVL_WEAK {
201        TrendPersistenceState::Weak
202    } else {
203        TrendPersistenceState::Dead
204    }
205}
206
207fn driver_and_drag(
208    r2_score: f64,
209    er_score: f64,
210    adx_score: f64,
211    fdi_score: f64,
212) -> (TrendPersistenceSensor, TrendPersistenceSensor) {
213    use TrendPersistenceSensor::*;
214    let scores = [
215        (Regression, r2_score),
216        (Efficiency, er_score),
217        (Adx, adx_score),
218        (Fractal, fdi_score),
219    ];
220    let driver = scores
221        .iter()
222        .max_by(|a, b| a.1.total_cmp(&b.1))
223        .expect("scores is non-empty")
224        .0;
225    let drag = scores
226        .iter()
227        .min_by(|a, b| a.1.total_cmp(&b.1))
228        .expect("scores is non-empty")
229        .0;
230    (driver, drag)
231}
232
233/// Reads the current trend-persistence state from `bars` (oldest first,
234/// current bar last). `None` until enough bars exist to seed the four
235/// sensors' `LEN`-bar window, the `SMOOTH_LEN`-bar composite smoothing, and
236/// the `Adx` indicator's own warmup.
237///
238/// For each of the last 5 bars, over the 35 bars ending there:
239///
240/// - `r2_score = 100 · corr²`, `corr` the Pearson correlation of the last 34 closes with their
241///   index (0 without variance);
242/// - `er_score = 100 · ER`, the efficiency ratio over the 35 closes (0 when nothing moved);
243/// - `fdi_score = 100 · (1 - norm(fdi, 1.20, 1.65))` with `fdi = 1 + ln(path / range) / ln(34)`,
244///   `path` the summed absolute close changes and `range` the highest high minus the lowest low
245///   (`fdi = 1.5` when either is zero);
246/// - `adx_score = 100 · (0.7 · norm(ADX, 12, 35) + 0.3 · norm(slope, -1, 1.5))`, the ADX from
247///   [`Adx::new`]`(14, 14, 3, 20)` fed from the first bar and `slope` a first-sample EMA(3) of its
248///   bar-to-bar changes; multiplied by 0.35 when both `r2_score` and `er_score` are below 20.
249///
250/// `norm(x, lo, hi) = clamp((x - lo) / (hi - lo), 0, 1)`. Each bar's raw score is
251/// `(40 · r2 + 25 · er + 20 · adx + 15 · fdi) / 100`; `score` is a first-sample EMA(5) over the
252/// five raw scores, seeded with the first. `transition_risk = 0.7 · (100 - score) + 0.3 · (100 -
253/// er_score)`. States from `score`: strong `>= 75`, healthy `>= 60`, transition `>= 45`, weak
254/// `>= 30`, else dead. Direction from the last `corr`: up above 0.15, down below -0.15, else
255/// flat. `driver`/`drag` name the highest/lowest of the four last sub-scores; on a tie the
256/// driver is the later and the drag the earlier in the order regression, efficiency, ADX,
257/// fractal.
258pub fn trend_persistence_reading(bars: &[Bar]) -> Option<TrendPersistenceReading> {
259    if bars.len() < LEN + SMOOTH_LEN {
260        return None;
261    }
262    let adx_scores = adx_score_series(bars);
263    let last = bars.len() - 1;
264
265    let mut smoother = SeededEma::new(SMOOTH_LEN);
266    let mut score = 0.0;
267    let mut latest: Option<SubScores> = None;
268    let mut latest_adx_score = 0.0;
269
270    #[allow(clippy::needless_range_loop)]
271    for idx in (last + 1 - SMOOTH_LEN)..=last {
272        if idx < LEN {
273            return None;
274        }
275        let adx_score_undamped = adx_scores[idx]?;
276        let sub = sub_scores_at(bars, idx);
277        let structure_dead = sub.r2_score < DEAD_THRESH && sub.er_score < DEAD_THRESH;
278        let adx_score = if structure_dead {
279            adx_score_undamped * ADX_DAMP
280        } else {
281            adx_score_undamped
282        };
283        let weight_sum = WEIGHT_R2 + WEIGHT_ER + WEIGHT_ADX + WEIGHT_FDI;
284        let raw = (sub.r2_score * WEIGHT_R2
285            + sub.er_score * WEIGHT_ER
286            + adx_score * WEIGHT_ADX
287            + sub.fdi_score * WEIGHT_FDI)
288            / weight_sum;
289        score = smoother.update(raw)?;
290        if idx == last {
291            latest_adx_score = adx_score;
292            latest = Some(sub);
293        }
294    }
295    let latest = latest.expect("loop always visits idx == last");
296
297    let transition_risk = (100.0 - score) * 0.7 + (100.0 - latest.er_score) * 0.3;
298    let state = classify_state(score);
299    let direction = if latest.corr > DIRECTION_THRESHOLD {
300        TrendPersistenceDirection::Up
301    } else if latest.corr < -DIRECTION_THRESHOLD {
302        TrendPersistenceDirection::Down
303    } else {
304        TrendPersistenceDirection::Flat
305    };
306    let (driver, drag) = driver_and_drag(
307        latest.r2_score,
308        latest.er_score,
309        latest_adx_score,
310        latest.fdi_score,
311    );
312
313    Some(TrendPersistenceReading {
314        score,
315        state,
316        direction,
317        transition_risk,
318        r2_score: latest.r2_score,
319        er_score: latest.er_score,
320        adx_score: latest_adx_score,
321        fdi_score: latest.fdi_score,
322        driver,
323        drag,
324    })
325}
326
327#[cfg(test)]
328mod tests {
329    use super::*;
330
331    fn bar(c: f64, h: f64, l: f64) -> Bar {
332        Bar {
333            timestamp: 0,
334            open: c,
335            high: h,
336            low: l,
337            close: c,
338            volume: 0.0,
339        }
340    }
341
342    /// Deterministic jitter around a level, same construction as
343    /// `fear_gauge.rs`'s `calm_bars` — avoids a perfectly flat degenerate
344    /// series while staying non-trending.
345    fn choppy_bars(n: usize) -> Vec<Bar> {
346        let mut state: u64 = 42;
347        let mut price = 100.0_f64;
348        (0..n)
349            .map(|_| {
350                state ^= state << 13;
351                state ^= state >> 7;
352                state ^= state << 17;
353                price = 100.0 + ((state % 21) as f64 - 10.0) / 5.0;
354                bar(price, price + 0.3, price - 0.3)
355            })
356            .collect()
357    }
358
359    fn ramp_bars(n: usize) -> Vec<Bar> {
360        (0..n)
361            .map(|i| {
362                let c = 100.0 + i as f64 * 0.5;
363                bar(c, c + 0.2, c - 0.2)
364            })
365            .collect()
366    }
367
368    #[test]
369    fn insufficient_bars_is_none() {
370        let bars = ramp_bars(10);
371        assert!(trend_persistence_reading(&bars).is_none());
372    }
373
374    #[test]
375    fn clean_ramp_reads_high_persistence() {
376        let bars = ramp_bars(120);
377        let r = trend_persistence_reading(&bars).expect("enough bars");
378        assert!(r.score >= LVL_HEALTHY, "score should be high: {}", r.score);
379        assert_eq!(r.direction, TrendPersistenceDirection::Up);
380    }
381
382    #[test]
383    fn choppy_market_reads_low_persistence() {
384        let bars = choppy_bars(120);
385        let r = trend_persistence_reading(&bars).expect("enough bars");
386        assert!(r.score < LVL_TRANS, "score should be low: {}", r.score);
387    }
388
389    #[test]
390    fn state_thresholds_are_monotonic() {
391        assert_eq!(classify_state(80.0), TrendPersistenceState::Strong);
392        assert_eq!(classify_state(65.0), TrendPersistenceState::Healthy);
393        assert_eq!(classify_state(50.0), TrendPersistenceState::Transition);
394        assert_eq!(classify_state(35.0), TrendPersistenceState::Weak);
395        assert_eq!(classify_state(10.0), TrendPersistenceState::Dead);
396    }
397}