Skip to main content

wickra_core/indicators/
cypher.rs

1//! Cypher harmonic pattern.
2
3use crate::indicators::pattern_swing::{ratios_in, xabcd, SwingTracker, SWING_THRESHOLD};
4use crate::ohlcv::Candle;
5use crate::traits::Indicator;
6
7/// Cypher — a 5-point (X-A-B-C-D) harmonic pattern (Darren Oglesbee) whose C
8/// point projects the XA leg beyond A and whose D retraces the XC leg by
9/// `0.786`:
10///
11/// ```text
12/// AB / XA ∈ [0.382, 0.618]
13/// XC / XA ∈ [1.272, 1.414]  (C projects XA beyond A — measured X-to-C)
14/// CD / XC ∈ [0.74, 0.83]    (≈ 0.786 retracement of XC — the D completion)
15/// ```
16///
17/// The C constraint is the X-to-C projection, not B-to-C: unlike the Gartley
18/// family, the Cypher measures its third point against the initial XA leg
19/// rather than against AB.
20///
21/// Output is `+1.0` (bullish, D a swing low), `-1.0` (bearish, D a swing high),
22/// or `0.0`; never `None`. See `crates/wickra-core/src/indicators/cypher.rs`.
23#[derive(Debug, Clone)]
24pub struct Cypher {
25    swing: SwingTracker,
26    has_emitted: bool,
27}
28
29impl Cypher {
30    /// Construct a new Cypher detector.
31    pub const fn new() -> Self {
32        Self {
33            swing: SwingTracker::new(SWING_THRESHOLD, 5),
34            has_emitted: false,
35        }
36    }
37}
38
39impl Default for Cypher {
40    fn default() -> Self {
41        Self::new()
42    }
43}
44
45impl Indicator for Cypher {
46    type Input = Candle;
47    type Output = f64;
48
49    #[inline]
50    fn update(&mut self, candle: Candle) -> Option<f64> {
51        let advanced = self.swing.update(candle);
52        let pivots = self.swing.pivots();
53        // Too few pivots to form the shape at all: the indicator cannot
54        // judge yet, which is what `None` means.
55        if pivots.len() < 5 {
56            return None;
57        }
58        self.has_emitted = true;
59        // Armed, but this bar did not close a new pivot, so there is
60        // nothing new to match against.
61        if !advanced {
62            return Some(0.0);
63        }
64        let p = xabcd(pivots);
65        let xa = (p.a - p.x).abs();
66        let ab = (p.b - p.a).abs();
67        let xc = (p.c - p.x).abs();
68        let cd = (p.d - p.c).abs();
69        let matched = ratios_in(&[
70            (ab / xa, 0.382, 0.618),
71            (xc / xa, 1.272, 1.414),
72            (cd / xc, 0.74, 0.83),
73        ]);
74        if matched {
75            return Some(if p.bullish { 1.0 } else { -1.0 });
76        }
77        Some(0.0)
78    }
79
80    fn reset(&mut self) {
81        self.swing.reset();
82        self.has_emitted = false;
83    }
84
85    #[inline]
86    fn warmup_period(&self) -> usize {
87        6
88    }
89
90    #[inline]
91    fn is_ready(&self) -> bool {
92        self.has_emitted
93    }
94
95    #[inline]
96    fn name(&self) -> &'static str {
97        "Cypher"
98    }
99}
100
101#[cfg(test)]
102mod tests {
103    use super::*;
104    use crate::indicators::pattern_swing::candles_for_pivots;
105    use crate::traits::BatchExt;
106
107    fn run(pivots: &[f64]) -> Vec<f64> {
108        let mut indicator = Cypher::new();
109        candles_for_pivots(pivots)
110            .into_iter()
111            .filter_map(|c| indicator.update(c))
112            .collect()
113    }
114
115    #[test]
116    fn accessors_and_metadata() {
117        let indicator = Cypher::new();
118        assert_eq!(indicator.name(), "Cypher");
119        assert_eq!(indicator.warmup_period(), 6);
120        assert!(!indicator.is_ready());
121        assert!(!Cypher::default().is_ready());
122    }
123
124    #[test]
125    fn bullish_cypher_is_plus_one() {
126        // X=100 A=140 B=120 C=152 D=111.128:
127        // AB/XA = 20/40 = 0.5, XC/XA = 52/40 = 1.3, CD/XC = 40.872/52 = 0.786.
128        let out = run(&[150.0, 100.0, 140.0, 120.0, 152.0, 111.128]);
129        assert_eq!(*out.last().unwrap(), 1.0);
130        assert!(out[..out.len() - 1].iter().all(|&x| x == 0.0));
131    }
132
133    #[test]
134    fn bearish_cypher_is_minus_one() {
135        // X=150 A=110 B=130 C=98 D=138.872: same ratios, terminal D a swing high.
136        let out = run(&[150.0, 110.0, 130.0, 98.0, 138.872]);
137        assert_eq!(*out.last().unwrap(), -1.0);
138    }
139
140    #[test]
141    fn out_of_ratio_does_not_trigger() {
142        let out = run(&[150.0, 100.0, 140.0, 110.0, 135.0, 105.0]);
143        assert_eq!(*out.last().unwrap(), 0.0);
144    }
145
146    #[test]
147    fn c_beyond_the_projection_window_does_not_trigger() {
148        // X=100 A=140 B=120 C=168 D=114.55: BC/XA = 1.2 sits inside the old
149        // (incorrect) window, but XC/XA = 1.7 is outside the 1.272-1.414
150        // projection, so the pattern must not match.
151        let out = run(&[150.0, 100.0, 140.0, 120.0, 168.0, 114.55]);
152        assert_eq!(*out.last().unwrap(), 0.0);
153    }
154
155    #[test]
156    fn reset_clears_state() {
157        let mut indicator = Cypher::new();
158        for c in candles_for_pivots(&[150.0, 100.0, 140.0]) {
159            let _ = indicator.update(c);
160        }
161        indicator.reset();
162        assert!(!indicator.is_ready());
163        let c = Candle::new(99.5, 100.0, 99.5, 99.5, 1.0, 0).unwrap();
164        assert_eq!(indicator.update(c), None);
165    }
166
167    #[test]
168    fn batch_equals_streaming() {
169        let candles = candles_for_pivots(&[150.0, 100.0, 140.0, 120.0, 152.0, 111.128]);
170        let mut a = Cypher::new();
171        let mut b = Cypher::new();
172        assert_eq!(
173            a.batch(&candles),
174            candles.iter().map(|x| b.update(*x)).collect::<Vec<_>>()
175        );
176    }
177}