1use crate::stats::rolling_median;
13
14use super::price_levels::{swing_fibonacci_levels, PriceLevel};
15use super::zigzag_advanced::ZigZagNode;
16
17#[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 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
57pub 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
133pub 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; 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 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
210pub 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#[derive(Debug, Clone, Default)]
226pub struct FibonacciReactionMemory {
227 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 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 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), node(1, 120.0, true), node(2, 110.0, false), node(3, 140.0, true), node(4, 130.0, false), node(5, 150.0, true), ];
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), 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), 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), node(2, 120.0, false),
341 node(3, 135.0, true), node(4, 125.0, false),
343 node(5, 160.0, true), ];
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), node(2, 141.0, true), node(3, 120.0, false), ];
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), node(2, 149.0, true), node(3, 128.0, false), ];
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 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}