kestrel_chartkit/indicator/
swing_structure.rs1use crate::model::Bar;
2
3#[cfg(feature = "serde")]
4use serde::{Deserialize, Serialize};
5
6#[derive(Debug, Clone, Copy, PartialEq, Eq)]
9#[cfg_attr(
10 feature = "serde",
11 derive(Serialize, Deserialize),
12 serde(rename_all = "snake_case")
13)]
14pub enum TrendQuality {
15 Strengthening,
16 Weakening,
17 Stable,
18}
19
20#[derive(Debug, Clone, PartialEq)]
24#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
25pub struct SwingStructureOutput {
26 pub median_impulse_atr: f64,
27 pub median_correction_atr: f64,
28 pub correction_impulse_ratio: f64,
29 pub recent_corrections_atr: Vec<f64>,
31 pub trend_quality: TrendQuality,
32 pub current_retracement_atr: f64,
33 pub entry_zone_reached: bool,
34 pub remaining_potential_atr: f64,
35 pub required_stop_atr: f64,
36 pub potential_crv: f64,
37 pub last_impulse_velocity_atr: f64,
38}
39
40struct Pivot {
41 index: usize,
43 price: f64,
44 is_high: bool,
45}
46
47pub struct SwingStructureEngine {
56 left: usize,
57 right: usize,
58 max_swings: usize,
59 bars: Vec<(usize, Bar)>,
60 pivots: Vec<Pivot>,
61 next_index: usize,
62}
63
64impl SwingStructureEngine {
65 pub fn new(left: usize, right: usize, max_swings: usize) -> Self {
66 Self {
67 left,
68 right,
69 max_swings,
70 bars: Vec::new(),
71 pivots: Vec::new(),
72 next_index: 0,
73 }
74 }
75
76 pub fn with_defaults() -> Self {
77 Self::new(5, 5, 50)
78 }
79
80 pub fn reset(&mut self) {
81 self.bars.clear();
82 self.pivots.clear();
83 self.next_index = 0;
84 }
85
86 pub fn update(&mut self, bar: &Bar, atr: f64) -> Option<SwingStructureOutput> {
89 self.bars.push((self.next_index, bar.clone()));
90 self.next_index += 1;
91 let max_history = (self.left + self.right + 1) * (self.max_swings + 2);
92 if self.bars.len() > max_history {
93 self.bars.remove(0);
94 }
95
96 if atr <= 0.0 {
97 return None;
98 }
99
100 self.detect_pivot();
101
102 if self.pivots.len() < 4 {
103 return None;
104 }
105
106 let mut legs: Vec<(f64, bool, usize)> = Vec::new();
108 for w in self.pivots.windows(2) {
109 let (a, b) = (&w[0], &w[1]);
110 let size = (b.price - a.price).abs() / atr;
111 let is_up = b.price > a.price;
112 let span = b.index.saturating_sub(a.index).max(1);
113 legs.push((size, is_up, span));
114 }
115 if legs.len() > self.max_swings {
116 let excess = legs.len() - self.max_swings;
117 legs.drain(0..excess);
118 }
119
120 let highs: Vec<f64> = self
121 .pivots
122 .iter()
123 .filter(|p| p.is_high)
124 .map(|p| p.price)
125 .collect();
126 let lows: Vec<f64> = self
127 .pivots
128 .iter()
129 .filter(|p| !p.is_high)
130 .map(|p| p.price)
131 .collect();
132 let bullish = highs.len() >= 2
133 && lows.len() >= 2
134 && highs[highs.len() - 1] > highs[highs.len() - 2]
135 && lows[lows.len() - 1] > lows[lows.len() - 2];
136 let bearish = highs.len() >= 2
137 && lows.len() >= 2
138 && highs[highs.len() - 1] < highs[highs.len() - 2]
139 && lows[lows.len() - 1] < lows[lows.len() - 2];
140 let trend_up = bullish || (!bearish && legs.last().map(|l| l.1).unwrap_or(true));
141
142 let mut impulses: Vec<f64> = Vec::new();
143 let mut corrections: Vec<f64> = Vec::new();
144 let mut impulse_velocities: Vec<f64> = Vec::new();
145 for &(size, is_up, span) in &legs {
146 if is_up == trend_up {
147 impulses.push(size);
148 impulse_velocities.push(size / span as f64);
149 } else {
150 corrections.push(size);
151 }
152 }
153
154 if impulses.len() < 2 || corrections.len() < 2 {
155 return None;
156 }
157
158 let median_impulse_atr = median(&impulses);
159 let median_correction_atr = median(&corrections);
160 let correction_impulse_ratio = if median_impulse_atr > 0.0 {
161 median_correction_atr / median_impulse_atr
162 } else {
163 0.0
164 };
165
166 let recent_corrections_atr: Vec<f64> = corrections.iter().rev().take(3).copied().collect();
167 let trend_quality = if recent_corrections_atr.len() >= 2 {
168 let newest = recent_corrections_atr[0];
169 let oldest = recent_corrections_atr[recent_corrections_atr.len() - 1];
170 if newest < oldest {
171 TrendQuality::Strengthening
172 } else if newest > oldest {
173 TrendQuality::Weakening
174 } else {
175 TrendQuality::Stable
176 }
177 } else {
178 TrendQuality::Stable
179 };
180
181 let last_swing_price = self.pivots.last().map(|p| p.price).unwrap_or(bar.close);
182 let current_retracement_atr = (bar.close - last_swing_price).abs() / atr;
183 let entry_zone_reached = current_retracement_atr >= median_correction_atr * 0.8;
184 let remaining_potential_atr = (median_impulse_atr - current_retracement_atr).max(0.0);
185 let required_stop_atr = median_correction_atr.max(0.1);
186 let potential_crv = if required_stop_atr > 0.0 {
187 remaining_potential_atr / required_stop_atr
188 } else {
189 0.0
190 };
191 let last_impulse_velocity_atr = impulse_velocities.last().copied().unwrap_or(0.0);
192
193 Some(SwingStructureOutput {
194 median_impulse_atr,
195 median_correction_atr,
196 correction_impulse_ratio,
197 recent_corrections_atr,
198 trend_quality,
199 current_retracement_atr,
200 entry_zone_reached,
201 remaining_potential_atr,
202 required_stop_atr,
203 potential_crv,
204 last_impulse_velocity_atr,
205 })
206 }
207
208 fn detect_pivot(&mut self) {
209 let req_len = self.left + self.right + 1;
210 if self.bars.len() < req_len {
211 return;
212 }
213 let candidate_idx = self.bars.len() - 1 - self.right;
214 let cand_high = self.bars[candidate_idx].1.high;
215 let cand_low = self.bars[candidate_idx].1.low;
216 let mut is_high = true;
217 let mut is_low = true;
218 for i in (candidate_idx - self.left)..=(candidate_idx + self.right) {
219 if i == candidate_idx {
220 continue;
221 }
222 if self.bars[i].1.high >= cand_high {
223 is_high = false;
224 }
225 if self.bars[i].1.low <= cand_low {
226 is_low = false;
227 }
228 }
229 let abs_index = self.bars[candidate_idx].0;
230 if is_high {
231 self.push_pivot(abs_index, cand_high, true);
232 }
233 if is_low {
234 self.push_pivot(abs_index, cand_low, false);
235 }
236 }
237
238 fn push_pivot(&mut self, index: usize, price: f64, is_high: bool) {
239 self.pivots.push(Pivot {
240 index,
241 price,
242 is_high,
243 });
244 if self.pivots.len() > self.max_swings * 2 + 4 {
245 self.pivots.remove(0);
246 }
247 }
248}
249
250fn median(values: &[f64]) -> f64 {
251 let mut sorted = values.to_vec();
252 sorted.sort_by(|a, b| a.partial_cmp(b).unwrap());
253 let mid = sorted.len() / 2;
254 if sorted.len().is_multiple_of(2) {
255 (sorted[mid - 1] + sorted[mid]) / 2.0
256 } else {
257 sorted[mid]
258 }
259}
260
261#[cfg(test)]
262mod tests {
263 use super::*;
264
265 fn bar(t: i64, high: f64, low: f64, close: f64) -> Bar {
266 Bar::new(t, close, high, low, close, 100.0)
267 }
268
269 #[test]
270 fn zigzag_series_eventually_produces_output() {
271 let mut engine = SwingStructureEngine::new(2, 2, 10);
272 let mut result = None;
273 let mut price = 100.0;
275 for i in 0..80 {
276 let leg = i / 10;
277 let up = leg % 2 == 0;
278 price += if up { 1.0 } else { -0.5 };
279 let out = engine.update(&bar(i, price + 1.0, price - 1.0, price), 2.0);
280 if out.is_some() {
281 result = out;
282 }
283 }
284 assert!(
285 result.is_some(),
286 "expected SwingStructureOutput once enough legs are confirmed"
287 );
288 }
289
290 #[test]
291 fn zero_atr_yields_no_output() {
292 let mut engine = SwingStructureEngine::with_defaults();
293 assert!(engine.update(&bar(0, 101.0, 99.0, 100.0), 0.0).is_none());
294 }
295}