Skip to main content

wickra_core/indicators/
fractal_chaos_bands.rs

1//! Fractal Chaos Bands (Bill Williams Fractals).
2
3use std::collections::VecDeque;
4
5use crate::error::{Error, Result};
6use crate::ohlcv::Candle;
7use crate::traits::Indicator;
8
9/// Fractal Chaos Bands output.
10#[derive(Debug, Clone, Copy, PartialEq)]
11pub struct FractalChaosBandsOutput {
12    /// Upper band: high of the most recent confirmed fractal high.
13    pub upper: f64,
14    /// Lower band: low of the most recent confirmed fractal low.
15    pub lower: f64,
16}
17
18/// Fractal Chaos Bands: a step-function envelope of the most recent Bill
19/// Williams fractal highs and lows.
20///
21/// A bar is a **fractal high** when its high is the maximum of the window
22/// `[i − k, …, i + k]`. A **fractal low** is defined symmetrically on lows.
23/// The bands hold the high (low) of the latest confirmed fractal high (low),
24/// stepping outwards whenever a new fractal forms and otherwise staying flat:
25///
26/// ```text
27/// confirmation_lag = k                     // the centre bar is known only k bars later
28/// upper = high of the most recent confirmed fractal high
29/// lower = low  of the most recent confirmed fractal low
30/// ```
31///
32/// `k = 2` (5-bar fractals) is the canonical Williams setting and matches the
33/// "Fractal Chaos Bands" oscillator shipped with several chart vendors. With
34/// `k` bars of look-ahead, every band update reflects price `k` bars ago —
35/// strict streaming preserves this lag rather than peeking into the future.
36///
37/// # Example
38///
39/// ```
40/// use wickra_core::{Candle, FractalChaosBands, Indicator};
41///
42/// let mut indicator = FractalChaosBands::new(2).unwrap();
43/// let mut last = None;
44/// for i in 0..30 {
45///     let base = 100.0 + (f64::from(i) * 0.5).sin() * 5.0;
46///     let candle =
47///         Candle::new(base, base + 1.0, base - 1.0, base, 10.0, i64::from(i)).unwrap();
48///     last = indicator.update(candle);
49/// }
50/// // Confirmation requires `2k + 1` bars plus at least one fractal of each
51/// // kind, so `last` may legitimately be `None` on a single sweep without
52/// // both a peak and a trough in the window.
53/// let _ = last;
54/// ```
55#[derive(Debug, Clone)]
56pub struct FractalChaosBands {
57    k: usize,
58    window: VecDeque<Candle>,
59    last_upper: Option<f64>,
60    last_lower: Option<f64>,
61}
62
63impl FractalChaosBands {
64    /// Construct a new Fractal Chaos Bands indicator with the given fractal
65    /// half-width `k` (a bar is a fractal high if its high exceeds the highs
66    /// of the `k` bars on either side; canonical `k = 2`).
67    ///
68    /// # Errors
69    /// Returns [`Error::PeriodZero`] if `k == 0` (a single bar is always its
70    /// own trivial fractal).
71    pub fn new(k: usize) -> Result<Self> {
72        if k == 0 {
73            return Err(Error::PeriodZero);
74        }
75        Ok(Self {
76            k,
77            window: VecDeque::with_capacity(2 * k + 1),
78            last_upper: None,
79            last_lower: None,
80        })
81    }
82
83    /// Canonical Bill Williams configuration: `k = 2` (5-bar fractals).
84    pub fn classic() -> Self {
85        Self::new(2).expect("classic Fractal Chaos Bands parameters are valid")
86    }
87
88    /// Configured half-width `k`.
89    pub const fn k(&self) -> usize {
90        self.k
91    }
92}
93
94impl Indicator for FractalChaosBands {
95    type Input = Candle;
96    type Output = FractalChaosBandsOutput;
97
98    fn update(&mut self, candle: Candle) -> Option<FractalChaosBandsOutput> {
99        let window_len = 2 * self.k + 1;
100        if self.window.len() == window_len {
101            self.window.pop_front();
102        }
103        self.window.push_back(candle);
104        if self.window.len() < window_len {
105            return None;
106        }
107        // The centre bar is at index `k`. Strictly compare against the `k`
108        // bars on either side: `>` for the high and `<` for the low (a ties-
109        // included pattern would fire on flat tops/bottoms, against Williams'
110        // intent).
111        let center = &self.window[self.k];
112        let mut is_high = true;
113        let mut is_low = true;
114        for (i, c) in self.window.iter().enumerate() {
115            if i == self.k {
116                continue;
117            }
118            if c.high >= center.high {
119                is_high = false;
120            }
121            if c.low <= center.low {
122                is_low = false;
123            }
124        }
125        if is_high {
126            self.last_upper = Some(center.high);
127        }
128        if is_low {
129            self.last_lower = Some(center.low);
130        }
131        // Both bands must have been seen at least once before we can emit.
132        match (self.last_upper, self.last_lower) {
133            (Some(u), Some(l)) => Some(FractalChaosBandsOutput { upper: u, lower: l }),
134            _ => None,
135        }
136    }
137
138    fn reset(&mut self) {
139        self.window.clear();
140        self.last_upper = None;
141        self.last_lower = None;
142    }
143
144    fn warmup_period(&self) -> usize {
145        2 * self.k + 1
146    }
147
148    fn is_ready(&self) -> bool {
149        self.last_upper.is_some() && self.last_lower.is_some()
150    }
151
152    fn name(&self) -> &'static str {
153        "FractalChaosBands"
154    }
155}
156
157#[cfg(test)]
158mod tests {
159    use super::*;
160    use crate::traits::BatchExt;
161    use approx::assert_relative_eq;
162
163    fn c(h: f64, l: f64, cl: f64) -> Candle {
164        Candle::new(cl, h, l, cl, 1.0, 0).unwrap()
165    }
166
167    #[test]
168    fn rejects_zero_k() {
169        assert!(matches!(FractalChaosBands::new(0), Err(Error::PeriodZero)));
170    }
171
172    #[test]
173    fn accessors_and_metadata() {
174        let f = FractalChaosBands::classic();
175        assert_eq!(f.k(), 2);
176        assert_eq!(f.warmup_period(), 5);
177        assert_eq!(f.name(), "FractalChaosBands");
178    }
179
180    /// Detect a single peak and a single trough with `k = 2`.
181    /// Bars (high, low, close): (1,1,1), (2,2,2), (5,3,4), (3,1,2),
182    /// (2,2,2), (1,1,1), (2,2,2), (5,3,4).
183    /// Indices: 0..7. The peak at i=2 is `>` its 2 neighbours on each side
184    /// (after index 4 lands). The trough at i=3 is `<` its 2 neighbours on
185    /// each side (after index 5 lands). Both bands first emit on index 5.
186    #[test]
187    fn detects_simple_peak_and_trough() {
188        let candles = vec![
189            c(1.0, 1.0, 1.0),
190            c(2.0, 2.0, 2.0),
191            c(5.0, 3.0, 4.0), // peak: high 5 is the max of neighbouring 4
192            c(3.0, 0.5, 1.0), // trough: low 0.5 is the min
193            c(2.0, 2.0, 2.0),
194            c(1.0, 1.0, 1.0),
195            c(2.0, 2.0, 2.0),
196        ];
197        let mut f = FractalChaosBands::new(2).unwrap();
198        let out = f.batch(&candles);
199        // Bars 0..4 are warmup or single-band only — both bands haven't been
200        // confirmed yet.
201        for v in out.iter().take(5) {
202            assert!(v.is_none());
203        }
204        // Bar 5 confirms the trough at i=3 (low 0.5); the peak at i=2 was
205        // confirmed by bar 4 (centre 2, look-ahead 2 → index 4). So index 5
206        // is the first bar with *both* upper and lower set.
207        let v = out[5].unwrap();
208        assert_relative_eq!(v.upper, 5.0, epsilon = 1e-12);
209        assert_relative_eq!(v.lower, 0.5, epsilon = 1e-12);
210    }
211
212    /// In a flat market no bar is strictly higher (or lower) than its
213    /// neighbours, so no fractal ever confirms and the indicator never emits.
214    #[test]
215    fn flat_market_never_emits() {
216        let candles: Vec<Candle> = (0..30).map(|_| c(10.0, 10.0, 10.0)).collect();
217        let mut f = FractalChaosBands::new(2).unwrap();
218        for v in f.batch(&candles) {
219            assert!(v.is_none());
220        }
221    }
222
223    #[test]
224    fn batch_equals_streaming() {
225        let candles: Vec<Candle> = (0..40)
226            .map(|i| {
227                let m = 100.0 + (f64::from(i) * 0.5).sin() * 3.0;
228                c(m + 1.0, m - 1.0, m)
229            })
230            .collect();
231        let mut a = FractalChaosBands::new(2).unwrap();
232        let mut b = FractalChaosBands::new(2).unwrap();
233        assert_eq!(
234            a.batch(&candles),
235            candles.iter().map(|x| b.update(*x)).collect::<Vec<_>>()
236        );
237    }
238
239    #[test]
240    fn reset_clears_state() {
241        let candles = vec![
242            c(1.0, 1.0, 1.0),
243            c(2.0, 2.0, 2.0),
244            c(5.0, 3.0, 4.0),
245            c(3.0, 0.5, 1.0),
246            c(2.0, 2.0, 2.0),
247            c(1.0, 1.0, 1.0),
248            c(2.0, 2.0, 2.0),
249        ];
250        let mut f = FractalChaosBands::new(2).unwrap();
251        f.batch(&candles);
252        assert!(f.is_ready());
253        f.reset();
254        assert!(!f.is_ready());
255        assert_eq!(f.update(candles[0]), None);
256    }
257
258    #[test]
259    fn upper_above_lower_when_both_set() {
260        let candles: Vec<Candle> = (0..60)
261            .map(|i| {
262                let m = 100.0 + (f64::from(i) * 0.4).sin() * 5.0;
263                c(m + 1.0, m - 1.0, m)
264            })
265            .collect();
266        let mut f = FractalChaosBands::new(2).unwrap();
267        for o in f.batch(&candles).into_iter().flatten() {
268            assert!(o.upper >= o.lower);
269        }
270    }
271}