Skip to main content

wickra_core/indicators/
shark.rs

1//! Shark harmonic pattern.
2
3use crate::indicators::pattern_swing::{ratios_in, xabcd, SwingTracker, SWING_THRESHOLD};
4use crate::ohlcv::Candle;
5use crate::traits::Indicator;
6
7/// Shark — a 5-point (X-A-B-C-D) harmonic pattern characterised by an
8/// **expansion** leg (AB longer than XA) and a `0.886`–`1.13` D completion:
9///
10/// ```text
11/// AB / XA ∈ [1.13, 1.618]  (expansion — B overshoots X)
12/// BC / AB ∈ [1.618, 2.24]
13/// CD / BC ∈ [0.382, 0.886]
14/// AD / XA ∈ [0.886, 1.13]  (the defining D completion near A)
15/// ```
16///
17/// This is the 5-point reading of the Shark; output is `+1.0` (bullish, D a
18/// swing low), `-1.0` (bearish, D a swing high), or `0.0`; never `None`. See
19/// `crates/wickra-core/src/indicators/shark.rs`.
20#[derive(Debug, Clone)]
21pub struct Shark {
22    swing: SwingTracker,
23    has_emitted: bool,
24}
25
26impl Shark {
27    /// Construct a new Shark detector.
28    pub const fn new() -> Self {
29        Self {
30            swing: SwingTracker::new(SWING_THRESHOLD, 5),
31            has_emitted: false,
32        }
33    }
34}
35
36impl Default for Shark {
37    fn default() -> Self {
38        Self::new()
39    }
40}
41
42impl Indicator for Shark {
43    type Input = Candle;
44    type Output = f64;
45
46    #[inline]
47    fn update(&mut self, candle: Candle) -> Option<f64> {
48        let advanced = self.swing.update(candle);
49        let pivots = self.swing.pivots();
50        // Too few pivots to form the shape at all: the indicator cannot
51        // judge yet, which is what `None` means.
52        if pivots.len() < 5 {
53            return None;
54        }
55        self.has_emitted = true;
56        // Armed, but this bar did not close a new pivot, so there is
57        // nothing new to match against.
58        if !advanced {
59            return Some(0.0);
60        }
61        let p = xabcd(pivots);
62        let xa = (p.a - p.x).abs();
63        let ab = (p.b - p.a).abs();
64        let bc = (p.c - p.b).abs();
65        let cd = (p.d - p.c).abs();
66        let ad = (p.d - p.a).abs();
67        let matched = ratios_in(&[
68            (ab / xa, 1.13, 1.618),
69            (bc / ab, 1.618, 2.24),
70            (cd / bc, 0.382, 0.886),
71            (ad / xa, 0.886, 1.13),
72        ]);
73        if matched {
74            return Some(if p.bullish { 1.0 } else { -1.0 });
75        }
76        Some(0.0)
77    }
78
79    fn reset(&mut self) {
80        self.swing.reset();
81        self.has_emitted = false;
82    }
83
84    #[inline]
85    fn warmup_period(&self) -> usize {
86        6
87    }
88
89    #[inline]
90    fn is_ready(&self) -> bool {
91        self.has_emitted
92    }
93
94    #[inline]
95    fn name(&self) -> &'static str {
96        "Shark"
97    }
98}
99
100#[cfg(test)]
101mod tests {
102    use super::*;
103    use crate::indicators::pattern_swing::candles_for_pivots;
104    use crate::traits::BatchExt;
105
106    fn run(pivots: &[f64]) -> Vec<f64> {
107        let mut indicator = Shark::new();
108        candles_for_pivots(pivots)
109            .into_iter()
110            .filter_map(|c| indicator.update(c))
111            .collect()
112    }
113
114    #[test]
115    fn accessors_and_metadata() {
116        let indicator = Shark::new();
117        assert_eq!(indicator.name(), "Shark");
118        assert_eq!(indicator.warmup_period(), 6);
119        assert!(!indicator.is_ready());
120        assert!(!Shark::default().is_ready());
121    }
122
123    #[test]
124    fn bullish_shark_is_plus_one() {
125        let out = run(&[150.0, 100.0, 140.0, 88.0, 186.8, 100.0]);
126        assert_eq!(*out.last().unwrap(), 1.0);
127        assert!(out[..out.len() - 1].iter().all(|&x| x == 0.0));
128    }
129
130    #[test]
131    fn bearish_shark_is_minus_one() {
132        let out = run(&[150.0, 110.0, 162.0, 60.2, 150.0]);
133        assert_eq!(*out.last().unwrap(), -1.0);
134    }
135
136    #[test]
137    fn out_of_ratio_does_not_trigger() {
138        let out = run(&[150.0, 100.0, 140.0, 110.0, 135.0, 105.0]);
139        assert_eq!(*out.last().unwrap(), 0.0);
140    }
141
142    #[test]
143    fn reset_clears_state() {
144        let mut indicator = Shark::new();
145        for c in candles_for_pivots(&[150.0, 100.0, 140.0]) {
146            let _ = indicator.update(c);
147        }
148        indicator.reset();
149        assert!(!indicator.is_ready());
150        let c = Candle::new(99.5, 100.0, 99.5, 99.5, 1.0, 0).unwrap();
151        assert_eq!(indicator.update(c), None);
152    }
153
154    #[test]
155    fn batch_equals_streaming() {
156        let candles = candles_for_pivots(&[150.0, 100.0, 140.0, 88.0, 186.8, 100.0]);
157        let mut a = Shark::new();
158        let mut b = Shark::new();
159        assert_eq!(
160            a.batch(&candles),
161            candles.iter().map(|x| b.update(*x)).collect::<Vec<_>>()
162        );
163    }
164}