Skip to main content

wickra_core/indicators/
sine_wave.rs

1//! Ehlers Sine Wave indicator.
2#![allow(clippy::manual_clamp)]
3
4use std::f64::consts::PI;
5
6use crate::indicators::hilbert_dominant_cycle::HilbertDominantCycle;
7use crate::traits::Indicator;
8
9/// Ehlers' Sine Wave indicator (sine + leadsine).
10///
11/// Implementation from *Rocket Science for Traders* (Ehlers 2001, ch. 9). Uses
12/// the same Hilbert-transform machinery as [`HilbertDominantCycle`] to derive
13/// the instantaneous phase, then returns `sin(phase)` and the 45° lead
14/// `sin(phase + 45°)`. The two lines cross deep in trends but oscillate
15/// rapidly during cycles, providing a visual lead/lag signal.
16///
17/// Only the primary `sine` line is exposed as the scalar output to match the
18/// crate's standard scalar-indicator surface; the lead is accessible via the
19/// [`SineWave::lead`] accessor after each update.
20///
21/// # Example
22///
23/// ```
24/// use wickra_core::{Indicator, SineWave};
25///
26/// let mut sw = SineWave::new();
27/// let mut last = None;
28/// for i in 0..200 {
29///     last = sw.update(100.0 + (f64::from(i) * 0.4).sin() * 5.0);
30/// }
31/// assert!(last.is_some());
32/// ```
33#[derive(Debug, Clone, Default)]
34pub struct SineWave {
35    cycle: HilbertDominantCycle,
36    smooth_buf: Vec<f64>,
37    detrender_buf: Vec<f64>,
38    last_phase: f64,
39    last_sine: Option<f64>,
40    last_lead: f64,
41    count: usize,
42}
43
44impl SineWave {
45    /// Construct a new Sine Wave indicator.
46    pub fn new() -> Self {
47        Self::default()
48    }
49
50    /// Most recent lead (45°-ahead) value. `0.0` until the indicator is ready.
51    pub const fn lead(&self) -> f64 {
52        self.last_lead
53    }
54
55    /// Current sine value if available.
56    pub const fn value(&self) -> Option<f64> {
57        self.last_sine
58    }
59
60    fn push_front(buf: &mut Vec<f64>, v: f64, cap: usize) {
61        buf.insert(0, v);
62        if buf.len() > cap {
63            buf.truncate(cap);
64        }
65    }
66}
67
68impl Indicator for SineWave {
69    type Input = f64;
70    type Output = f64;
71
72    fn update(&mut self, input: f64) -> Option<f64> {
73        if !input.is_finite() {
74            return self.last_sine;
75        }
76        self.count += 1;
77        // Drive the dominant-cycle estimator first; its smoothing state is
78        // independent from ours so the two share input but not buffers.
79        let _ = self.cycle.update(input);
80
81        Self::push_front(&mut self.smooth_buf, input, 7);
82        if self.smooth_buf.len() < 4 {
83            return None;
84        }
85        let smooth = (4.0 * self.smooth_buf[0]
86            + 3.0 * self.smooth_buf[1]
87            + 2.0 * self.smooth_buf[2]
88            + self.smooth_buf[3])
89            / 10.0;
90        if self.smooth_buf.len() < 7 {
91            return None;
92        }
93        let period = self.cycle.value().unwrap_or(15.0).max(6.0).min(50.0);
94        let adj = 0.075 * period + 0.54;
95        let s0 = smooth;
96        let s2 = self.smooth_buf[2];
97        let s4 = self.smooth_buf[4];
98        let s6 = self.smooth_buf[6];
99        let detrender = (0.0962 * s0 + 0.5769 * s2 - 0.5769 * s4 - 0.0962 * s6) * adj;
100        Self::push_front(&mut self.detrender_buf, detrender, 7);
101        if self.detrender_buf.len() < 7 {
102            return None;
103        }
104        let q1 = (0.0962 * self.detrender_buf[0] + 0.5769 * self.detrender_buf[2]
105            - 0.5769 * self.detrender_buf[4]
106            - 0.0962 * self.detrender_buf[6])
107            * adj;
108        let i1 = self.detrender_buf[3];
109        let phase = if i1.abs() > f64::EPSILON {
110            (q1 / i1).atan()
111        } else {
112            self.last_phase
113        };
114        self.last_phase = phase;
115        let sine = phase.sin();
116        let lead = (phase + PI / 4.0).sin();
117
118        if self.count < 50 {
119            return None;
120        }
121        self.last_sine = Some(sine);
122        self.last_lead = lead;
123        Some(sine)
124    }
125
126    fn reset(&mut self) {
127        self.cycle.reset();
128        self.smooth_buf.clear();
129        self.detrender_buf.clear();
130        self.last_phase = 0.0;
131        self.last_sine = None;
132        self.last_lead = 0.0;
133        self.count = 0;
134    }
135
136    fn warmup_period(&self) -> usize {
137        50
138    }
139
140    fn is_ready(&self) -> bool {
141        self.last_sine.is_some()
142    }
143
144    fn name(&self) -> &'static str {
145        "SineWave"
146    }
147}
148
149#[cfg(test)]
150mod tests {
151    use super::*;
152    use crate::traits::BatchExt;
153
154    #[test]
155    fn accessors_and_metadata() {
156        let mut sw = SineWave::new();
157        assert_eq!(sw.warmup_period(), 50);
158        assert_eq!(sw.name(), "SineWave");
159        assert!(!sw.is_ready());
160        assert!(sw.value().is_none());
161        let prices: Vec<f64> = (0..120)
162            .map(|i| 100.0 + (f64::from(i) * 0.4).sin() * 5.0)
163            .collect();
164        sw.batch(&prices);
165        assert!(sw.is_ready());
166        assert!(sw.value().is_some());
167    }
168
169    #[test]
170    fn output_bounded() {
171        let prices: Vec<f64> = (0..200)
172            .map(|i| 100.0 + (f64::from(i) * 0.3).cos() * 5.0)
173            .collect();
174        let mut sw = SineWave::new();
175        for v in sw.batch(&prices).into_iter().flatten() {
176            assert!((-1.0..=1.0).contains(&v), "sine out of bounds: {v}");
177        }
178        // Lead value also bounded after warmup.
179        assert!(sw.lead() >= -1.0 && sw.lead() <= 1.0);
180    }
181
182    #[test]
183    fn batch_equals_streaming() {
184        let prices: Vec<f64> = (0..200)
185            .map(|i| 100.0 + (f64::from(i) * 0.3).sin() * 5.0)
186            .collect();
187        let mut a = SineWave::new();
188        let mut b = SineWave::new();
189        let batch = a.batch(&prices);
190        let streamed: Vec<_> = prices.iter().map(|p| b.update(*p)).collect();
191        assert_eq!(batch, streamed);
192    }
193
194    #[test]
195    fn ignores_non_finite_input() {
196        let mut sw = SineWave::new();
197        let prices: Vec<f64> = (0..120)
198            .map(|i| 100.0 + (f64::from(i) * 0.4).sin() * 5.0)
199            .collect();
200        sw.batch(&prices);
201        let before = sw.value();
202        assert!(before.is_some());
203        assert_eq!(sw.update(f64::NAN), before);
204    }
205
206    #[test]
207    fn reset_clears_state() {
208        let mut sw = SineWave::new();
209        let prices: Vec<f64> = (0..120)
210            .map(|i| 100.0 + (f64::from(i) * 0.4).sin() * 5.0)
211            .collect();
212        sw.batch(&prices);
213        assert!(sw.is_ready());
214        sw.reset();
215        assert!(!sw.is_ready());
216        assert!(sw.value().is_none());
217    }
218
219    #[test]
220    fn flat_input_uses_phase_fallback() {
221        // Zero inputs make every smooth/detrender term arithmetically exact
222        // zero (no IEEE-754 cancellation residue), so `i1 == 0.0` and the
223        // phase calculation deterministically takes the `self.last_phase`
224        // fallback rather than `atan(q1/i1)`. A non-zero constant like
225        // `100.0` leaves a sub-EPSILON residue that flips the branch back.
226        let mut sw = SineWave::new();
227        let _ = sw.batch(&[0.0_f64; 120]);
228        assert!(sw.value().is_some());
229    }
230}