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}