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::model::SeriesCapabilities;
13use crate::stats::rolling_median;
14
15use super::price_levels::{swing_fibonacci_levels, PriceLevel};
16use super::zigzag_advanced::ZigZagNode;
17
18/// A rule violation found while validating an impulse or correction.
19#[derive(Debug, Clone, PartialEq)]
20pub struct RuleViolation {
21    pub rule: String,
22    pub detail: String,
23}
24
25#[derive(Debug, Clone, Copy, PartialEq, Eq)]
26pub enum CorrectionVariant {
27    Zigzag,
28    Flat,
29    ExpandedFlat,
30}
31
32#[derive(Debug, Clone, PartialEq)]
33pub struct ImpulseValidation {
34    pub valid: bool,
35    pub violations: Vec<RuleViolation>,
36    /// How proportionally "clean" wave 2 and wave 4 are relative to common Fibonacci retracement
37    /// ratios (0.382/0.5/0.618): `1.0` = both land close to a standard ratio, decaying with
38    /// distance from the nearest one.
39    pub pullback_quality: f64,
40    /// Which series this validation was computed on, if the caller attached one via
41    /// [`ImpulseValidation::with_capabilities`]. `None` by default: a validation is only
42    /// meaningful for the series it was computed on (session cut, roll/adjustment, provenance),
43    /// so a stored/exported result should carry this rather than be re-checked
44    /// against a different series later without knowing it no longer applies.
45    pub series_capabilities: Option<SeriesCapabilities>,
46}
47
48impl ImpulseValidation {
49    /// Tags this validation with the series it was computed on. See
50    /// [`ImpulseValidation::series_capabilities`] for why this matters.
51    pub fn with_capabilities(mut self, capabilities: SeriesCapabilities) -> Self {
52        self.series_capabilities = Some(capabilities);
53        self
54    }
55}
56
57#[derive(Debug, Clone, PartialEq)]
58pub struct CorrectionValidation {
59    pub variant: CorrectionVariant,
60    pub valid: bool,
61    pub violations: Vec<RuleViolation>,
62    pub pullback_quality: f64,
63    /// See [`ImpulseValidation::series_capabilities`].
64    pub series_capabilities: Option<SeriesCapabilities>,
65}
66
67impl CorrectionValidation {
68    /// Tags this validation with the series it was computed on. See
69    /// [`ImpulseValidation::series_capabilities`] for why this matters.
70    pub fn with_capabilities(mut self, capabilities: SeriesCapabilities) -> Self {
71        self.series_capabilities = Some(capabilities);
72        self
73    }
74}
75
76fn nearest_fib_distance(ratio: f64) -> f64 {
77    const COMMON: [f64; 3] = [0.382, 0.5, 0.618];
78    COMMON
79        .iter()
80        .map(|r| (r - ratio).abs())
81        .fold(f64::INFINITY, f64::min)
82}
83
84/// Validates a 6-node bullish-or-bearish impulse labeled `[0, 1, 2, 3, 4, 5]` against Elliott's
85/// three cardinal rules: wave 2 never retraces beyond the start of wave 1, wave 3 is never the
86/// shortest of waves 1/3/5, and wave 4 never enters wave 1's price territory. Returns `None` if
87/// `nodes` does not have exactly 6 alternating entries.
88pub fn validate_impulse(nodes: &[ZigZagNode]) -> Option<ImpulseValidation> {
89    let [node0, node1, node2, node3, node4, node5] = nodes else {
90        return None;
91    };
92    if nodes.windows(2).any(|p| match p {
93        [a, b] => a.is_high == b.is_high,
94        _ => false,
95    }) {
96        return None;
97    }
98
99    let bullish = node1.price > node0.price;
100    let (w0, w1, w2, w3, w4, w5) = (
101        node0.price,
102        node1.price,
103        node2.price,
104        node3.price,
105        node4.price,
106        node5.price,
107    );
108
109    let mut violations = Vec::new();
110
111    let wave2_ok = if bullish { w2 > w0 } else { w2 < w0 };
112    if !wave2_ok {
113        violations.push(RuleViolation {
114            rule: "wave2_no_full_retrace".to_string(),
115            detail: "Wave 2 retraced beyond the start of wave 1".to_string(),
116        });
117    }
118
119    let len1 = (w1 - w0).abs();
120    let len3 = (w3 - w2).abs();
121    let len5 = (w5 - w4).abs();
122    if len3 < len1 && len3 < len5 {
123        violations.push(RuleViolation {
124            rule: "wave3_not_shortest".to_string(),
125            detail: "Wave 3 is the shortest of waves 1, 3, and 5".to_string(),
126        });
127    }
128
129    let wave4_ok = if bullish { w4 > w1 } else { w4 < w1 };
130    if !wave4_ok {
131        violations.push(RuleViolation {
132            rule: "wave4_no_overlap".to_string(),
133            detail: "Wave 4 entered wave 1's price territory".to_string(),
134        });
135    }
136
137    let retrace2 = if len1 > 0.0 {
138        (w0 - w2).abs() / len1
139    } else {
140        f64::INFINITY
141    };
142    let len34 = (w3 - w2).abs();
143    let retrace4 = if len34 > 0.0 {
144        (w3 - w4).abs() / len34
145    } else {
146        f64::INFINITY
147    };
148    let pullback_quality = if retrace2.is_finite() && retrace4.is_finite() {
149        let d2 = nearest_fib_distance(retrace2);
150        let d4 = nearest_fib_distance(retrace4);
151        (1.0 - (d2 + d4)).clamp(0.0, 1.0)
152    } else {
153        0.0
154    };
155
156    Some(ImpulseValidation {
157        valid: violations.is_empty(),
158        violations,
159        pullback_quality,
160        series_capabilities: None,
161    })
162}
163
164/// Validates a 4-node correction labeled `[0, A, B, C]`, classifying it as a Zigzag (B retraces
165/// less than 100% of A), Flat (B retraces close to 100% of A, C similar length to A), or Expanded
166/// Flat (B exceeds the start of the move that preceded A). Returns `None` if `nodes` does not
167/// have exactly 4 alternating entries.
168pub fn validate_correction(nodes: &[ZigZagNode]) -> Option<CorrectionValidation> {
169    let [node0, node1, node2, node3] = nodes else {
170        return None;
171    };
172    if nodes.windows(2).any(|p| match p {
173        [a, b] => a.is_high == b.is_high,
174        _ => false,
175    }) {
176        return None;
177    }
178
179    let bearish_correction = node1.price > node0.price; // 0->A moves down within an uptrend correction, etc.; use magnitude only
180    let _ = bearish_correction;
181
182    let (n0, a, b, c) = (node0.price, node1.price, node2.price, node3.price);
183    let leg_a = (a - n0).abs();
184    let leg_b_retrace = if leg_a > 0.0 {
185        (b - a).abs() / leg_a
186    } else {
187        f64::INFINITY
188    };
189    let leg_c = (c - b).abs();
190    let c_vs_a = if leg_a > 0.0 {
191        leg_c / leg_a
192    } else {
193        f64::INFINITY
194    };
195
196    let variant = if leg_b_retrace >= 1.0 {
197        CorrectionVariant::ExpandedFlat
198    } else if leg_b_retrace >= 0.90 {
199        CorrectionVariant::Flat
200    } else {
201        CorrectionVariant::Zigzag
202    };
203
204    let mut violations = Vec::new();
205    // C must continue past B in the same direction as A (a genuine 3-wave correction, not a
206    // reversal back through the start).
207    let a_dir_down = a < n0;
208    let c_continues = if a_dir_down { c < b } else { c > b };
209    if !c_continues {
210        violations.push(RuleViolation {
211            rule: "wave_c_must_extend_past_b".to_string(),
212            detail: "Wave C did not continue past wave B in wave A's direction".to_string(),
213        });
214    }
215
216    if variant == CorrectionVariant::Zigzag && leg_b_retrace > 0.786 {
217        violations.push(RuleViolation {
218            rule: "zigzag_b_retrace_bound".to_string(),
219            detail: "Wave B retraced more than a Zigzag's typical bound (78.6%) without qualifying as a Flat".to_string(),
220        });
221    }
222
223    let quality_ref = match variant {
224        CorrectionVariant::Zigzag => nearest_fib_distance(leg_b_retrace.min(1.0)),
225        CorrectionVariant::Flat | CorrectionVariant::ExpandedFlat => {
226            (1.0 - c_vs_a.min(2.0) / 1.0).abs().min(1.0)
227        }
228    };
229    let pullback_quality = (1.0 - quality_ref).clamp(0.0, 1.0);
230
231    Some(CorrectionValidation {
232        variant,
233        valid: violations.is_empty(),
234        violations,
235        pullback_quality,
236        series_capabilities: None,
237    })
238}
239
240/// Projects Fibonacci "C-setup" target levels from a validated correction's A and B legs, reusing
241/// [`swing_fibonacci_levels`] rather than a separate ratio table. `is_uptrend` matches that
242/// function's convention: `true` if wave A ran low-to-high.
243pub fn c_setup_levels(wave_a_start: f64, wave_a_end: f64, is_uptrend: bool) -> Vec<PriceLevel> {
244    let (high, low) = if wave_a_end >= wave_a_start {
245        (wave_a_end, wave_a_start)
246    } else {
247        (wave_a_start, wave_a_end)
248    };
249    swing_fibonacci_levels(high, low, is_uptrend)
250}
251
252/// Where in an impulse-shaped 6-node sequence a diagonal sits. Leading diagonals occupy wave 1 (or
253/// A); ending diagonals occupy wave 5 (or C) — the distinction does not change the geometry check
254/// below, only which rule relaxations apply in the surrounding theory.
255#[derive(Debug, Clone, Copy, PartialEq, Eq)]
256pub enum DiagonalKind {
257    Leading,
258    Ending,
259}
260
261/// Whether the two boundary lines (through waves 1-3-5 and 2-4) converge or diverge. Approximated
262/// here from wave lengths rather than line intersection: a contracting diagonal's waves shrink
263/// leg over leg, an expanding diagonal's waves grow leg over leg. This is the same simplification
264/// [`super::chart_patterns`] makes for wedges — a converging/diverging channel read off the pivots
265/// rather than a fitted line.
266#[derive(Debug, Clone, Copy, PartialEq, Eq)]
267pub enum DiagonalVariant {
268    Contracting,
269    Expanding,
270}
271
272#[derive(Debug, Clone, PartialEq)]
273pub struct DiagonalValidation {
274    pub kind: DiagonalKind,
275    pub variant: DiagonalVariant,
276    pub valid: bool,
277    pub violations: Vec<RuleViolation>,
278    /// Whether wave 4 overlaps wave 1's price territory — expected and permitted in a diagonal,
279    /// unlike a plain impulse. Carried through rather than discarded because it is the one
280    /// property that distinguishes a diagonal from an impulse at the rule level.
281    pub wave4_overlaps_wave1: bool,
282    /// See [`ImpulseValidation::series_capabilities`].
283    pub series_capabilities: Option<SeriesCapabilities>,
284}
285
286impl DiagonalValidation {
287    /// Tags this validation with the series it was computed on. See
288    /// [`ImpulseValidation::series_capabilities`] for why this matters.
289    pub fn with_capabilities(mut self, capabilities: SeriesCapabilities) -> Self {
290        self.series_capabilities = Some(capabilities);
291        self
292    }
293}
294
295/// Validates a 6-node diagonal labeled `[0, 1, 2, 3, 4, 5]` — same shape as
296/// [`validate_impulse`], with rule 3 relaxed (wave 4 may enter wave 1's territory) and rule 2
297/// retained (wave 3 is never the shortest). Returns `None` if `nodes` does not have exactly 6
298/// alternating entries.
299pub fn validate_diagonal(nodes: &[ZigZagNode], kind: DiagonalKind) -> Option<DiagonalValidation> {
300    let [node0, node1, node2, node3, node4, node5] = nodes else {
301        return None;
302    };
303    if nodes.windows(2).any(|p| match p {
304        [a, b] => a.is_high == b.is_high,
305        _ => false,
306    }) {
307        return None;
308    }
309
310    let bullish = node1.price > node0.price;
311    let (w0, w1, w2, w3, w4, w5) = (
312        node0.price,
313        node1.price,
314        node2.price,
315        node3.price,
316        node4.price,
317        node5.price,
318    );
319
320    let mut violations = Vec::new();
321
322    let wave2_ok = if bullish { w2 > w0 } else { w2 < w0 };
323    if !wave2_ok {
324        violations.push(RuleViolation {
325            rule: "wave2_no_full_retrace".to_string(),
326            detail: "Wave 2 retraced beyond the start of wave 1".to_string(),
327        });
328    }
329
330    let len1 = (w1 - w0).abs();
331    let len2 = (w2 - w1).abs();
332    let len3 = (w3 - w2).abs();
333    let len4 = (w4 - w3).abs();
334    let len5 = (w5 - w4).abs();
335    if len3 < len1 && len3 < len5 {
336        violations.push(RuleViolation {
337            rule: "wave3_not_shortest".to_string(),
338            detail: "Wave 3 is the shortest of waves 1, 3, and 5".to_string(),
339        });
340    }
341
342    let wave4_overlaps_wave1 = if bullish { w4 <= w1 } else { w4 >= w1 };
343
344    let variant = if len5 < len3 && len3 < len1 && len4 < len2 {
345        DiagonalVariant::Contracting
346    } else {
347        DiagonalVariant::Expanding
348    };
349
350    Some(DiagonalValidation {
351        kind,
352        variant,
353        valid: violations.is_empty(),
354        violations,
355        wave4_overlaps_wave1,
356        series_capabilities: None,
357    })
358}
359
360/// How the two boundary lines of a 5-leg (`a-b-c-d-e`) triangle relate. Contracting and expanding
361/// mirror [`DiagonalVariant`]; `RunningOrBarrier` covers the case where leg `d` runs past the end
362/// of leg `b` instead of staying inside it — the two variants share a geometry check and are kept
363/// as one classification rather than split, since distinguishing them further needs the trend
364/// context the six pivots alone don't carry.
365#[derive(Debug, Clone, Copy, PartialEq, Eq)]
366pub enum TriangleVariant {
367    Contracting,
368    Expanding,
369    RunningOrBarrier,
370}
371
372#[derive(Debug, Clone, PartialEq)]
373pub struct TriangleValidation {
374    pub variant: TriangleVariant,
375    /// See [`ImpulseValidation::series_capabilities`].
376    pub series_capabilities: Option<SeriesCapabilities>,
377}
378
379impl TriangleValidation {
380    /// Tags this validation with the series it was computed on. See
381    /// [`ImpulseValidation::series_capabilities`] for why this matters.
382    pub fn with_capabilities(mut self, capabilities: SeriesCapabilities) -> Self {
383        self.series_capabilities = Some(capabilities);
384        self
385    }
386}
387
388/// Classifies a 6-node, 5-leg sequence labeled `[0, a, b, c, d, e]` as a contracting, expanding, or
389/// running/barrier triangle. Triangles carry no hard rules of their own in Elliott's own writing —
390/// only the classification varies — so unlike [`validate_diagonal`] this returns no violations.
391/// Returns `None` if `nodes` does not have exactly 6 alternating entries.
392pub fn validate_triangle(nodes: &[ZigZagNode]) -> Option<TriangleValidation> {
393    let [node0, node1, node2, node3, node4, node5] = nodes else {
394        return None;
395    };
396    if nodes.windows(2).any(|p| match p {
397        [a, b] => a.is_high == b.is_high,
398        _ => false,
399    }) {
400        return None;
401    }
402
403    // Classified from the two boundary lines, not from leg lengths. The boundaries are what the
404    // names describe: a contracting triangle's converge, an expanding triangle's diverge. Leg
405    // lengths follow from that but do not determine it — a sequence can shrink leg over leg and
406    // still have both boundaries running the same way, which is the barrier/running case.
407    //
408    // `node0` is the point the triangle starts from and belongs to neither boundary; the five legs
409    // it opens are `a`-`e`. For a sequence starting at a low, `node1`/`node3`/`node5` are the
410    // highs and `node2`/`node4` the lows; starting at a high it is the other way round. Both
411    // orientations reduce to the same two questions, so they are asked once, on the extremes.
412    let von_tief = node1.price > node0.price;
413    let (erstes_extrem, zweites_extrem, drittes_extrem) = (node1.price, node3.price, node5.price);
414    let (erste_gegenseite, zweite_gegenseite) = (node2.price, node4.price);
415
416    let obere_faellt = if von_tief {
417        zweites_extrem < erstes_extrem && drittes_extrem < zweites_extrem
418    } else {
419        zweite_gegenseite < erste_gegenseite
420    };
421    let untere_steigt = if von_tief {
422        zweite_gegenseite > erste_gegenseite
423    } else {
424        zweites_extrem > erstes_extrem && drittes_extrem > zweites_extrem
425    };
426
427    let obere_steigt = if von_tief {
428        zweites_extrem > erstes_extrem && drittes_extrem > zweites_extrem
429    } else {
430        zweite_gegenseite > erste_gegenseite
431    };
432    let untere_faellt = if von_tief {
433        zweite_gegenseite < erste_gegenseite
434    } else {
435        zweites_extrem < erstes_extrem && drittes_extrem < zweites_extrem
436    };
437
438    let variant = if obere_faellt && untere_steigt {
439        TriangleVariant::Contracting
440    } else if obere_steigt && untere_faellt {
441        TriangleVariant::Expanding
442    } else {
443        TriangleVariant::RunningOrBarrier
444    };
445
446    Some(TriangleValidation {
447        variant,
448        series_capabilities: None,
449    })
450}
451
452/// A W-X-Y or W-X-Y-X-Z combination correction: two or three simple corrections
453/// ([`validate_correction`]) joined by connecting "X" waves.
454#[derive(Debug, Clone, Copy, PartialEq, Eq)]
455pub enum CombinationVariant {
456    /// W-X-Y: a double three.
457    DoubleThree,
458    /// W-X-Y-X-Z: a triple three.
459    TripleThree,
460}
461
462#[derive(Debug, Clone, PartialEq)]
463pub struct CombinationValidation {
464    pub variant: CombinationVariant,
465    /// One validated correction per W/Y(/Z) segment, in order.
466    pub segments: Vec<CorrectionValidation>,
467    /// `true` if every segment in `segments` is individually valid.
468    pub valid: bool,
469    /// See [`ImpulseValidation::series_capabilities`].
470    pub series_capabilities: Option<SeriesCapabilities>,
471}
472
473impl CombinationValidation {
474    /// Tags this validation with the series it was computed on. See
475    /// [`ImpulseValidation::series_capabilities`] for why this matters.
476    pub fn with_capabilities(mut self, capabilities: SeriesCapabilities) -> Self {
477        self.series_capabilities = Some(capabilities);
478        self
479    }
480}
481
482/// Validates a combination correction from its full pivot sequence: 8 nodes for a W-X-Y double
483/// three (`[0, Wa, Wb, Wc, X, Ya, Yb, Yc]`), 12 for a W-X-Y-X-Z triple three (append
484/// `[X2, Za, Zb, Zc]`). Each connecting "X" wave is treated as a single atomic leg rather than a
485/// correction of its own — consistent with how [`validate_impulse`] and [`validate_diagonal`]
486/// treat each numbered wave as atomic rather than recursing into its sub-degree. Returns `None` if
487/// `nodes` is not exactly 8 or 12 nodes, or not alternating.
488pub fn validate_combination(nodes: &[ZigZagNode]) -> Option<CombinationValidation> {
489    if nodes.len() != 8 && nodes.len() != 12 {
490        return None;
491    }
492    if nodes.windows(2).any(|p| match p {
493        [a, b] => a.is_high == b.is_high,
494        _ => false,
495    }) {
496        return None;
497    }
498
499    let variant = if nodes.len() == 8 {
500        CombinationVariant::DoubleThree
501    } else {
502        CombinationVariant::TripleThree
503    };
504
505    // Each correction segment is 4 nodes: `[start, a, b, c]`. The connecting X leg is the single
506    // move from one segment's `c` to the next segment's `start` — those are adjacent nodes in
507    // `nodes` (e.g. `Wc` at index 3, `X` at index 4), not a shared one, and the next segment's
508    // `start` (`X`) is where the following correction begins. So segments simply tile `nodes` in
509    // non-overlapping groups of 4: `[0..4]`, `[4..8]`, ...
510    let segment_count = nodes.len() / 4;
511    let mut segments = Vec::with_capacity(segment_count);
512    for i in 0..segment_count {
513        let start = i * 4;
514        let segment = &nodes[start..start + 4];
515        let validation = validate_correction(segment)?;
516        segments.push(validation);
517    }
518
519    let valid = segments.iter().all(|s| s.valid);
520
521    Some(CombinationValidation {
522        variant,
523        segments,
524        valid,
525        series_capabilities: None,
526    })
527}
528
529/// Which of waves 1, 3, or 5 is "extended" — long enough relative to the other two that it reads
530/// as the dominant wave of the impulse. Elliott's guideline: extended when its length is at least
531/// 1.618 times the longer of the other two. Returns `None` if no wave qualifies, or if `nodes` is
532/// not exactly 6 alternating entries.
533pub fn identify_extended_wave(nodes: &[ZigZagNode]) -> Option<u8> {
534    let [node0, node1, node2, node3, node4, node5] = nodes else {
535        return None;
536    };
537    if nodes.windows(2).any(|p| match p {
538        [a, b] => a.is_high == b.is_high,
539        _ => false,
540    }) {
541        return None;
542    }
543
544    let len1 = (node1.price - node0.price).abs();
545    let len3 = (node3.price - node2.price).abs();
546    let len5 = (node5.price - node4.price).abs();
547
548    const EXTENSION_RATIO: f64 = 1.618;
549
550    if len1 >= EXTENSION_RATIO * len3.max(len5) {
551        Some(1)
552    } else if len3 >= EXTENSION_RATIO * len1.max(len5) {
553        Some(3)
554    } else if len5 >= EXTENSION_RATIO * len1.max(len3) {
555        Some(5)
556    } else {
557        None
558    }
559}
560
561/// A truncated (failed) fifth: wave 5 fails to move beyond wave 3's extreme, even though the three
562/// cardinal rules ([`validate_impulse`]) may still all hold — this is a guideline violation, not a
563/// rule violation. Returns `None` if `nodes` is not exactly 6 alternating entries.
564pub fn is_truncated_fifth(nodes: &[ZigZagNode]) -> Option<bool> {
565    let [node0, node1, _node2, node3, _node4, node5] = nodes else {
566        return None;
567    };
568    if nodes.windows(2).any(|p| match p {
569        [a, b] => a.is_high == b.is_high,
570        _ => false,
571    }) {
572        return None;
573    }
574
575    let bullish = node1.price > node0.price;
576    Some(if bullish {
577        node5.price <= node3.price
578    } else {
579        node5.price >= node3.price
580    })
581}
582
583/// Empirically tracks how often (and by how much) price has historically reacted at each standard
584/// Fibonacci ratio bucket, so future expectations can be calibrated from actual observed behavior
585/// instead of textbook assumptions alone.
586#[derive(Debug, Clone, Default)]
587pub struct FibonacciReactionMemory {
588    /// One bucket per ratio in [`super::price_levels::FIBONACCI_RATIOS`]: observed reaction
589    /// magnitudes (in ATR units) recorded at that level.
590    observations: Vec<(f64, Vec<f64>)>,
591}
592
593impl FibonacciReactionMemory {
594    pub fn new() -> Self {
595        let observations = super::price_levels::FIBONACCI_RATIOS
596            .iter()
597            .map(|&r| (r, Vec::new()))
598            .collect();
599        Self { observations }
600    }
601
602    /// Records a reaction magnitude (in ATR units) observed at the ratio nearest to `ratio`.
603    pub fn record(&mut self, ratio: f64, reaction_magnitude_atr: f64) {
604        if let Some((_, bucket)) = self
605            .observations
606            .iter_mut()
607            .min_by(|(a, _), (b, _)| (a - ratio).abs().total_cmp(&(b - ratio).abs()))
608        {
609            bucket.push(reaction_magnitude_atr);
610        }
611    }
612
613    /// Median observed reaction magnitude at the ratio nearest to `ratio`. `None` if that bucket
614    /// has no observations yet.
615    pub fn median_reaction(&self, ratio: f64) -> Option<f64> {
616        self.observations
617            .iter()
618            .min_by(|(a, _), (b, _)| (a - ratio).abs().total_cmp(&(b - ratio).abs()))
619            .filter(|(_, bucket)| !bucket.is_empty())
620            .map(|(_, bucket)| rolling_median(bucket))
621    }
622
623    pub fn observation_count(&self, ratio: f64) -> usize {
624        self.observations
625            .iter()
626            .min_by(|(a, _), (b, _)| (a - ratio).abs().total_cmp(&(b - ratio).abs()))
627            .map(|(_, bucket)| bucket.len())
628            .unwrap_or(0)
629    }
630}
631
632#[cfg(test)]
633mod tests {
634    use super::*;
635
636    fn node(ts: i64, price: f64, is_high: bool) -> ZigZagNode {
637        ZigZagNode {
638            timestamp: ts,
639            price,
640            is_high,
641            confirmed: true,
642        }
643    }
644
645    #[test]
646    fn test_valid_bullish_impulse_passes_all_rules() {
647        let nodes = vec![
648            node(0, 100.0, false), // 0
649            node(1, 120.0, true),  // 1
650            node(2, 110.0, false), // 2 (retraces 50% of wave1, doesn't undercut 0)
651            node(3, 140.0, true),  // 3 (longest leg)
652            node(4, 130.0, false), // 4 (stays above wave1 high=120)
653            node(5, 150.0, true),  // 5
654        ];
655        let result = validate_impulse(&nodes).unwrap();
656        assert!(result.valid, "violations: {:?}", result.violations);
657        assert!(result.pullback_quality > 0.0);
658    }
659
660    #[test]
661    fn test_impulse_rejects_wave2_full_retrace() {
662        let nodes = vec![
663            node(0, 100.0, false),
664            node(1, 120.0, true),
665            node(2, 95.0, false), // retraces beyond wave 1 start (100)
666            node(3, 140.0, true),
667            node(4, 130.0, false),
668            node(5, 150.0, true),
669        ];
670        let result = validate_impulse(&nodes).unwrap();
671        assert!(!result.valid);
672        assert!(result
673            .violations
674            .iter()
675            .any(|v| v.rule == "wave2_no_full_retrace"));
676    }
677
678    #[test]
679    fn test_impulse_rejects_wave4_overlap() {
680        let nodes = vec![
681            node(0, 100.0, false),
682            node(1, 120.0, true),
683            node(2, 110.0, false),
684            node(3, 140.0, true),
685            node(4, 115.0, false), // overlaps wave 1 territory (below 120)
686            node(5, 150.0, true),
687        ];
688        let result = validate_impulse(&nodes).unwrap();
689        assert!(!result.valid);
690        assert!(result
691            .violations
692            .iter()
693            .any(|v| v.rule == "wave4_no_overlap"));
694    }
695
696    #[test]
697    fn test_impulse_rejects_wave3_shortest() {
698        let nodes = vec![
699            node(0, 100.0, false),
700            node(1, 130.0, true), // wave1 = 30
701            node(2, 120.0, false),
702            node(3, 135.0, true), // wave3 = 15 (shortest)
703            node(4, 125.0, false),
704            node(5, 160.0, true), // wave5 = 35
705        ];
706        let result = validate_impulse(&nodes).unwrap();
707        assert!(!result.valid);
708        assert!(result
709            .violations
710            .iter()
711            .any(|v| v.rule == "wave3_not_shortest"));
712    }
713
714    #[test]
715    fn test_validate_impulse_requires_exactly_six_alternating_nodes() {
716        let too_few = vec![node(0, 100.0, false), node(1, 120.0, true)];
717        assert!(validate_impulse(&too_few).is_none());
718
719        let non_alternating = vec![
720            node(0, 100.0, false),
721            node(1, 120.0, false),
722            node(2, 110.0, false),
723            node(3, 140.0, true),
724            node(4, 130.0, false),
725            node(5, 150.0, true),
726        ];
727        assert!(validate_impulse(&non_alternating).is_none());
728    }
729
730    #[test]
731    fn test_correction_classifies_zigzag_vs_flat() {
732        let zigzag = vec![
733            node(0, 150.0, true),
734            node(1, 130.0, false), // A: -20
735            node(2, 141.0, true),  // B retraces 55% of A -> zigzag
736            node(3, 120.0, false), // C
737        ];
738        let result = validate_correction(&zigzag).unwrap();
739        assert_eq!(result.variant, CorrectionVariant::Zigzag);
740
741        let flat = vec![
742            node(0, 150.0, true),
743            node(1, 130.0, false), // A: -20
744            node(2, 149.0, true),  // B retraces 95% of A -> flat
745            node(3, 128.0, false), // C
746        ];
747        let result = validate_correction(&flat).unwrap();
748        assert_eq!(result.variant, CorrectionVariant::Flat);
749    }
750
751    #[test]
752    fn test_correction_rejects_c_not_extending_past_b() {
753        // A runs down (150 -> 130), B retraces up to 141; a valid C must continue down past B
754        // (below 141). Here C instead prints above B, violating the rule.
755        let nodes = vec![
756            node(0, 150.0, true),
757            node(1, 130.0, false),
758            node(2, 141.0, true),
759            node(3, 145.0, false),
760        ];
761        let result = validate_correction(&nodes).unwrap();
762        assert!(!result.valid);
763        assert!(result
764            .violations
765            .iter()
766            .any(|v| v.rule == "wave_c_must_extend_past_b"));
767    }
768
769    #[test]
770    fn test_c_setup_levels_delegate_to_swing_fibonacci() {
771        let levels = c_setup_levels(100.0, 150.0, true);
772        assert_eq!(
773            levels.len(),
774            super::super::price_levels::FIBONACCI_RATIOS.len()
775        );
776    }
777
778    #[test]
779    fn test_reaction_memory_buckets_by_nearest_ratio() {
780        let mut memory = FibonacciReactionMemory::new();
781        memory.record(0.62, 1.5);
782        memory.record(0.615, 1.7);
783        memory.record(0.235, 0.5);
784
785        assert_eq!(memory.observation_count(0.618), 2);
786        let median = memory.median_reaction(0.618).unwrap();
787        assert!((median - 1.6).abs() < 0.2);
788        assert_eq!(memory.observation_count(0.236), 1);
789    }
790
791    fn sample_capabilities() -> SeriesCapabilities {
792        SeriesCapabilities {
793            volume: crate::model::VolumeKind::RealTurnover,
794            trade_direction: false,
795            session: crate::model::SessionKind::Regular,
796            continuity: crate::model::ContinuityKind::SingleContract,
797            price_adjustment: crate::model::PriceAdjustment::Raw,
798            provenance: crate::model::Provenance::Exchange,
799            liquidity_tier: crate::model::LiquidityTier::Deep,
800        }
801    }
802
803    #[test]
804    fn test_impulse_validation_defaults_to_no_capabilities_and_can_be_tagged() {
805        let nodes = vec![
806            node(0, 100.0, false),
807            node(1, 120.0, true),
808            node(2, 110.0, false),
809            node(3, 140.0, true),
810            node(4, 130.0, false),
811            node(5, 150.0, true),
812        ];
813        let result = validate_impulse(&nodes).unwrap();
814        assert_eq!(result.series_capabilities, None);
815
816        let tagged = result.with_capabilities(sample_capabilities());
817        assert_eq!(tagged.series_capabilities, Some(sample_capabilities()));
818    }
819
820    #[test]
821    fn test_correction_validation_defaults_to_no_capabilities_and_can_be_tagged() {
822        let nodes = vec![
823            node(0, 150.0, true),
824            node(1, 130.0, false),
825            node(2, 141.0, true),
826            node(3, 100.0, false),
827        ];
828        let result = validate_correction(&nodes).unwrap();
829        assert_eq!(result.series_capabilities, None);
830
831        let tagged = result.with_capabilities(sample_capabilities());
832        assert_eq!(tagged.series_capabilities, Some(sample_capabilities()));
833    }
834
835    #[test]
836    fn test_diagonal_allows_wave4_overlap_that_would_fail_a_plain_impulse() {
837        // Same fixture as test_impulse_rejects_wave4_overlap: wave 4 (115) enters wave 1's
838        // territory (below the wave-1 high of 120), which rejects a plain impulse but is exactly
839        // the relaxation a diagonal grants.
840        let nodes = vec![
841            node(0, 100.0, false),
842            node(1, 120.0, true),
843            node(2, 110.0, false),
844            node(3, 140.0, true),
845            node(4, 115.0, false),
846            node(5, 150.0, true),
847        ];
848        assert!(validate_impulse(&nodes)
849            .unwrap()
850            .violations
851            .iter()
852            .any(|v| v.rule == "wave4_no_overlap"));
853
854        let result = validate_diagonal(&nodes, DiagonalKind::Ending).unwrap();
855        assert!(result.valid, "violations: {:?}", result.violations);
856        assert!(result.wave4_overlaps_wave1);
857    }
858
859    #[test]
860    fn test_diagonal_still_rejects_wave2_full_retrace() {
861        let nodes = vec![
862            node(0, 100.0, false),
863            node(1, 120.0, true),
864            node(2, 95.0, false),
865            node(3, 140.0, true),
866            node(4, 130.0, false),
867            node(5, 150.0, true),
868        ];
869        let result = validate_diagonal(&nodes, DiagonalKind::Leading).unwrap();
870        assert!(!result.valid);
871        assert!(result
872            .violations
873            .iter()
874            .any(|v| v.rule == "wave2_no_full_retrace"));
875    }
876
877    #[test]
878    fn test_diagonal_classifies_contracting_vs_expanding() {
879        let contracting = vec![
880            node(0, 100.0, false),
881            node(1, 120.0, true),  // len1 = 20
882            node(2, 110.0, false), // len2 = 10
883            node(3, 125.0, true),  // len3 = 15
884            node(4, 116.0, false), // len4 = 9, overlaps wave 1 (<=120)
885            node(5, 124.0, true),  // len5 = 8
886        ];
887        let result = validate_diagonal(&contracting, DiagonalKind::Ending).unwrap();
888        assert!(result.valid, "violations: {:?}", result.violations);
889        assert_eq!(result.variant, DiagonalVariant::Contracting);
890
891        let expanding = vec![
892            node(0, 100.0, false),
893            node(1, 110.0, true),  // len1 = 10
894            node(2, 102.0, false), // len2 = 8
895            node(3, 125.0, true),  // len3 = 23
896            node(4, 90.0, false),  // len4 = 35, overlaps wave 1 (<=110)
897            node(5, 140.0, true),  // len5 = 50
898        ];
899        let result = validate_diagonal(&expanding, DiagonalKind::Leading).unwrap();
900        assert!(result.valid, "violations: {:?}", result.violations);
901        assert_eq!(result.variant, DiagonalVariant::Expanding);
902    }
903
904    #[test]
905    fn test_triangle_classifies_contracting_expanding_and_running() {
906        // Converging boundaries: the highs fall (130 > 125 > 122) while the lows rise (110 < 115).
907        let contracting = vec![
908            node(0, 100.0, false),
909            node(1, 130.0, true),
910            node(2, 110.0, false),
911            node(3, 125.0, true),
912            node(4, 115.0, false),
913            node(5, 122.0, true),
914        ];
915        assert_eq!(
916            validate_triangle(&contracting).unwrap().variant,
917            TriangleVariant::Contracting
918        );
919
920        // Diverging boundaries: the highs rise (110 < 120 < 140) while the lows fall (95 > 85).
921        let expanding = vec![
922            node(0, 100.0, false),
923            node(1, 110.0, true),
924            node(2, 95.0, false),
925            node(3, 120.0, true),
926            node(4, 85.0, false),
927            node(5, 140.0, true),
928        ];
929        assert_eq!(
930            validate_triangle(&expanding).unwrap().variant,
931            TriangleVariant::Expanding
932        );
933
934        // Both boundaries fall: the highs (130 > 125 > 120) and the lows (110 > 108) run the same
935        // way. Neither converging nor diverging — the running/barrier case.
936        let running = vec![
937            node(0, 100.0, false),
938            node(1, 130.0, true),
939            node(2, 110.0, false),
940            node(3, 125.0, true),
941            node(4, 108.0, false),
942            node(5, 120.0, true),
943        ];
944        assert_eq!(
945            validate_triangle(&running).unwrap().variant,
946            TriangleVariant::RunningOrBarrier
947        );
948
949        // The same three cases mirrored: a triangle that starts from a high. The classification
950        // must not depend on which side the sequence opens with.
951        let contracting_von_hoch = vec![
952            node(0, 140.0, true),
953            node(1, 110.0, false),
954            node(2, 130.0, true),
955            node(3, 115.0, false),
956            node(4, 125.0, true),
957            node(5, 118.0, false),
958        ];
959        assert_eq!(
960            validate_triangle(&contracting_von_hoch).unwrap().variant,
961            TriangleVariant::Contracting
962        );
963    }
964
965    #[test]
966    fn test_validate_combination_classifies_wxy_from_two_valid_corrections() {
967        let nodes = vec![
968            node(0, 150.0, true),
969            node(1, 130.0, false), // W: leg A
970            node(2, 141.0, true),  // W: leg B, 55% retrace -> zigzag
971            node(3, 120.0, false), // W: leg C, also the X wave's start
972            node(4, 140.0, true),  // X wave's end, also Y's start
973            node(5, 125.0, false), // Y: leg A
974            node(6, 134.0, true),  // Y: leg B, 60% retrace -> zigzag
975            node(7, 112.0, false), // Y: leg C
976        ];
977        let result = validate_combination(&nodes).unwrap();
978        assert_eq!(result.variant, CombinationVariant::DoubleThree);
979        assert_eq!(result.segments.len(), 2);
980        assert!(result.valid, "segments: {:?}", result.segments);
981        assert!(result
982            .segments
983            .iter()
984            .all(|s| s.variant == CorrectionVariant::Zigzag));
985    }
986
987    #[test]
988    fn test_validate_combination_rejects_wrong_node_count() {
989        let too_few = vec![node(0, 100.0, false), node(1, 120.0, true)];
990        assert!(validate_combination(&too_few).is_none());
991    }
992
993    #[test]
994    fn test_identify_extended_wave() {
995        let wave3_extended = vec![
996            node(0, 100.0, false),
997            node(1, 110.0, true), // len1 = 10
998            node(2, 105.0, false),
999            node(3, 145.0, true), // len3 = 40
1000            node(4, 135.0, false),
1001            node(5, 150.0, true), // len5 = 5
1002        ];
1003        assert_eq!(identify_extended_wave(&wave3_extended), Some(3));
1004
1005        let no_extension = vec![
1006            node(0, 100.0, false),
1007            node(1, 120.0, true), // len1 = 20
1008            node(2, 110.0, false),
1009            node(3, 142.0, true), // len3 = 22
1010            node(4, 130.0, false),
1011            node(5, 148.0, true), // len5 = 18
1012        ];
1013        assert_eq!(identify_extended_wave(&no_extension), None);
1014    }
1015
1016    #[test]
1017    fn test_is_truncated_fifth() {
1018        let truncated = vec![
1019            node(0, 100.0, false),
1020            node(1, 130.0, true),
1021            node(2, 115.0, false),
1022            node(3, 150.0, true),
1023            node(4, 135.0, false),
1024            node(5, 145.0, true), // fails to exceed wave 3's high (150)
1025        ];
1026        assert_eq!(is_truncated_fifth(&truncated), Some(true));
1027
1028        let not_truncated = vec![
1029            node(0, 100.0, false),
1030            node(1, 130.0, true),
1031            node(2, 115.0, false),
1032            node(3, 150.0, true),
1033            node(4, 135.0, false),
1034            node(5, 160.0, true),
1035        ];
1036        assert_eq!(is_truncated_fifth(&not_truncated), Some(false));
1037    }
1038}