1use super::zigzag_advanced::ZigZagNode;
28use crate::model::Bar;
29
30#[derive(Debug, Clone, Copy, PartialEq)]
32pub struct RatioRange {
33 pub min: f64,
34 pub max: f64,
35}
36
37impl RatioRange {
38 pub const fn new(min: f64, max: f64) -> Self {
39 Self { min, max }
40 }
41
42 pub fn contains(&self, value: f64, tolerance: f64) -> bool {
44 value >= self.min - tolerance && value <= self.max + tolerance
45 }
46
47 fn center(&self) -> f64 {
48 (self.min + self.max) / 2.0
49 }
50}
51
52#[derive(Debug, Clone, Copy, PartialEq)]
54pub struct HarmonicDefinition {
55 pub name: &'static str,
56 pub b: RatioRange,
58 pub c: RatioRange,
60 pub d: RatioRange,
62 pub cd: RatioRange,
64}
65
66pub const HARMONIC_TABLE: &[HarmonicDefinition] = &[
71 HarmonicDefinition {
72 name: "gartley",
73 b: RatioRange::new(0.586, 0.65),
74 c: RatioRange::new(0.382, 0.886),
75 d: RatioRange::new(0.75, 0.82),
76 cd: RatioRange::new(1.13, 1.618),
77 },
78 HarmonicDefinition {
79 name: "bat",
80 b: RatioRange::new(0.382, 0.5),
81 c: RatioRange::new(0.382, 0.886),
82 d: RatioRange::new(0.85, 0.92),
83 cd: RatioRange::new(1.618, 2.618),
84 },
85 HarmonicDefinition {
86 name: "butterfly",
87 b: RatioRange::new(0.75, 0.82),
88 c: RatioRange::new(0.382, 0.886),
89 d: RatioRange::new(1.13, 1.618),
90 cd: RatioRange::new(1.618, 2.618),
91 },
92 HarmonicDefinition {
93 name: "crab",
94 b: RatioRange::new(0.382, 0.618),
95 c: RatioRange::new(0.382, 0.886),
96 d: RatioRange::new(1.5, 1.7),
97 cd: RatioRange::new(2.24, 3.618),
98 },
99 HarmonicDefinition {
100 name: "deep_crab",
101 b: RatioRange::new(0.85, 0.92),
102 c: RatioRange::new(0.382, 0.886),
103 d: RatioRange::new(1.5, 1.7),
104 cd: RatioRange::new(2.24, 3.618),
105 },
106 HarmonicDefinition {
107 name: "shark",
108 b: RatioRange::new(0.382, 0.618),
109 c: RatioRange::new(1.13, 1.618),
110 d: RatioRange::new(0.85, 1.13),
111 cd: RatioRange::new(1.618, 2.24),
112 },
113];
114
115#[derive(Debug, Clone, Copy, PartialEq, Eq)]
117pub enum HarmonicState {
118 Candidate,
123 Complete,
125 Confirmed,
127 Invalidated,
129}
130
131#[derive(Debug, Clone, Copy, PartialEq)]
133pub struct Prz {
134 pub low: f64,
135 pub high: f64,
136}
137
138impl Prz {
139 pub fn contains(&self, price: f64) -> bool {
140 price >= self.low && price <= self.high
141 }
142
143 pub fn width(&self) -> f64 {
144 self.high - self.low
145 }
146}
147
148#[derive(Debug, Clone, PartialEq)]
150pub struct HarmonicCandidate {
151 pub name: &'static str,
152 pub bullish: bool,
154 pub x: f64,
155 pub a: f64,
156 pub b: f64,
157 pub c: f64,
158 pub d: Option<f64>,
160 pub prz: Prz,
161 pub state: HarmonicState,
162 pub ratios: (f64, f64, Option<f64>, Option<f64>),
164}
165
166impl HarmonicCandidate {
167 pub fn update_state(&mut self, bar: &Bar) -> HarmonicState {
173 match self.state {
174 HarmonicState::Confirmed | HarmonicState::Invalidated => self.state,
175 HarmonicState::Candidate => {
176 let reached = if self.bullish {
177 bar.low <= self.prz.high
178 } else {
179 bar.high >= self.prz.low
180 };
181 if reached {
182 self.d = Some(if self.bullish { bar.low } else { bar.high });
183 self.state = HarmonicState::Complete;
184 }
185 if self.ran_through(bar) {
186 self.state = HarmonicState::Invalidated;
187 }
188 self.state
189 }
190 HarmonicState::Complete => {
191 if self.ran_through(bar) {
192 self.state = HarmonicState::Invalidated;
193 } else {
194 let turned = if self.bullish {
195 bar.close > self.prz.high
196 } else {
197 bar.close < self.prz.low
198 };
199 if turned {
200 self.state = HarmonicState::Confirmed;
201 }
202 }
203 self.state
204 }
205 }
206 }
207
208 fn ran_through(&self, bar: &Bar) -> bool {
209 if self.bullish {
210 bar.close < self.prz.low
211 } else {
212 bar.close > self.prz.high
213 }
214 }
215}
216
217#[derive(Debug, Clone, Copy, PartialEq)]
219pub struct HarmonicConfig {
220 pub ratio_tolerance: f64,
222 pub min_prz_share: f64,
224}
225
226impl Default for HarmonicConfig {
227 fn default() -> Self {
228 Self {
229 ratio_tolerance: 0.03,
230 min_prz_share: 0.005,
231 }
232 }
233}
234
235pub struct HarmonicDetector {
237 pub config: HarmonicConfig,
238}
239
240impl Default for HarmonicDetector {
241 fn default() -> Self {
242 Self::new(HarmonicConfig::default())
243 }
244}
245
246impl HarmonicDetector {
247 pub fn new(config: HarmonicConfig) -> Self {
248 Self { config }
249 }
250
251 pub fn scan(&self, nodes: &[ZigZagNode]) -> Vec<HarmonicCandidate> {
253 let mut out = Vec::new();
254 for window in nodes.windows(5) {
255 if !window.windows(2).all(|p| p[0].is_high != p[1].is_high) {
256 continue;
257 }
258 let (x, a, b, c, d) = (
259 window[0].price,
260 window[1].price,
261 window[2].price,
262 window[3].price,
263 window[4].price,
264 );
265 let Some((b_r, c_r, d_r, cd_r)) = measure(x, a, b, c, Some(d)) else {
266 continue;
267 };
268 let (Some(d_r), Some(cd_r)) = (d_r, cd_r) else {
269 continue;
270 };
271
272 for def in HARMONIC_TABLE {
273 let t = self.config.ratio_tolerance;
274 if def.b.contains(b_r, t)
275 && def.c.contains(c_r, t)
276 && def.d.contains(d_r, t)
277 && def.cd.contains(cd_r, t)
278 {
279 let prz = self.prz(def, x, a, b, c);
280 out.push(HarmonicCandidate {
281 name: def.name,
282 bullish: !window[0].is_high,
283 x,
284 a,
285 b,
286 c,
287 d: Some(d),
288 prz,
289 state: HarmonicState::Complete,
290 ratios: (b_r, c_r, Some(d_r), Some(cd_r)),
291 });
292 }
293 }
294 }
295 out
296 }
297
298 pub fn scan_candidates(&self, nodes: &[ZigZagNode]) -> Vec<HarmonicCandidate> {
300 let mut out = Vec::new();
301 for window in nodes.windows(4) {
302 if !window.windows(2).all(|p| p[0].is_high != p[1].is_high) {
303 continue;
304 }
305 let (x, a, b, c) = (
306 window[0].price,
307 window[1].price,
308 window[2].price,
309 window[3].price,
310 );
311 let Some((b_r, c_r, _, _)) = measure(x, a, b, c, None) else {
312 continue;
313 };
314
315 for def in HARMONIC_TABLE {
316 let t = self.config.ratio_tolerance;
317 if def.b.contains(b_r, t) && def.c.contains(c_r, t) {
318 out.push(HarmonicCandidate {
319 name: def.name,
320 bullish: !window[0].is_high,
321 x,
322 a,
323 b,
324 c,
325 d: None,
326 prz: self.prz(def, x, a, b, c),
327 state: HarmonicState::Candidate,
328 ratios: (b_r, c_r, None, None),
329 });
330 }
331 }
332 }
333 out
334 }
335
336 fn prz(&self, def: &HarmonicDefinition, x: f64, a: f64, b: f64, c: f64) -> Prz {
341 let xa = a - x;
342 let bc = c - b;
343 let ab = b - a;
344
345 let from_xa = a - def.d.center() * xa;
346 let from_bc = c + def.cd.center() * (-bc);
347 let from_abcd = c + ab;
348
349 let mut low = from_xa.min(from_bc).min(from_abcd);
350 let mut high = from_xa.max(from_bc).max(from_abcd);
351
352 let floor = self.config.min_prz_share * xa.abs();
355 if high - low < floor {
356 let mid = (high + low) / 2.0;
357 low = mid - floor / 2.0;
358 high = mid + floor / 2.0;
359 }
360 Prz { low, high }
361 }
362}
363
364fn measure(
366 x: f64,
367 a: f64,
368 b: f64,
369 c: f64,
370 d: Option<f64>,
371) -> Option<(f64, f64, Option<f64>, Option<f64>)> {
372 let xa = (a - x).abs();
373 let ab = (b - a).abs();
374 let bc = (c - b).abs();
375 if xa <= f64::EPSILON || ab <= f64::EPSILON || bc <= f64::EPSILON {
376 return None;
377 }
378 let b_ratio = ab / xa;
379 let c_ratio = bc / ab;
380 let (d_ratio, cd_ratio) = match d {
381 Some(d) => (Some((a - d).abs() / xa), Some((d - c).abs() / bc)),
382 None => (None, None),
383 };
384 Some((b_ratio, c_ratio, d_ratio, cd_ratio))
385}
386
387#[cfg(test)]
388mod tests {
389 use super::*;
390
391 fn node(ts: i64, price: f64, is_high: bool) -> ZigZagNode {
392 ZigZagNode {
393 timestamp: ts,
394 price,
395 is_high,
396 confirmed: true,
397 }
398 }
399
400 fn gartley_nodes() -> Vec<ZigZagNode> {
405 vec![
406 node(0, 100.0, false),
407 node(600, 120.0, true),
408 node(1200, 107.64, false),
409 node(1800, 113.82, true),
410 node(2400, 104.28, false),
411 ]
412 }
413
414 #[test]
415 fn measures_the_ratios_of_the_table() {
416 let (b, c, d, cd) = measure(100.0, 120.0, 107.64, 113.82, Some(104.28)).unwrap();
417 assert!((b - 0.618).abs() < 1e-9, "b = {b}");
418 assert!((c - 0.5).abs() < 1e-9, "c = {c}");
419 assert!((d.unwrap() - 0.786).abs() < 1e-9);
420 assert!((cd.unwrap() - 9.54 / 6.18).abs() < 1e-9);
422 }
423
424 #[test]
425 fn recognises_a_gartley() {
426 let found = HarmonicDetector::default().scan(&gartley_nodes());
427 assert!(found.iter().any(|h| h.name == "gartley"), "{found:?}");
428 let g = found.iter().find(|h| h.name == "gartley").unwrap();
429 assert!(g.bullish, "X is a low, so D resolves upward");
430 assert_eq!(g.state, HarmonicState::Complete);
431 }
432
433 #[test]
434 fn the_tolerance_decides_whether_it_exists() {
435 let mut nodes = gartley_nodes();
437 nodes[2].price = 108.0;
438
439 let weit = HarmonicDetector::default();
440 assert!(weit.scan(&nodes).iter().any(|h| h.name == "gartley"));
441
442 let eng = HarmonicDetector::new(HarmonicConfig {
443 ratio_tolerance: 0.0,
444 ..HarmonicConfig::default()
445 });
446 assert!(!eng.scan(&nodes).iter().any(|h| h.name == "gartley"));
447 }
448
449 #[test]
450 fn a_candidate_has_no_d_but_a_zone() {
451 let nodes = &gartley_nodes()[..4];
452 let candidates = HarmonicDetector::default().scan_candidates(nodes);
453 let g = candidates
454 .iter()
455 .find(|h| h.name == "gartley")
456 .expect("candidate");
457 assert_eq!(g.state, HarmonicState::Candidate);
458 assert!(g.d.is_none());
459 assert!(g.prz.width() > 0.0, "a zone, never a line");
460 assert!(
461 g.prz.contains(104.28),
462 "the actual D lies inside: {:?}",
463 g.prz
464 );
465 }
466
467 #[test]
468 fn the_zone_is_never_a_line() {
469 let d = HarmonicDetector::default();
471 let prz = d.prz(&HARMONIC_TABLE[0], 100.0, 120.0, 107.64, 113.82);
472 assert!(prz.width() >= 0.005 * 20.0);
473 }
474
475 #[test]
476 fn a_candidate_completes_and_confirms() {
477 let mut c = HarmonicDetector::default()
478 .scan_candidates(&gartley_nodes()[..4])
479 .into_iter()
480 .find(|h| h.name == "gartley")
481 .expect("candidate");
482
483 assert_eq!(
485 c.update_state(&Bar::new(3000, 110.0, 111.0, 109.0, 110.0, 1.0)),
486 HarmonicState::Candidate
487 );
488 let mitte = (c.prz.low + c.prz.high) / 2.0;
490 assert_eq!(
491 c.update_state(&Bar::new(3600, 106.0, 106.5, mitte, mitte + 0.2, 1.0)),
492 HarmonicState::Complete
493 );
494 assert_eq!(
496 c.update_state(&Bar::new(4200, 106.0, 112.0, 105.8, 111.0, 1.0)),
497 HarmonicState::Confirmed
498 );
499 }
500
501 #[test]
502 fn running_through_the_zone_invalidates() {
503 let mut c = HarmonicDetector::default()
504 .scan_candidates(&gartley_nodes()[..4])
505 .into_iter()
506 .find(|h| h.name == "gartley")
507 .expect("candidate");
508 assert_eq!(
509 c.update_state(&Bar::new(3600, 106.0, 106.5, 90.0, 91.0, 1.0)),
510 HarmonicState::Invalidated
511 );
512 assert_eq!(
514 c.update_state(&Bar::new(4200, 91.0, 120.0, 91.0, 119.0, 1.0)),
515 HarmonicState::Invalidated
516 );
517 }
518
519 #[test]
520 fn a_butterfly_extends_past_x_and_a_gartley_does_not() {
521 let gartley = HARMONIC_TABLE.iter().find(|d| d.name == "gartley").unwrap();
522 let butterfly = HARMONIC_TABLE
523 .iter()
524 .find(|d| d.name == "butterfly")
525 .unwrap();
526 assert!(gartley.d.max < 1.0, "retracement stays inside XA");
527 assert!(butterfly.d.min > 1.0, "extension runs past X");
528 }
529}