Skip to main content

kestrel_chartkit/analytics/
trend.rs

1//! Smoothed price direction combined with an existing trend/range vote.
2
3#[cfg(feature = "serde")]
4use serde::Serialize;
5
6use super::regime::{RegimeReading, RegimeState};
7use crate::indicator::smoothing::SmootherKind;
8use crate::indicator::smoothing::{Jma, Smoother};
9use crate::Bar;
10
11#[derive(Debug, Clone, Copy, PartialEq, Eq)]
12#[cfg_attr(feature = "serde", derive(Serialize))]
13#[cfg_attr(feature = "serde", serde(rename_all = "snake_case"))]
14pub enum TrendDirection {
15    Up,
16    Flat,
17    Down,
18}
19
20/// Five-step market phase. "Strong" means the regime vote also calls it `Trending`, not
21/// just that direction is non-flat โ€” a directional move inside a `Ranging`
22/// read (still choppy, just currently drifting) is the plain `Up`/`Down`.
23#[derive(Debug, Clone, Copy, PartialEq, Eq)]
24#[cfg_attr(feature = "serde", derive(Serialize))]
25#[cfg_attr(feature = "serde", serde(rename_all = "snake_case"))]
26pub enum MarketPhase {
27    StrongUp,
28    Up,
29    Range,
30    Down,
31    StrongDown,
32}
33
34#[derive(Debug, Clone, Copy, PartialEq)]
35#[cfg_attr(feature = "serde", derive(Serialize))]
36pub struct TrendReading {
37    pub direction: TrendDirection,
38    pub phase: MarketPhase,
39    /// Percent change of the smoothed price over `len` bars.
40    pub slope_pct: f64,
41}
42
43/// Direction from a smoothed price's slope over `len` bars (`kind`/
44/// `jma_phase`/`jma_power` select the kernel), combined with
45/// `regime`'s trend/range vote into a 5-step phase. `deadband_pct` is the
46/// minimum `|slope_pct|` to call it `Up`/`Down` instead of `Flat` โ€” keeps
47/// noise from flipping direction every bar. `None` until the kernel has
48/// published `len + 1` values, or for a zero reference value;
49/// `slope_pct = 100 ยท (s_last / s_(last-len) - 1)` over those published values.
50#[allow(clippy::too_many_arguments)]
51pub fn trend_reading(
52    bars: &[Bar],
53    regime: &RegimeReading,
54    kind: SmootherKind,
55    len: usize,
56    jma_phase: f64,
57    jma_power: f64,
58    deadband_pct: f64,
59) -> Option<TrendReading> {
60    if len < 1 {
61        return None;
62    }
63    let series = smoothed_series(bars, kind, len, jma_phase, jma_power);
64    if series.len() < len + 1 {
65        return None;
66    }
67    let last = series[series.len() - 1];
68    let prior = series[series.len() - 1 - len];
69    if prior == 0.0 {
70        return None;
71    }
72    let slope_pct = 100.0 * (last / prior - 1.0);
73    let direction = if slope_pct > deadband_pct {
74        TrendDirection::Up
75    } else if slope_pct < -deadband_pct {
76        TrendDirection::Down
77    } else {
78        TrendDirection::Flat
79    };
80
81    let phase = match direction {
82        TrendDirection::Flat => MarketPhase::Range,
83        TrendDirection::Up if regime.state == RegimeState::Trending => MarketPhase::StrongUp,
84        TrendDirection::Up => MarketPhase::Up,
85        TrendDirection::Down if regime.state == RegimeState::Trending => MarketPhase::StrongDown,
86        TrendDirection::Down => MarketPhase::Down,
87    };
88
89    Some(TrendReading {
90        direction,
91        phase,
92        slope_pct,
93    })
94}
95
96/// Maps `SmootherKind` to a `kestrel-chartkit` `Smoother` instance. Not a plain
97/// `crate::indicator::smoothing::SmootherKind::build(len)` call: chartkit's generic
98/// `build` bakes in the `Jma` defaults (`phase = 0.0`, `power = 2.0`) since it has no way to
99/// take extra parameters โ€” this function's callers (config-driven, `jma_phase`/`jma_power` are
100/// user-configurable) construct `Jma` directly for that case instead.
101fn build_smoother(
102    kind: SmootherKind,
103    len: usize,
104    jma_phase: f64,
105    jma_power: f64,
106) -> Box<dyn Smoother> {
107    match kind {
108        SmootherKind::Jma => Box::new(Jma::new(len, jma_phase, jma_power)),
109        other => other.build(len),
110    }
111}
112
113/// Feeds every bar's close through the chosen kernel, keeping only the
114/// values past its own warmup (matches `Smoother::update`'s `None`-while-
115/// warming-up contract).
116fn smoothed_series(
117    bars: &[Bar],
118    kind: SmootherKind,
119    len: usize,
120    jma_phase: f64,
121    jma_power: f64,
122) -> Vec<f64> {
123    let mut smoother = build_smoother(kind, len, jma_phase, jma_power);
124    bars.iter()
125        .filter_map(|b| smoother.update(b.close))
126        .collect()
127}
128
129#[cfg(test)]
130mod tests {
131    use super::*;
132
133    fn bar(c: f64) -> Bar {
134        Bar {
135            timestamp: 0,
136            open: c,
137            high: c + 0.5,
138            low: c - 0.5,
139            close: c,
140            volume: 0.0,
141        }
142    }
143
144    fn trending_regime() -> RegimeReading {
145        RegimeReading {
146            state: RegimeState::Trending,
147            adx: 30.0,
148            choppiness: 30.0,
149            efficiency: 0.8,
150            trend_votes: 3,
151        }
152    }
153
154    fn ranging_regime() -> RegimeReading {
155        RegimeReading {
156            state: RegimeState::Ranging,
157            adx: 15.0,
158            choppiness: 70.0,
159            efficiency: 0.2,
160            trend_votes: 0,
161        }
162    }
163
164    #[test]
165    fn ramp_with_trending_regime_is_strong_up() {
166        let bars: Vec<Bar> = (0..60).map(|i| bar(100.0 + i as f64)).collect();
167        let r = trend_reading(
168            &bars,
169            &trending_regime(),
170            SmootherKind::Ema,
171            20,
172            0.0,
173            2.0,
174            0.1,
175        )
176        .expect("enough bars");
177        assert_eq!(r.direction, TrendDirection::Up);
178        assert_eq!(r.phase, MarketPhase::StrongUp);
179        assert!(r.slope_pct > 0.0);
180    }
181
182    #[test]
183    fn ramp_with_ranging_regime_is_weak_up() {
184        let bars: Vec<Bar> = (0..60).map(|i| bar(100.0 + i as f64)).collect();
185        let r = trend_reading(
186            &bars,
187            &ranging_regime(),
188            SmootherKind::Ema,
189            20,
190            0.0,
191            2.0,
192            0.1,
193        )
194        .expect("enough bars");
195        assert_eq!(r.direction, TrendDirection::Up);
196        assert_eq!(r.phase, MarketPhase::Up);
197    }
198
199    #[test]
200    fn flat_price_is_range_regardless_of_regime() {
201        let bars: Vec<Bar> = (0..60).map(|_| bar(100.0)).collect();
202        let r = trend_reading(
203            &bars,
204            &trending_regime(),
205            SmootherKind::Ema,
206            20,
207            0.0,
208            2.0,
209            0.1,
210        )
211        .expect("enough bars");
212        assert_eq!(r.direction, TrendDirection::Flat);
213        assert_eq!(r.phase, MarketPhase::Range);
214    }
215
216    #[test]
217    fn downtrend_with_trending_regime_is_strong_down() {
218        let bars: Vec<Bar> = (0..60).map(|i| bar(200.0 - i as f64)).collect();
219        let r = trend_reading(
220            &bars,
221            &trending_regime(),
222            SmootherKind::Ema,
223            20,
224            0.0,
225            2.0,
226            0.1,
227        )
228        .expect("enough bars");
229        assert_eq!(r.direction, TrendDirection::Down);
230        assert_eq!(r.phase, MarketPhase::StrongDown);
231    }
232
233    #[test]
234    fn insufficient_bars_is_none() {
235        let bars: Vec<Bar> = (0..5).map(|_| bar(100.0)).collect();
236        assert!(trend_reading(
237            &bars,
238            &trending_regime(),
239            SmootherKind::Ema,
240            20,
241            0.0,
242            2.0,
243            0.1
244        )
245        .is_none());
246    }
247
248    #[test]
249    fn jma_kernel_also_produces_a_reading() {
250        let bars: Vec<Bar> = (0..60).map(|i| bar(100.0 + i as f64)).collect();
251        let r = trend_reading(
252            &bars,
253            &trending_regime(),
254            SmootherKind::Jma,
255            20,
256            0.0,
257            2.0,
258            0.1,
259        )
260        .expect("jma is valid from the first sample");
261        assert_eq!(r.direction, TrendDirection::Up);
262    }
263}