Skip to main content

kestrel_chartkit/indicator/
swing_structure.rs

1use crate::model::Bar;
2
3#[cfg(feature = "serde")]
4use serde::{Deserialize, Serialize};
5
6/// Trend-quality classification derived from the recent correction/impulse ratio trend
7/// (plan Anhang D).
8#[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/// Statistical Swing-Structure output (plan Anhang D): trend strength expressed via the
21/// relative size of consecutive impulse/correction legs, ATR-normalized, instead of a
22/// binary "trend up/down" flag.
23#[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    /// Most recent corrections first (newest → oldest), in ATR.
30    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    /// Absolute bar index (monotonically increasing, survives window trimming).
42    index: usize,
43    price: f64,
44    is_high: bool,
45}
46
47/// Detects swing pivots and derives `SwingStructureOutput`. Reuses the same left/right pivot
48/// window as `pivots_structure::PivotStructureEngine`, but exposes the underlying leg sizes
49/// instead of a single bounded score.
50///
51/// This is a composite/consumer of another indicator's output, not a pure `Indicator`: it
52/// needs the *raw* ATR in price units on every bar, not the `%`-of-price value that
53/// `indicator::atr::Atr` returns. Convert via `atr_raw = atr_pct / 100.0 * bar.close` before
54/// calling `update`.
55pub 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    /// Feed one bar plus the current raw ATR (price units, not `%`). Returns a fresh
87    /// `SwingStructureOutput` once enough alternating swing legs have been confirmed.
88    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        // Legs between consecutive confirmed pivots: (size in ATR, is-up-leg, bar span).
107        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        // Alternating up/down legs so pivots + impulses/corrections accumulate.
274        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}