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 if k > crate::error::MAX_PERIOD {
76 return Err(Error::InvalidPeriod {
77 message: crate::error::PERIOD_ABOVE_MAX,
78 });
79 }
80 Ok(Self {
81 k,
82 window: VecDeque::with_capacity(2 * k + 1),
83 last_upper: None,
84 last_lower: None,
85 })
86 }
87
88 /// Canonical Bill Williams configuration: `k = 2` (5-bar fractals).
89 pub fn classic() -> Self {
90 Self::new(2).expect("classic Fractal Chaos Bands parameters are valid")
91 }
92
93 /// Configured half-width `k`.
94 pub const fn k(&self) -> usize {
95 self.k
96 }
97}
98
99impl Indicator for FractalChaosBands {
100 type Input = Candle;
101 type Output = FractalChaosBandsOutput;
102
103 #[inline]
104 fn update(&mut self, candle: Candle) -> Option<FractalChaosBandsOutput> {
105 let window_len = 2 * self.k + 1;
106 if self.window.len() == window_len {
107 self.window.pop_front();
108 }
109 self.window.push_back(candle);
110 if self.window.len() < window_len {
111 return None;
112 }
113 // The centre bar is at index `k`. Strictly compare against the `k`
114 // bars on either side: `>` for the high and `<` for the low (a ties-
115 // included pattern would fire on flat tops/bottoms, against Williams'
116 // intent).
117 let center = &self.window[self.k];
118 let mut is_high = true;
119 let mut is_low = true;
120 for (i, c) in self.window.iter().enumerate() {
121 if i == self.k {
122 continue;
123 }
124 if c.high >= center.high {
125 is_high = false;
126 }
127 if c.low <= center.low {
128 is_low = false;
129 }
130 }
131 if is_high {
132 self.last_upper = Some(center.high);
133 }
134 if is_low {
135 self.last_lower = Some(center.low);
136 }
137 // Both bands must have been seen at least once before we can emit.
138 match (self.last_upper, self.last_lower) {
139 (Some(u), Some(l)) => Some(FractalChaosBandsOutput { upper: u, lower: l }),
140 _ => None,
141 }
142 }
143
144 fn reset(&mut self) {
145 self.window.clear();
146 self.last_upper = None;
147 self.last_lower = None;
148 }
149
150 #[inline]
151 fn warmup_period(&self) -> usize {
152 2 * self.k + 1
153 }
154
155 #[inline]
156 fn is_ready(&self) -> bool {
157 self.last_upper.is_some() && self.last_lower.is_some()
158 }
159
160 #[inline]
161 fn name(&self) -> &'static str {
162 "FractalChaosBands"
163 }
164}
165
166#[cfg(test)]
167mod tests {
168 use super::*;
169 use crate::traits::BatchExt;
170 use approx::assert_relative_eq;
171
172 fn c(h: f64, l: f64, cl: f64) -> Candle {
173 Candle::new(cl, h, l, cl, 1.0, 0).unwrap()
174 }
175
176 #[test]
177 fn rejects_zero_k() {
178 assert!(matches!(FractalChaosBands::new(0), Err(Error::PeriodZero)));
179 }
180
181 #[test]
182 fn accessors_and_metadata() {
183 let f = FractalChaosBands::classic();
184 assert_eq!(f.k(), 2);
185 assert_eq!(f.warmup_period(), 5);
186 assert_eq!(f.name(), "FractalChaosBands");
187 }
188
189 /// Detect a single peak and a single trough with `k = 2`.
190 /// Bars (high, low, close): (1,1,1), (2,2,2), (5,3,4), (3,1,2),
191 /// (2,2,2), (1,1,1), (2,2,2), (5,3,4).
192 /// Indices: 0..7. The peak at i=2 is `>` its 2 neighbours on each side
193 /// (after index 4 lands). The trough at i=3 is `<` its 2 neighbours on
194 /// each side (after index 5 lands). Both bands first emit on index 5.
195 #[test]
196 fn detects_simple_peak_and_trough() {
197 let candles = vec![
198 c(1.0, 1.0, 1.0),
199 c(2.0, 2.0, 2.0),
200 c(5.0, 3.0, 4.0), // peak: high 5 is the max of neighbouring 4
201 c(3.0, 0.5, 1.0), // trough: low 0.5 is the min
202 c(2.0, 2.0, 2.0),
203 c(1.0, 1.0, 1.0),
204 c(2.0, 2.0, 2.0),
205 ];
206 let mut f = FractalChaosBands::new(2).unwrap();
207 let out = f.batch(&candles);
208 // Bars 0..4 are warmup or single-band only — both bands haven't been
209 // confirmed yet.
210 for v in out.iter().take(5) {
211 assert!(v.is_none());
212 }
213 // Bar 5 confirms the trough at i=3 (low 0.5); the peak at i=2 was
214 // confirmed by bar 4 (centre 2, look-ahead 2 → index 4). So index 5
215 // is the first bar with *both* upper and lower set.
216 let v = out[5].unwrap();
217 assert_relative_eq!(v.upper, 5.0, epsilon = 1e-12);
218 assert_relative_eq!(v.lower, 0.5, epsilon = 1e-12);
219 }
220
221 /// In a flat market no bar is strictly higher (or lower) than its
222 /// neighbours, so no fractal ever confirms and the indicator never emits.
223 #[test]
224 fn flat_market_never_emits() {
225 let candles: Vec<Candle> = (0..30).map(|_| c(10.0, 10.0, 10.0)).collect();
226 let mut f = FractalChaosBands::new(2).unwrap();
227 for v in f.batch(&candles) {
228 assert!(v.is_none());
229 }
230 }
231
232 #[test]
233 fn batch_equals_streaming() {
234 let candles: Vec<Candle> = (0..40)
235 .map(|i| {
236 let m = 100.0 + (f64::from(i) * 0.5).sin() * 3.0;
237 c(m + 1.0, m - 1.0, m)
238 })
239 .collect();
240 let mut a = FractalChaosBands::new(2).unwrap();
241 let mut b = FractalChaosBands::new(2).unwrap();
242 assert_eq!(
243 a.batch(&candles),
244 candles.iter().map(|x| b.update(*x)).collect::<Vec<_>>()
245 );
246 }
247
248 #[test]
249 fn reset_clears_state() {
250 let candles = vec![
251 c(1.0, 1.0, 1.0),
252 c(2.0, 2.0, 2.0),
253 c(5.0, 3.0, 4.0),
254 c(3.0, 0.5, 1.0),
255 c(2.0, 2.0, 2.0),
256 c(1.0, 1.0, 1.0),
257 c(2.0, 2.0, 2.0),
258 ];
259 let mut f = FractalChaosBands::new(2).unwrap();
260 f.batch(&candles);
261 assert!(f.is_ready());
262 f.reset();
263 assert!(!f.is_ready());
264 assert_eq!(f.update(candles[0]), None);
265 }
266
267 #[test]
268 fn upper_above_lower_when_both_set() {
269 let candles: Vec<Candle> = (0..60)
270 .map(|i| {
271 let m = 100.0 + (f64::from(i) * 0.4).sin() * 5.0;
272 c(m + 1.0, m - 1.0, m)
273 })
274 .collect();
275 let mut f = FractalChaosBands::new(2).unwrap();
276 for o in f.batch(&candles).into_iter().flatten() {
277 assert!(o.upper >= o.lower);
278 }
279 }
280}