kestrel_chartkit/analytics/
trend_persistence.rs1use 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#[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 pub score: f64,
76 pub state: TrendPersistenceState,
77 pub direction: TrendPersistenceDirection,
79 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
99fn correlation_with_index(closes: &[f64]) -> f64 {
101 let n = closes.len() as f64;
102 let mean_y = (n - 1.0) / 2.0; 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
128fn sub_scores_at(bars: &[Bar], idx: usize) -> SubScores {
131 let window = &bars[idx - LEN..=idx]; let closes: Vec<f64> = window.iter().map(|b| b.close).collect();
133
134 let corr = correlation_with_index(&closes[1..]); 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
161fn 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 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
233pub 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 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}