Skip to main content

wickra_core/indicators/
fib_fan.rs

1//! Fibonacci Fan — trendlines fanning from a swing start through the
2//! retracement levels at the swing end, extended to the current bar.
3
4use crate::indicators::pattern_swing::{SwingTracker, SWING_THRESHOLD};
5use crate::ohlcv::Candle;
6use crate::traits::Indicator;
7
8/// The three fan ratios drawn (38.2% / 50% / 61.8%).
9const RATIOS: [f64; 3] = [0.382, 0.5, 0.618];
10
11/// Fibonacci Fan line prices evaluated at the current bar.
12#[derive(Debug, Clone, Copy, PartialEq)]
13pub struct FibFanOutput {
14    /// Price of the 38.2% fan line at the current bar.
15    pub fan_382: f64,
16    /// Price of the 50% fan line at the current bar.
17    pub fan_500: f64,
18    /// Price of the 61.8% fan line at the current bar.
19    pub fan_618: f64,
20}
21
22/// Fibonacci Fan (`FibFan`).
23///
24/// Anchored at the start of the most recent confirmed swing leg, three lines fan
25/// out through the 38.2% / 50% / 61.8% retracement levels located at the leg's
26/// end bar, then extend to the current bar. The retracement is measured back from
27/// the end of the leg (the same convention as [`FibRetracement`](crate::FibRetracement)),
28/// so at the end bar the `r` line sits at `end − r·(end − start)`. Each line's
29/// price is reported as the fan opens with elapsed time.
30///
31/// ```text
32/// line(r) = start + (1 − r) * (end - start) * (cur - start_bar) / (end_bar - start_bar)
33/// ```
34///
35/// Parameter-free; construction is infallible. Returns `None` until the first
36/// leg is complete.
37///
38/// See `crates/wickra-core/src/indicators/fib_fan.rs`.
39#[derive(Debug, Clone)]
40pub struct FibFan {
41    swing: SwingTracker,
42}
43
44impl FibFan {
45    /// Construct a new Fibonacci Fan tracker.
46    #[must_use]
47    pub const fn new() -> Self {
48        Self {
49            swing: SwingTracker::new(SWING_THRESHOLD, 2),
50        }
51    }
52
53    fn fan(&self) -> Option<FibFanOutput> {
54        let pivots = self.swing.pivots();
55        let start = pivots.first()?;
56        let end = pivots.get(1)?;
57        // Consecutive pivots occur at strictly increasing bars, so the span is
58        // always at least one bar — no division by zero.
59        let span_bars = (end.bar - start.bar) as f64;
60        let elapsed = (self.swing.current_bar() - start.bar) as f64;
61        let progress = elapsed / span_bars;
62        let line = |r: f64| start.price + (1.0 - r) * (end.price - start.price) * progress;
63        Some(FibFanOutput {
64            fan_382: line(RATIOS[0]),
65            fan_500: line(RATIOS[1]),
66            fan_618: line(RATIOS[2]),
67        })
68    }
69}
70
71impl Default for FibFan {
72    fn default() -> Self {
73        Self::new()
74    }
75}
76
77impl Indicator for FibFan {
78    type Input = Candle;
79    type Output = FibFanOutput;
80
81    #[inline]
82    fn update(&mut self, candle: Candle) -> Option<FibFanOutput> {
83        self.swing.update(candle);
84        self.fan()
85    }
86
87    fn reset(&mut self) {
88        self.swing.reset();
89    }
90
91    #[inline]
92    fn warmup_period(&self) -> usize {
93        2
94    }
95
96    #[inline]
97    fn is_ready(&self) -> bool {
98        self.swing.pivots().len() >= 2
99    }
100
101    #[inline]
102    fn name(&self) -> &'static str {
103        "FibFan"
104    }
105}
106
107#[cfg(test)]
108mod tests {
109    use super::*;
110    use crate::traits::BatchExt;
111    use approx::assert_relative_eq;
112
113    fn c(high: f64, low: f64, ts: i64) -> Candle {
114        Candle::new(low, high, low, low, 1.0, ts).unwrap()
115    }
116
117    /// Drive a leg start=200 (bar 0) -> end=100 (bar 2), confirmed at bar 3, so
118    /// the fan is first reported at bar 3 with `progress = 3 / 2 = 1.5`.
119    fn down_leg() -> Vec<Candle> {
120        vec![
121            c(200.0, 199.0, 0), // bootstrap high @200 (bar 0)
122            c(190.0, 160.0, 1), // confirm high @200, low candidate @160
123            c(150.0, 100.0, 2), // extend low to 100 (bar 2)
124            c(110.0, 105.0, 3), // confirm low @100 -> two pivots
125        ]
126    }
127
128    #[test]
129    fn accessors_and_metadata() {
130        let indicator = FibFan::new();
131        assert_eq!(indicator.name(), "FibFan");
132        assert_eq!(indicator.warmup_period(), 2);
133        assert!(!indicator.is_ready());
134        assert!(!FibFan::default().is_ready());
135    }
136
137    #[test]
138    fn no_output_before_two_pivots() {
139        let mut indicator = FibFan::new();
140        // Only the high confirms here; no end pivot yet.
141        let outputs: Vec<_> = [c(200.0, 199.0, 0), c(190.0, 150.0, 1)]
142            .into_iter()
143            .map(|x| indicator.update(x))
144            .collect();
145        assert!(outputs.iter().all(Option::is_none));
146        assert!(!indicator.is_ready());
147    }
148
149    #[test]
150    fn fan_lines_open_with_elapsed_time() {
151        let mut indicator = FibFan::new();
152        let mut last = None;
153        for candle in down_leg() {
154            last = indicator.update(candle);
155        }
156        let v = last.unwrap();
157        assert!(indicator.is_ready());
158        // progress = (3 - 0) / (2 - 0) = 1.5; line(r) = 200 + (1 - r)*(-100)*1.5.
159        assert_relative_eq!(v.fan_382, 200.0 - (1.0 - 0.382) * 150.0);
160        assert_relative_eq!(v.fan_500, 125.0);
161        assert_relative_eq!(v.fan_618, 200.0 - (1.0 - 0.618) * 150.0);
162    }
163
164    #[test]
165    fn reset_clears_state() {
166        let mut indicator = FibFan::new();
167        for candle in down_leg() {
168            let _ = indicator.update(candle);
169        }
170        assert!(indicator.is_ready());
171        indicator.reset();
172        assert!(!indicator.is_ready());
173        assert!(indicator.update(c(100.0, 99.5, 0)).is_none());
174    }
175
176    #[test]
177    fn batch_equals_streaming() {
178        let candles = down_leg();
179        let mut a = FibFan::new();
180        let mut b = FibFan::new();
181        assert_eq!(
182            a.batch(&candles),
183            candles.iter().map(|x| b.update(*x)).collect::<Vec<_>>()
184        );
185    }
186
187    /// The quickest possible two-pivot sequence, then an up leg that rolls the
188    /// two-pivot window forward.
189    fn fast_series() -> Vec<Candle> {
190        vec![
191            c(200.0, 199.0, 0), // bootstrap high @200 (bar 0)
192            c(190.0, 150.0, 1), // 150 <= 200 * 0.95 -> confirm high 200 @0, track low 150 @1
193            c(160.0, 155.0, 2), // 160 >= 150 * 1.05 -> confirm low 150 @1
194            c(180.0, 170.0, 3), // extend candidate high to 180 @3
195            c(175.0, 165.0, 4), // 165 <= 180 * 0.95 -> confirm high 180 @3 (cap 2 drops 200)
196            c(170.0, 168.0, 5), // no new pivot
197        ]
198    }
199
200    #[test]
201    fn first_value_lands_on_the_earliest_possible_bar() {
202        let out = FibFan::new().batch(&fast_series());
203        // warmup_period() == 2, and nothing is emitted before index 2 - 1 = 1.
204        assert!(out[..1].iter().all(Option::is_none));
205        // Two pivots need a bootstrap bar plus two confirming bars, so even the
206        // fastest series emits first at index 2 (bar index 1 is still None).
207        assert!(out[1].is_none());
208        assert!(out[2].is_some());
209    }
210
211    #[test]
212    fn hand_computed_down_then_up_leg() {
213        let mut indicator = FibFan::new();
214        let out = indicator.batch(&fast_series());
215        // Bar 2: start 200 @0, end 150 @1, progress = (2 - 0) / (1 - 0) = 2.
216        // line(r) = 200 + (1 - r) * (-50) * 2 = 200 - 100 * (1 - r)
217        // -> 138.2 / 150 / 161.8.
218        let v2 = out[2].unwrap();
219        assert_relative_eq!(v2.fan_382, 138.2, epsilon = 1e-9);
220        assert_relative_eq!(v2.fan_500, 150.0, epsilon = 1e-9);
221        assert_relative_eq!(v2.fan_618, 161.8, epsilon = 1e-9);
222        // Bar 3: progress = 3 / 1 = 3 -> 200 - 150 * (1 - r) -> 107.3 / 125 / 142.7.
223        let v3 = out[3].unwrap();
224        assert_relative_eq!(v3.fan_382, 107.3, epsilon = 1e-9);
225        assert_relative_eq!(v3.fan_500, 125.0, epsilon = 1e-9);
226        assert_relative_eq!(v3.fan_618, 142.7, epsilon = 1e-9);
227        // Bar 4: new leg start 150 @1, end 180 @3, progress = (4 - 1) / (3 - 1) = 1.5.
228        // line(r) = 150 + (1 - r) * 30 * 1.5 = 150 + 45 * (1 - r) -> 177.81 / 172.5 / 167.19.
229        let v4 = out[4].unwrap();
230        assert_relative_eq!(v4.fan_382, 177.81, epsilon = 1e-9);
231        assert_relative_eq!(v4.fan_500, 172.5, epsilon = 1e-9);
232        assert_relative_eq!(v4.fan_618, 167.19, epsilon = 1e-9);
233        // Bar 5: progress = 4 / 2 = 2 -> 150 + 60 * (1 - r) -> 187.08 / 180 / 172.92.
234        let v5 = out[5].unwrap();
235        assert_relative_eq!(v5.fan_382, 187.08, epsilon = 1e-9);
236        assert_relative_eq!(v5.fan_500, 180.0, epsilon = 1e-9);
237        assert_relative_eq!(v5.fan_618, 172.92, epsilon = 1e-9);
238        // On an up leg the 38.2% line is the steepest (closest to the leg end).
239        assert!(v5.fan_382 > v5.fan_500 && v5.fan_500 > v5.fan_618);
240    }
241
242    #[test]
243    fn line_at_unit_progress_is_measured_back_from_leg_end() {
244        // Evaluate the fan with the current bar on the leg end (progress == 1):
245        // line(r) = end - r * (end - start). Feed the down leg through bar 2 only
246        // via the tracker so the end pivot exists, then check the formula identity
247        // on the reported bar-3 value scaled back to progress 1.
248        let mut indicator = FibFan::new();
249        let v = indicator.batch(&down_leg())[3].unwrap();
250        // progress 1.5; (line - start) / 1.5 + start == end - r * (end - start).
251        let at_end = |line: f64| (line - 200.0) / 1.5 + 200.0;
252        assert_relative_eq!(at_end(v.fan_382), 100.0 + 0.382 * 100.0, epsilon = 1e-9);
253        assert_relative_eq!(at_end(v.fan_500), 150.0, epsilon = 1e-9);
254        assert_relative_eq!(at_end(v.fan_618), 100.0 + 0.618 * 100.0, epsilon = 1e-9);
255    }
256
257    #[test]
258    fn reset_replays_identically_to_fresh_instance() {
259        let candles = fast_series();
260        let mut indicator = FibFan::new();
261        let first = indicator.batch(&candles);
262        indicator.reset();
263        let second = indicator.batch(&candles);
264        assert_eq!(first, second);
265        assert_eq!(second, FibFan::default().batch(&candles));
266    }
267
268    #[test]
269    fn batch_equals_streaming_over_multiple_legs() {
270        let candles = fast_series();
271        let mut streaming = FibFan::new();
272        let streamed: Vec<_> = candles.iter().map(|x| streaming.update(*x)).collect();
273        assert_eq!(FibFan::new().batch(&candles), streamed);
274    }
275}