Skip to main content

kestrel_chartkit/indicator/
swing_structure.rs

1use crate::model::{Bar, SeriesCapabilities};
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    /// Which series this output was computed on, if the caller attached one via
39    /// [`SwingStructureOutput::with_capabilities`]. `None` by default: a structure result is
40    /// only meaningful for the series (session cut,
41    /// roll/adjustment, provenance) it was computed on.
42    pub series_capabilities: Option<SeriesCapabilities>,
43}
44
45impl SwingStructureOutput {
46    /// Tags this output with the series it was computed on. See
47    /// [`SwingStructureOutput::series_capabilities`] for why this matters.
48    pub fn with_capabilities(mut self, capabilities: SeriesCapabilities) -> Self {
49        self.series_capabilities = Some(capabilities);
50        self
51    }
52}
53
54struct Pivot {
55    /// Absolute bar index (monotonically increasing, survives window trimming).
56    index: usize,
57    price: f64,
58    is_high: bool,
59}
60
61/// Detects swing pivots and derives `SwingStructureOutput`. Reuses the same left/right pivot
62/// window as `pivots_structure::PivotStructureEngine`, but exposes the underlying leg sizes
63/// instead of a single bounded score.
64///
65/// This is a composite/consumer of another indicator's output, not a pure `Indicator`: it
66/// needs the *raw* ATR in price units on every bar, not the `%`-of-price value that
67/// `indicator::atr::Atr` returns. Convert via `atr_raw = atr_pct / 100.0 * bar.close` before
68/// calling `update`.
69pub 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    /// Feed one bar plus the current raw ATR (price units, not `%`). Returns a fresh
101    /// `SwingStructureOutput` once enough alternating swing legs have been confirmed.
102    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        // Legs between consecutive confirmed pivots: (size in ATR, is-up-leg, bar span).
121        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        // Alternating up/down legs so pivots + impulses/corrections accumulate.
297        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}