kestrel_chartkit/indicator/
swing_structure.rs1use crate::model::{Bar, SeriesCapabilities};
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 pub series_capabilities: Option<SeriesCapabilities>,
43}
44
45impl SwingStructureOutput {
46 pub fn with_capabilities(mut self, capabilities: SeriesCapabilities) -> Self {
49 self.series_capabilities = Some(capabilities);
50 self
51 }
52}
53
54struct Pivot {
55 index: usize,
57 price: f64,
58 is_high: bool,
59}
60
61pub struct SwingStructureEngine {
70 left: usize,
71 right: usize,
72 max_swings: usize,
73 bars: Vec<(usize, Bar)>,
74 pivots: Vec<Pivot>,
75 next_index: usize,
76}
77
78impl SwingStructureEngine {
79 pub fn new(left: usize, right: usize, max_swings: usize) -> Self {
80 Self {
81 left,
82 right,
83 max_swings,
84 bars: Vec::new(),
85 pivots: Vec::new(),
86 next_index: 0,
87 }
88 }
89
90 pub fn with_defaults() -> Self {
91 Self::new(5, 5, 50)
92 }
93
94 pub fn reset(&mut self) {
95 self.bars.clear();
96 self.pivots.clear();
97 self.next_index = 0;
98 }
99
100 pub fn update(&mut self, bar: &Bar, atr: f64) -> Option<SwingStructureOutput> {
103 self.bars.push((self.next_index, bar.clone()));
104 self.next_index += 1;
105 let max_history = (self.left + self.right + 1) * (self.max_swings + 2);
106 if self.bars.len() > max_history {
107 self.bars.remove(0);
108 }
109
110 if atr <= 0.0 {
111 return None;
112 }
113
114 self.detect_pivot();
115
116 if self.pivots.len() < 4 {
117 return None;
118 }
119
120 let mut legs: Vec<(f64, bool, usize)> = Vec::new();
122 for w in self.pivots.windows(2) {
123 let [a, b] = w else { continue };
124 let size = (b.price - a.price).abs() / atr;
125 let is_up = b.price > a.price;
126 let span = b.index.saturating_sub(a.index).max(1);
127 legs.push((size, is_up, span));
128 }
129 if legs.len() > self.max_swings {
130 let excess = legs.len() - self.max_swings;
131 legs.drain(0..excess);
132 }
133
134 let highs: Vec<f64> = self
135 .pivots
136 .iter()
137 .filter(|p| p.is_high)
138 .map(|p| p.price)
139 .collect();
140 let lows: Vec<f64> = self
141 .pivots
142 .iter()
143 .filter(|p| !p.is_high)
144 .map(|p| p.price)
145 .collect();
146 let h_last = highs.last().copied();
147 let h_prev = highs.get(highs.len().saturating_sub(2)).copied();
148 let l_last = lows.last().copied();
149 let l_prev = lows.get(lows.len().saturating_sub(2)).copied();
150
151 let bullish = highs.len() >= 2
152 && lows.len() >= 2
153 && matches!((h_last, h_prev, l_last, l_prev), (Some(hl), Some(hp), Some(ll), Some(lp)) if hl > hp && ll > lp);
154 let bearish = highs.len() >= 2
155 && lows.len() >= 2
156 && matches!((h_last, h_prev, l_last, l_prev), (Some(hl), Some(hp), Some(ll), Some(lp)) if hl < hp && ll < lp);
157 let trend_up = bullish || (!bearish && legs.last().map(|l| l.1).unwrap_or(true));
158
159 let mut impulses: Vec<f64> = Vec::new();
160 let mut corrections: Vec<f64> = Vec::new();
161 let mut impulse_velocities: Vec<f64> = Vec::new();
162 for &(size, is_up, span) in &legs {
163 if is_up == trend_up {
164 impulses.push(size);
165 impulse_velocities.push(size / span as f64);
166 } else {
167 corrections.push(size);
168 }
169 }
170
171 if impulses.len() < 2 || corrections.len() < 2 {
172 return None;
173 }
174
175 let median_impulse_atr = median(&impulses);
176 let median_correction_atr = median(&corrections);
177 let correction_impulse_ratio = if median_impulse_atr > 0.0 {
178 median_correction_atr / median_impulse_atr
179 } else {
180 0.0
181 };
182
183 let recent_corrections_atr: Vec<f64> = corrections.iter().rev().take(3).copied().collect();
184 let trend_quality = if recent_corrections_atr.len() >= 2 {
185 let newest = recent_corrections_atr.first().copied().unwrap_or(0.0);
186 let oldest = recent_corrections_atr.last().copied().unwrap_or(0.0);
187 if newest < oldest {
188 TrendQuality::Strengthening
189 } else if newest > oldest {
190 TrendQuality::Weakening
191 } else {
192 TrendQuality::Stable
193 }
194 } else {
195 TrendQuality::Stable
196 };
197
198 let last_swing_price = self.pivots.last().map(|p| p.price).unwrap_or(bar.close);
199 let current_retracement_atr = (bar.close - last_swing_price).abs() / atr;
200 let entry_zone_reached = current_retracement_atr >= median_correction_atr * 0.8;
201 let remaining_potential_atr = (median_impulse_atr - current_retracement_atr).max(0.0);
202 let required_stop_atr = median_correction_atr.max(0.1);
203 let potential_crv = if required_stop_atr > 0.0 {
204 remaining_potential_atr / required_stop_atr
205 } else {
206 0.0
207 };
208 let last_impulse_velocity_atr = impulse_velocities.last().copied().unwrap_or(0.0);
209
210 Some(SwingStructureOutput {
211 median_impulse_atr,
212 median_correction_atr,
213 correction_impulse_ratio,
214 recent_corrections_atr,
215 trend_quality,
216 current_retracement_atr,
217 entry_zone_reached,
218 remaining_potential_atr,
219 required_stop_atr,
220 potential_crv,
221 last_impulse_velocity_atr,
222 series_capabilities: None,
223 })
224 }
225
226 fn detect_pivot(&mut self) {
227 let req_len = self.left + self.right + 1;
228 if self.bars.len() < req_len {
229 return;
230 }
231 let candidate_idx = self.bars.len() - 1 - self.right;
232 let cand_high = self.bars[candidate_idx].1.high;
233 let cand_low = self.bars[candidate_idx].1.low;
234 let mut is_high = true;
235 let mut is_low = true;
236 for i in (candidate_idx - self.left)..=(candidate_idx + self.right) {
237 if i == candidate_idx {
238 continue;
239 }
240 if self.bars[i].1.high >= cand_high {
241 is_high = false;
242 }
243 if self.bars[i].1.low <= cand_low {
244 is_low = false;
245 }
246 }
247 let abs_index = self.bars[candidate_idx].0;
248 if is_high {
249 self.push_pivot(abs_index, cand_high, true);
250 }
251 if is_low {
252 self.push_pivot(abs_index, cand_low, false);
253 }
254 }
255
256 fn push_pivot(&mut self, index: usize, price: f64, is_high: bool) {
257 self.pivots.push(Pivot {
258 index,
259 price,
260 is_high,
261 });
262 if self.pivots.len() > self.max_swings * 2 + 4 {
263 self.pivots.remove(0);
264 }
265 }
266}
267
268fn median(values: &[f64]) -> f64 {
269 if values.is_empty() {
270 return 0.0;
271 }
272 let mut sorted = values.to_vec();
273 sorted.sort_by(f64::total_cmp);
274 let mid = sorted.len() / 2;
275 if sorted.len().is_multiple_of(2) {
276 let m1 = sorted.get(mid.saturating_sub(1)).copied().unwrap_or(0.0);
277 let m2 = sorted.get(mid).copied().unwrap_or(0.0);
278 (m1 + m2) / 2.0
279 } else {
280 sorted.get(mid).copied().unwrap_or(0.0)
281 }
282}
283
284#[cfg(test)]
285mod tests {
286 use super::*;
287
288 fn bar(t: i64, high: f64, low: f64, close: f64) -> Bar {
289 Bar::new(t, close, high, low, close, 100.0)
290 }
291
292 #[test]
293 fn zigzag_series_eventually_produces_output() {
294 let mut engine = SwingStructureEngine::new(2, 2, 10);
295 let mut result = None;
296 let mut price = 100.0;
298 for i in 0..80 {
299 let leg = i / 10;
300 let up = leg % 2 == 0;
301 price += if up { 1.0 } else { -0.5 };
302 let out = engine.update(&bar(i, price + 1.0, price - 1.0, price), 2.0);
303 if out.is_some() {
304 result = out;
305 }
306 }
307 assert!(
308 result.is_some(),
309 "expected SwingStructureOutput once enough legs are confirmed"
310 );
311 }
312
313 #[test]
314 fn zero_atr_yields_no_output() {
315 let mut engine = SwingStructureEngine::with_defaults();
316 assert!(engine.update(&bar(0, 101.0, 99.0, 100.0), 0.0).is_none());
317 }
318
319 fn sample_capabilities() -> SeriesCapabilities {
320 SeriesCapabilities {
321 volume: crate::model::VolumeKind::RealTurnover,
322 trade_direction: false,
323 session: crate::model::SessionKind::Regular,
324 continuity: crate::model::ContinuityKind::SingleContract,
325 price_adjustment: crate::model::PriceAdjustment::Raw,
326 provenance: crate::model::Provenance::Exchange,
327 liquidity_tier: crate::model::LiquidityTier::Deep,
328 }
329 }
330
331 #[test]
332 fn output_defaults_to_no_capabilities_and_can_be_tagged() {
333 let mut engine = SwingStructureEngine::new(2, 2, 10);
334 let mut result = None;
335 let mut price = 100.0;
336 for i in 0..80 {
337 let leg = i / 10;
338 let up = leg % 2 == 0;
339 price += if up { 1.0 } else { -0.5 };
340 let out = engine.update(&bar(i, price + 1.0, price - 1.0, price), 2.0);
341 if out.is_some() {
342 result = out;
343 }
344 }
345 let result = result.expect("expected SwingStructureOutput once enough legs are confirmed");
346 assert_eq!(result.series_capabilities, None);
347
348 let tagged = result.with_capabilities(sample_capabilities());
349 assert_eq!(tagged.series_capabilities, Some(sample_capabilities()));
350 }
351}