kestrel_chartkit/analytics/
trend.rs1#[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#[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 pub slope_pct: f64,
41}
42
43#[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
96fn 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
113fn 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}