Skip to main content

kestrel_chartkit/indicator/
elliott.rs

1//! Elliott Wave and Fibonacci pattern validation: rule-checked impulses and corrections (Zigzag/
2//! Flat variants), C-setup projection, pullback quality scoring, and a reaction-memory tracker —
3//! built on [`super::zigzag_advanced::ZigZagNode`] sequences, reusing
4//! [`super::price_levels::swing_fibonacci_levels`] for level projection rather than duplicating
5//! the ratio table.
6//!
7//! Wave counting is inherently interpretive; this validates a *given* labeling against Elliott's
8//! documented structural rules (not heuristics about which count is "right") and scores how
9//! Fibonacci-clean the retracements are — a rule checker and quality scorer, not a wave counter
10//! that discovers labelings on its own.
11
12use crate::stats::rolling_median;
13
14use super::price_levels::{swing_fibonacci_levels, PriceLevel};
15use super::zigzag_advanced::ZigZagNode;
16
17/// A rule violation found while validating an impulse or correction.
18#[derive(Debug, Clone, PartialEq)]
19pub struct RuleViolation {
20    pub rule: String,
21    pub detail: String,
22}
23
24#[derive(Debug, Clone, Copy, PartialEq, Eq)]
25pub enum CorrectionVariant {
26    Zigzag,
27    Flat,
28    ExpandedFlat,
29}
30
31#[derive(Debug, Clone, PartialEq)]
32pub struct ImpulseValidation {
33    pub valid: bool,
34    pub violations: Vec<RuleViolation>,
35    /// How proportionally "clean" wave 2 and wave 4 are relative to common Fibonacci retracement
36    /// ratios (0.382/0.5/0.618): `1.0` = both land close to a standard ratio, decaying with
37    /// distance from the nearest one.
38    pub pullback_quality: f64,
39}
40
41#[derive(Debug, Clone, PartialEq)]
42pub struct CorrectionValidation {
43    pub variant: CorrectionVariant,
44    pub valid: bool,
45    pub violations: Vec<RuleViolation>,
46    pub pullback_quality: f64,
47}
48
49fn nearest_fib_distance(ratio: f64) -> f64 {
50    const COMMON: [f64; 3] = [0.382, 0.5, 0.618];
51    COMMON
52        .iter()
53        .map(|r| (r - ratio).abs())
54        .fold(f64::INFINITY, f64::min)
55}
56
57/// Validates a 6-node bullish-or-bearish impulse labeled `[0, 1, 2, 3, 4, 5]` against Elliott's
58/// three cardinal rules: wave 2 never retraces beyond the start of wave 1, wave 3 is never the
59/// shortest of waves 1/3/5, and wave 4 never enters wave 1's price territory. Returns `None` if
60/// `nodes` does not have exactly 6 alternating entries.
61pub fn validate_impulse(nodes: &[ZigZagNode]) -> Option<ImpulseValidation> {
62    if nodes.len() != 6 {
63        return None;
64    }
65    if nodes.windows(2).any(|p| p[0].is_high == p[1].is_high) {
66        return None;
67    }
68
69    let bullish = nodes[1].price > nodes[0].price;
70    let (w0, w1, w2, w3, w4, w5) = (
71        nodes[0].price,
72        nodes[1].price,
73        nodes[2].price,
74        nodes[3].price,
75        nodes[4].price,
76        nodes[5].price,
77    );
78
79    let mut violations = Vec::new();
80
81    let wave2_ok = if bullish { w2 > w0 } else { w2 < w0 };
82    if !wave2_ok {
83        violations.push(RuleViolation {
84            rule: "wave2_no_full_retrace".to_string(),
85            detail: "Wave 2 retraced beyond the start of wave 1".to_string(),
86        });
87    }
88
89    let len1 = (w1 - w0).abs();
90    let len3 = (w3 - w2).abs();
91    let len5 = (w5 - w4).abs();
92    if len3 < len1 && len3 < len5 {
93        violations.push(RuleViolation {
94            rule: "wave3_not_shortest".to_string(),
95            detail: "Wave 3 is the shortest of waves 1, 3, and 5".to_string(),
96        });
97    }
98
99    let wave4_ok = if bullish { w4 > w1 } else { w4 < w1 };
100    if !wave4_ok {
101        violations.push(RuleViolation {
102            rule: "wave4_no_overlap".to_string(),
103            detail: "Wave 4 entered wave 1's price territory".to_string(),
104        });
105    }
106
107    let retrace2 = if len1 > 0.0 {
108        (w0 - w2).abs() / len1
109    } else {
110        f64::INFINITY
111    };
112    let len34 = (w3 - w2).abs();
113    let retrace4 = if len34 > 0.0 {
114        (w3 - w4).abs() / len34
115    } else {
116        f64::INFINITY
117    };
118    let pullback_quality = if retrace2.is_finite() && retrace4.is_finite() {
119        let d2 = nearest_fib_distance(retrace2);
120        let d4 = nearest_fib_distance(retrace4);
121        (1.0 - (d2 + d4)).clamp(0.0, 1.0)
122    } else {
123        0.0
124    };
125
126    Some(ImpulseValidation {
127        valid: violations.is_empty(),
128        violations,
129        pullback_quality,
130    })
131}
132
133/// Validates a 4-node correction labeled `[0, A, B, C]`, classifying it as a Zigzag (B retraces
134/// less than 100% of A), Flat (B retraces close to 100% of A, C similar length to A), or Expanded
135/// Flat (B exceeds the start of the move that preceded A). Returns `None` if `nodes` does not
136/// have exactly 4 alternating entries.
137pub fn validate_correction(nodes: &[ZigZagNode]) -> Option<CorrectionValidation> {
138    if nodes.len() != 4 {
139        return None;
140    }
141    if nodes.windows(2).any(|p| p[0].is_high == p[1].is_high) {
142        return None;
143    }
144
145    let bearish_correction = nodes[1].price > nodes[0].price; // 0->A moves down within an uptrend correction, etc.; use magnitude only
146    let _ = bearish_correction;
147
148    let (n0, a, b, c) = (
149        nodes[0].price,
150        nodes[1].price,
151        nodes[2].price,
152        nodes[3].price,
153    );
154    let leg_a = (a - n0).abs();
155    let leg_b_retrace = if leg_a > 0.0 {
156        (b - a).abs() / leg_a
157    } else {
158        f64::INFINITY
159    };
160    let leg_c = (c - b).abs();
161    let c_vs_a = if leg_a > 0.0 {
162        leg_c / leg_a
163    } else {
164        f64::INFINITY
165    };
166
167    let variant = if leg_b_retrace >= 1.0 {
168        CorrectionVariant::ExpandedFlat
169    } else if leg_b_retrace >= 0.90 {
170        CorrectionVariant::Flat
171    } else {
172        CorrectionVariant::Zigzag
173    };
174
175    let mut violations = Vec::new();
176    // C must continue past B in the same direction as A (a genuine 3-wave correction, not a
177    // reversal back through the start).
178    let a_dir_down = a < n0;
179    let c_continues = if a_dir_down { c < b } else { c > b };
180    if !c_continues {
181        violations.push(RuleViolation {
182            rule: "wave_c_must_extend_past_b".to_string(),
183            detail: "Wave C did not continue past wave B in wave A's direction".to_string(),
184        });
185    }
186
187    if variant == CorrectionVariant::Zigzag && leg_b_retrace > 0.786 {
188        violations.push(RuleViolation {
189            rule: "zigzag_b_retrace_bound".to_string(),
190            detail: "Wave B retraced more than a Zigzag's typical bound (78.6%) without qualifying as a Flat".to_string(),
191        });
192    }
193
194    let quality_ref = match variant {
195        CorrectionVariant::Zigzag => nearest_fib_distance(leg_b_retrace.min(1.0)),
196        CorrectionVariant::Flat | CorrectionVariant::ExpandedFlat => {
197            (1.0 - c_vs_a.min(2.0) / 1.0).abs().min(1.0)
198        }
199    };
200    let pullback_quality = (1.0 - quality_ref).clamp(0.0, 1.0);
201
202    Some(CorrectionValidation {
203        variant,
204        valid: violations.is_empty(),
205        violations,
206        pullback_quality,
207    })
208}
209
210/// Projects Fibonacci "C-setup" target levels from a validated correction's A and B legs, reusing
211/// [`swing_fibonacci_levels`] rather than a separate ratio table. `is_uptrend` matches that
212/// function's convention: `true` if wave A ran low-to-high.
213pub fn c_setup_levels(wave_a_start: f64, wave_a_end: f64, is_uptrend: bool) -> Vec<PriceLevel> {
214    let (high, low) = if wave_a_end >= wave_a_start {
215        (wave_a_end, wave_a_start)
216    } else {
217        (wave_a_start, wave_a_end)
218    };
219    swing_fibonacci_levels(high, low, is_uptrend)
220}
221
222/// Empirically tracks how often (and by how much) price has historically reacted at each standard
223/// Fibonacci ratio bucket, so future expectations can be calibrated from actual observed behavior
224/// instead of textbook assumptions alone.
225#[derive(Debug, Clone, Default)]
226pub struct FibonacciReactionMemory {
227    /// One bucket per ratio in [`super::price_levels::FIBONACCI_RATIOS`]: observed reaction
228    /// magnitudes (in ATR units) recorded at that level.
229    observations: Vec<(f64, Vec<f64>)>,
230}
231
232impl FibonacciReactionMemory {
233    pub fn new() -> Self {
234        let observations = super::price_levels::FIBONACCI_RATIOS
235            .iter()
236            .map(|&r| (r, Vec::new()))
237            .collect();
238        Self { observations }
239    }
240
241    /// Records a reaction magnitude (in ATR units) observed at the ratio nearest to `ratio`.
242    pub fn record(&mut self, ratio: f64, reaction_magnitude_atr: f64) {
243        if let Some((_, bucket)) = self
244            .observations
245            .iter_mut()
246            .min_by(|(a, _), (b, _)| (a - ratio).abs().total_cmp(&(b - ratio).abs()))
247        {
248            bucket.push(reaction_magnitude_atr);
249        }
250    }
251
252    /// Median observed reaction magnitude at the ratio nearest to `ratio`. `None` if that bucket
253    /// has no observations yet.
254    pub fn median_reaction(&self, ratio: f64) -> Option<f64> {
255        self.observations
256            .iter()
257            .min_by(|(a, _), (b, _)| (a - ratio).abs().total_cmp(&(b - ratio).abs()))
258            .filter(|(_, bucket)| !bucket.is_empty())
259            .map(|(_, bucket)| rolling_median(bucket))
260    }
261
262    pub fn observation_count(&self, ratio: f64) -> usize {
263        self.observations
264            .iter()
265            .min_by(|(a, _), (b, _)| (a - ratio).abs().total_cmp(&(b - ratio).abs()))
266            .map(|(_, bucket)| bucket.len())
267            .unwrap_or(0)
268    }
269}
270
271#[cfg(test)]
272mod tests {
273    use super::*;
274
275    fn node(ts: i64, price: f64, is_high: bool) -> ZigZagNode {
276        ZigZagNode {
277            timestamp: ts,
278            price,
279            is_high,
280            confirmed: true,
281        }
282    }
283
284    #[test]
285    fn test_valid_bullish_impulse_passes_all_rules() {
286        let nodes = vec![
287            node(0, 100.0, false), // 0
288            node(1, 120.0, true),  // 1
289            node(2, 110.0, false), // 2 (retraces 50% of wave1, doesn't undercut 0)
290            node(3, 140.0, true),  // 3 (longest leg)
291            node(4, 130.0, false), // 4 (stays above wave1 high=120)
292            node(5, 150.0, true),  // 5
293        ];
294        let result = validate_impulse(&nodes).unwrap();
295        assert!(result.valid, "violations: {:?}", result.violations);
296        assert!(result.pullback_quality > 0.0);
297    }
298
299    #[test]
300    fn test_impulse_rejects_wave2_full_retrace() {
301        let nodes = vec![
302            node(0, 100.0, false),
303            node(1, 120.0, true),
304            node(2, 95.0, false), // retraces beyond wave 1 start (100)
305            node(3, 140.0, true),
306            node(4, 130.0, false),
307            node(5, 150.0, true),
308        ];
309        let result = validate_impulse(&nodes).unwrap();
310        assert!(!result.valid);
311        assert!(result
312            .violations
313            .iter()
314            .any(|v| v.rule == "wave2_no_full_retrace"));
315    }
316
317    #[test]
318    fn test_impulse_rejects_wave4_overlap() {
319        let nodes = vec![
320            node(0, 100.0, false),
321            node(1, 120.0, true),
322            node(2, 110.0, false),
323            node(3, 140.0, true),
324            node(4, 115.0, false), // overlaps wave 1 territory (below 120)
325            node(5, 150.0, true),
326        ];
327        let result = validate_impulse(&nodes).unwrap();
328        assert!(!result.valid);
329        assert!(result
330            .violations
331            .iter()
332            .any(|v| v.rule == "wave4_no_overlap"));
333    }
334
335    #[test]
336    fn test_impulse_rejects_wave3_shortest() {
337        let nodes = vec![
338            node(0, 100.0, false),
339            node(1, 130.0, true), // wave1 = 30
340            node(2, 120.0, false),
341            node(3, 135.0, true), // wave3 = 15 (shortest)
342            node(4, 125.0, false),
343            node(5, 160.0, true), // wave5 = 35
344        ];
345        let result = validate_impulse(&nodes).unwrap();
346        assert!(!result.valid);
347        assert!(result
348            .violations
349            .iter()
350            .any(|v| v.rule == "wave3_not_shortest"));
351    }
352
353    #[test]
354    fn test_validate_impulse_requires_exactly_six_alternating_nodes() {
355        let too_few = vec![node(0, 100.0, false), node(1, 120.0, true)];
356        assert!(validate_impulse(&too_few).is_none());
357
358        let non_alternating = vec![
359            node(0, 100.0, false),
360            node(1, 120.0, false),
361            node(2, 110.0, false),
362            node(3, 140.0, true),
363            node(4, 130.0, false),
364            node(5, 150.0, true),
365        ];
366        assert!(validate_impulse(&non_alternating).is_none());
367    }
368
369    #[test]
370    fn test_correction_classifies_zigzag_vs_flat() {
371        let zigzag = vec![
372            node(0, 150.0, true),
373            node(1, 130.0, false), // A: -20
374            node(2, 141.0, true),  // B retraces 55% of A -> zigzag
375            node(3, 120.0, false), // C
376        ];
377        let result = validate_correction(&zigzag).unwrap();
378        assert_eq!(result.variant, CorrectionVariant::Zigzag);
379
380        let flat = vec![
381            node(0, 150.0, true),
382            node(1, 130.0, false), // A: -20
383            node(2, 149.0, true),  // B retraces 95% of A -> flat
384            node(3, 128.0, false), // C
385        ];
386        let result = validate_correction(&flat).unwrap();
387        assert_eq!(result.variant, CorrectionVariant::Flat);
388    }
389
390    #[test]
391    fn test_correction_rejects_c_not_extending_past_b() {
392        // A runs down (150 -> 130), B retraces up to 141; a valid C must continue down past B
393        // (below 141). Here C instead prints above B, violating the rule.
394        let nodes = vec![
395            node(0, 150.0, true),
396            node(1, 130.0, false),
397            node(2, 141.0, true),
398            node(3, 145.0, false),
399        ];
400        let result = validate_correction(&nodes).unwrap();
401        assert!(!result.valid);
402        assert!(result
403            .violations
404            .iter()
405            .any(|v| v.rule == "wave_c_must_extend_past_b"));
406    }
407
408    #[test]
409    fn test_c_setup_levels_delegate_to_swing_fibonacci() {
410        let levels = c_setup_levels(100.0, 150.0, true);
411        assert_eq!(
412            levels.len(),
413            super::super::price_levels::FIBONACCI_RATIOS.len()
414        );
415    }
416
417    #[test]
418    fn test_reaction_memory_buckets_by_nearest_ratio() {
419        let mut memory = FibonacciReactionMemory::new();
420        memory.record(0.62, 1.5);
421        memory.record(0.615, 1.7);
422        memory.record(0.235, 0.5);
423
424        assert_eq!(memory.observation_count(0.618), 2);
425        let median = memory.median_reaction(0.618).unwrap();
426        assert!((median - 1.6).abs() < 0.2);
427        assert_eq!(memory.observation_count(0.236), 1);
428    }
429}