wickra-core 0.5.3

Core streaming-first technical indicators engine for the Wickra library
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
//! Internal swing-pivot tracker shared by the chart-pattern and harmonic-pattern
//! detectors. Not a public indicator — it carries no `Indicator` impl and is
//! re-exported nowhere, so it is excluded from the public catalogue counter.
//!
//! The tracker mirrors [`crate::indicators::ZigZag`]'s non-repainting
//! percent-threshold confirmation logic, but differs in two ways that make it a
//! reusable building block rather than a standalone indicator:
//!
//! * It is **parameter-free at the call site** — the reversal threshold is baked
//!   in by each detector as a compile-time constant, so construction is
//!   infallible (`const fn new`) and there is no user-facing validation branch.
//! * It **accumulates a bounded history** of the most recently confirmed pivots
//!   (capped at `cap`), so a detector can inspect the last few swings to match a
//!   geometric template (double top, head-and-shoulders, the XABCD legs of a
//!   harmonic pattern, …).

use crate::ohlcv::Candle;

/// Default fractional reversal threshold for pattern swing detection (5%). A
/// pivot is confirmed once price reverses by this fraction away from the running
/// extreme. Baked in so the pattern detectors stay parameter-free, mirroring the
/// candlestick-pattern family's fixed geometric thresholds.
pub(crate) const SWING_THRESHOLD: f64 = 0.05;

/// Default relative tolerance for two swing levels to count as "equal" (3%) —
/// the twin tops of a double top, the shoulders of a head-and-shoulders, the
/// flat boundary of a rectangle.
pub(crate) const LEVEL_TOLERANCE: f64 = 0.03;

/// A confirmed swing pivot: the extreme price the swing turned from, its
/// direction (`+1.0` for a swing high, `-1.0` for a swing low) and the bar index
/// at which that extreme occurred.
#[derive(Debug, Clone, Copy, PartialEq)]
pub(crate) struct Pivot {
    /// Price of the confirmed swing extreme.
    pub price: f64,
    /// `+1.0` if the pivot is a swing high, `-1.0` if it is a swing low.
    pub direction: f64,
    /// Zero-based index of the candle at which the swing extreme occurred (not
    /// the later bar that confirmed it). Used by the geometric Fibonacci tools
    /// (fan, arcs, channel, time zones) to place trendlines and time offsets.
    pub bar: usize,
}

/// Non-repainting percent-threshold swing tracker with a bounded pivot history.
///
/// Feeding a candle returns `true` exactly on the bar where a new pivot is
/// confirmed (price has reversed by the configured fraction away from the
/// running extreme); the newly confirmed pivot is appended to [`pivots`] and the
/// oldest is dropped once the cap is exceeded. Bars that merely extend the
/// running extreme, or that move less than the threshold, return `false`.
///
/// [`pivots`]: SwingTracker::pivots
#[derive(Debug, Clone)]
pub(crate) struct SwingTracker {
    threshold: f64,
    cap: usize,
    /// Number of candles fed so far; the current bar index is `bars_seen - 1`.
    bars_seen: usize,
    state: Option<State>,
    pivots: Vec<Pivot>,
}

#[derive(Debug, Clone, Copy)]
struct State {
    /// `+1.0` while tracking a candidate high (uptrend), `-1.0` while tracking a
    /// candidate low (downtrend).
    direction: f64,
    /// The running candidate extreme price.
    extreme: f64,
    /// Bar index at which the running extreme was last set.
    extreme_bar: usize,
}

impl SwingTracker {
    /// Construct a tracker with a fractional reversal `threshold` (e.g. `0.05`
    /// for 5%) and a pivot history capped at `cap` entries.
    ///
    /// The threshold is supplied by the detectors as a compile-time constant in
    /// `(0, 1)`, so no runtime validation is performed — an out-of-range
    /// constant would be a library bug caught by the unit tests, not invalid
    /// caller input.
    pub(crate) const fn new(threshold: f64, cap: usize) -> Self {
        Self {
            threshold,
            cap,
            bars_seen: 0,
            state: None,
            pivots: Vec::new(),
        }
    }

    /// Feed one candle. Returns `true` when a new pivot was confirmed this bar.
    pub(crate) fn update(&mut self, candle: Candle) -> bool {
        let bar = self.bars_seen;
        self.bars_seen += 1;
        let Some(s) = self.state else {
            // Bootstrap: seed an uptrend tracking the first candle's high.
            self.state = Some(State {
                direction: 1.0,
                extreme: candle.high,
                extreme_bar: bar,
            });
            return false;
        };

        if s.direction > 0.0 {
            if candle.high > s.extreme {
                // Extend the candidate high.
                self.state = Some(State {
                    direction: 1.0,
                    extreme: candle.high,
                    extreme_bar: bar,
                });
                return false;
            }
            if candle.low <= s.extreme * (1.0 - self.threshold) {
                // Confirm the swing high; flip to tracking this bar's low.
                self.push(Pivot {
                    price: s.extreme,
                    direction: 1.0,
                    bar: s.extreme_bar,
                });
                self.state = Some(State {
                    direction: -1.0,
                    extreme: candle.low,
                    extreme_bar: bar,
                });
                return true;
            }
            false
        } else {
            if candle.low < s.extreme {
                // Extend the candidate low.
                self.state = Some(State {
                    direction: -1.0,
                    extreme: candle.low,
                    extreme_bar: bar,
                });
                return false;
            }
            if candle.high >= s.extreme * (1.0 + self.threshold) {
                // Confirm the swing low; flip to tracking this bar's high.
                self.push(Pivot {
                    price: s.extreme,
                    direction: -1.0,
                    bar: s.extreme_bar,
                });
                self.state = Some(State {
                    direction: 1.0,
                    extreme: candle.high,
                    extreme_bar: bar,
                });
                return true;
            }
            false
        }
    }

    /// Zero-based index of the most recently fed candle. Saturates at `0` before
    /// any candle has been seen.
    pub(crate) fn current_bar(&self) -> usize {
        self.bars_seen.saturating_sub(1)
    }

    fn push(&mut self, pivot: Pivot) {
        self.pivots.push(pivot);
        if self.pivots.len() > self.cap {
            self.pivots.remove(0);
        }
    }

    /// The confirmed pivots in chronological order (oldest first, newest last).
    pub(crate) fn pivots(&self) -> &[Pivot] {
        &self.pivots
    }

    /// Clear all state, returning the tracker to its just-constructed condition.
    pub(crate) fn reset(&mut self) {
        self.bars_seen = 0;
        self.state = None;
        self.pivots.clear();
    }
}

/// The two most recent swing highs and lows from the last four (strictly
/// alternating) pivots, returned as `(high_old, high_new, low_old, low_new)`.
/// Used by the converging/diverging trendline patterns (triangle, wedge,
/// rectangle). The slice must hold at least four pivots.
pub(crate) fn recent_legs(pivots: &[Pivot]) -> (f64, f64, f64, f64) {
    let n = pivots.len();
    if pivots[n - 1].direction > 0.0 {
        // … low_old, high_old, low_new, high_new (newest is a high)
        (
            pivots[n - 3].price,
            pivots[n - 1].price,
            pivots[n - 4].price,
            pivots[n - 2].price,
        )
    } else {
        // … high_old, low_old, high_new, low_new (newest is a low)
        (
            pivots[n - 4].price,
            pivots[n - 2].price,
            pivots[n - 3].price,
            pivots[n - 1].price,
        )
    }
}

/// Relative-tolerance equality: `true` when `a` and `b` are within `tol`
/// (a fraction) of the larger magnitude. Used to decide whether two swing
/// levels (the twin highs of a double top, the shoulders of a head-and-shoulders,
/// a harmonic Fibonacci ratio) count as "the same".
pub(crate) fn approx_equal(a: f64, b: f64, tol: f64) -> bool {
    let scale = a.abs().max(b.abs()).max(f64::MIN_POSITIVE);
    (a - b).abs() <= tol * scale
}

/// The five most recent pivots interpreted as the X-A-B-C-D points of a harmonic
/// pattern, with the terminal direction. The slice must hold at least five
/// pivots. Each detector derives the leg lengths and Fibonacci ratios it needs
/// from these five prices.
#[derive(Debug, Clone, Copy)]
pub(crate) struct Xabcd {
    pub x: f64,
    pub a: f64,
    pub b: f64,
    pub c: f64,
    pub d: f64,
    /// `true` when the terminal point D is a swing low (a bullish, buy-side
    /// completion); `false` when D is a swing high (bearish).
    pub bullish: bool,
}

/// Read the last five pivots as an [`Xabcd`]. Pivots are guaranteed nonzero-leg
/// (the swing tracker only confirms moves of at least the threshold), so the
/// leg-ratio divisions in the detectors never divide by zero.
pub(crate) fn xabcd(pivots: &[Pivot]) -> Xabcd {
    let n = pivots.len();
    Xabcd {
        x: pivots[n - 5].price,
        a: pivots[n - 4].price,
        b: pivots[n - 3].price,
        c: pivots[n - 2].price,
        d: pivots[n - 1].price,
        bullish: pivots[n - 1].direction < 0.0,
    }
}

/// `true` when every `(value, low, high)` triple satisfies `low <= value <= high`.
/// Harmonic detectors express their Fibonacci windows as a list of these triples;
/// evaluating them in one expression keeps the per-triple comparison on a single
/// line (no multi-line `&&` coverage gaps).
pub(crate) fn ratios_in(checks: &[(f64, f64, f64)]) -> bool {
    checks
        .iter()
        .all(|&(value, low, high)| value >= low && value <= high)
}

/// Build a candle sequence that drives a `SwingTracker` (or any detector built
/// on one) to confirm exactly the given alternating pivot prices, in order.
///
/// `pivots` must start with a **high** and strictly alternate high/low, with
/// each consecutive pair differing by at least the swing threshold (5%) in the
/// correct direction (`high > adjacent low * 1.05`). The returned vector has one
/// seed candle plus one confirming candle per pivot; pivot `k` is confirmed by
/// candle `k + 1`. Only the high/low of each candle is meaningful — the pattern
/// detectors read swings, not bodies.
#[cfg(test)]
pub(crate) fn candles_for_pivots(pivots: &[f64]) -> Vec<Candle> {
    fn bar(high: f64, low: f64, ts: i64) -> Candle {
        Candle::new(low, high, low, low, 1.0, ts).unwrap()
    }
    let mut out = vec![bar(pivots[0], pivots[0] * 0.999, 0)];
    let mut ts: i64 = 0;
    for (k, &price) in pivots.iter().enumerate() {
        ts += 1;
        let is_high = k % 2 == 0;
        let next = if k + 1 < pivots.len() {
            pivots[k + 1]
        } else if is_high {
            price * 0.90
        } else {
            price * 1.10
        };
        let candle = if is_high {
            // Reverse down from the candidate high `price` to confirm it.
            bar(price * 0.99, next, ts)
        } else {
            // Reverse up from the candidate low `price` to confirm it.
            bar(next, price * 1.01, ts)
        };
        out.push(candle);
    }
    out
}

#[cfg(test)]
mod tests {
    use super::*;

    fn c_hl(high: f64, low: f64, ts: i64) -> Candle {
        Candle::new(low, high, low, low, 1.0, ts).unwrap()
    }

    #[test]
    fn first_bar_only_bootstraps_no_pivot() {
        let mut t = SwingTracker::new(0.05, 6);
        assert!(!t.update(c_hl(100.0, 99.5, 0)));
        assert!(t.pivots().is_empty());
    }

    #[test]
    fn extends_candidate_high_without_confirming() {
        let mut t = SwingTracker::new(0.10, 6);
        assert!(!t.update(c_hl(100.0, 99.5, 0)));
        // A higher high merely raises the candidate — no pivot yet.
        assert!(!t.update(c_hl(110.0, 109.0, 1)));
        assert!(t.pivots().is_empty());
    }

    #[test]
    fn uptrend_small_move_does_not_confirm() {
        let mut t = SwingTracker::new(0.10, 6);
        let _ = t.update(c_hl(100.0, 99.5, 0));
        // A 1% dip is below the 10% threshold — neither extends nor confirms.
        assert!(!t.update(c_hl(99.8, 99.0, 1)));
        assert!(t.pivots().is_empty());
    }

    #[test]
    fn confirms_high_then_low_alternating() {
        let mut t = SwingTracker::new(0.10, 6);
        let _ = t.update(c_hl(100.0, 99.5, 0)); // seed uptrend
        let _ = t.update(c_hl(120.0, 119.5, 1)); // raise candidate high to 120
                                                 // Drop ≥10% below 120 → confirm the high at 120, flip to downtrend.
        assert!(t.update(c_hl(101.0, 100.0, 2)));
        assert_eq!(
            t.pivots().last().copied(),
            // The high extreme was set at bar 1, confirmed at bar 2.
            Some(Pivot {
                price: 120.0,
                direction: 1.0,
                bar: 1,
            })
        );
        // Now in a downtrend: a lower low extends the candidate low.
        assert!(!t.update(c_hl(100.5, 90.0, 3)));
        // Rise ≥10% above 90 → confirm the low at 90.
        assert!(t.update(c_hl(100.0, 99.0, 4)));
        assert_eq!(
            t.pivots().last().copied(),
            // The low extreme was set at bar 3, confirmed at bar 4.
            Some(Pivot {
                price: 90.0,
                direction: -1.0,
                bar: 3,
            })
        );
    }

    #[test]
    fn downtrend_tiny_rise_does_not_confirm() {
        let mut t = SwingTracker::new(0.10, 6);
        let _ = t.update(c_hl(100.0, 99.5, 0));
        let _ = t.update(c_hl(120.0, 119.5, 1));
        let _ = t.update(c_hl(101.0, 90.0, 2)); // confirm high, now downtrend at 90
                                                // A 1% bounce is below threshold — no confirmation, no new candidate low.
        assert!(!t.update(c_hl(91.0, 90.5, 3)));
        assert_eq!(t.pivots().len(), 1);
    }

    #[test]
    fn history_is_capped() {
        let mut t = SwingTracker::new(0.10, 2);
        // Drive an oscillation that confirms several pivots; only the last 2 stay.
        let path = [
            (100.0, 99.5),
            (120.0, 119.5),
            (101.0, 90.0), // confirm 120 (high)
            (91.0, 90.5),
            (110.0, 109.0), // confirm 90 (low)
            (109.0, 95.0),  // confirm 110 (high)
        ];
        for (i, (h, l)) in path.iter().enumerate() {
            let _ = t.update(c_hl(*h, *l, i64::try_from(i).unwrap()));
        }
        assert_eq!(t.pivots().len(), 2);
        // The two most recent confirmations: low 90 then high 110.
        assert_eq!(t.pivots()[0].price, 90.0);
        assert_eq!(t.pivots()[1].price, 110.0);
    }

    #[test]
    fn reset_clears_state_and_history() {
        let mut t = SwingTracker::new(0.10, 6);
        let _ = t.update(c_hl(100.0, 99.5, 0));
        let _ = t.update(c_hl(120.0, 119.5, 1));
        let _ = t.update(c_hl(101.0, 90.0, 2));
        assert_eq!(t.pivots().len(), 1);
        t.reset();
        assert!(t.pivots().is_empty());
        // After reset the next bar bootstraps again (returns false).
        assert!(!t.update(c_hl(100.0, 99.5, 0)));
    }

    #[test]
    fn recent_legs_extracts_highs_and_lows_either_ending() {
        // Newest pivot a high: [low_old, high_old, low_new, high_new].
        let ending_high = [
            Pivot {
                price: 100.0,
                direction: -1.0,
                bar: 0,
            },
            Pivot {
                price: 120.0,
                direction: 1.0,
                bar: 0,
            },
            Pivot {
                price: 110.0,
                direction: -1.0,
                bar: 0,
            },
            Pivot {
                price: 121.0,
                direction: 1.0,
                bar: 0,
            },
        ];
        assert_eq!(recent_legs(&ending_high), (120.0, 121.0, 100.0, 110.0));
        // Newest pivot a low: [high_old, low_old, high_new, low_new].
        let ending_low = [
            Pivot {
                price: 120.0,
                direction: 1.0,
                bar: 0,
            },
            Pivot {
                price: 100.0,
                direction: -1.0,
                bar: 0,
            },
            Pivot {
                price: 110.0,
                direction: 1.0,
                bar: 0,
            },
            Pivot {
                price: 99.0,
                direction: -1.0,
                bar: 0,
            },
        ];
        assert_eq!(recent_legs(&ending_low), (120.0, 110.0, 100.0, 99.0));
    }

    #[test]
    fn xabcd_reads_last_five_pivots_and_direction() {
        let pivots = [
            Pivot {
                price: 50.0,
                direction: 1.0,
                bar: 0,
            },
            Pivot {
                price: 100.0,
                direction: -1.0,
                bar: 0,
            }, // X
            Pivot {
                price: 140.0,
                direction: 1.0,
                bar: 0,
            }, // A
            Pivot {
                price: 115.0,
                direction: -1.0,
                bar: 0,
            }, // B
            Pivot {
                price: 128.0,
                direction: 1.0,
                bar: 0,
            }, // C
            Pivot {
                price: 108.0,
                direction: -1.0,
                bar: 0,
            }, // D (low → bullish)
        ];
        let p = xabcd(&pivots);
        assert_eq!(
            (p.x, p.a, p.b, p.c, p.d),
            (100.0, 140.0, 115.0, 128.0, 108.0)
        );
        assert!(p.bullish);
    }

    #[test]
    fn ratios_in_checks_every_window() {
        assert!(ratios_in(&[(0.6, 0.5, 0.7), (1.5, 1.0, 2.0)]));
        assert!(!ratios_in(&[(0.6, 0.5, 0.7), (3.0, 1.0, 2.0)])); // second out of range
        assert!(!ratios_in(&[(0.4, 0.5, 0.7)])); // below the window
    }

    #[test]
    fn candles_for_pivots_realizes_the_requested_swings() {
        let want = [120.0, 100.0, 125.0, 95.0];
        let mut t = SwingTracker::new(0.05, 6);
        for candle in candles_for_pivots(&want) {
            let _ = t.update(candle);
        }
        let got: Vec<f64> = t.pivots().iter().map(|p| p.price).collect();
        assert_eq!(got, want);
        // Directions alternate starting from a high.
        assert_eq!(t.pivots()[0].direction, 1.0);
        assert_eq!(t.pivots()[1].direction, -1.0);
    }

    #[test]
    fn approx_equal_relative_tolerance() {
        assert!(approx_equal(100.0, 102.0, 0.03)); // 2% apart, within 3%
        assert!(!approx_equal(100.0, 110.0, 0.03)); // 10% apart, outside 3%
        assert!(approx_equal(0.0, 0.0, 0.01)); // both zero
        assert!(approx_equal(-50.0, -49.0, 0.05)); // negative magnitudes
    }

    #[test]
    fn tracks_extreme_bar_and_current_bar() {
        let mut t = SwingTracker::new(0.10, 6);
        // Before any candle, the current bar saturates at 0.
        assert_eq!(t.current_bar(), 0);
        let _ = t.update(c_hl(100.0, 99.5, 0));
        let _ = t.update(c_hl(120.0, 119.5, 1)); // candidate high at bar 1
        let _ = t.update(c_hl(101.0, 90.0, 2)); // confirm high @120 (extreme bar 1)
        assert_eq!(t.current_bar(), 2);
        assert_eq!(t.pivots().last().unwrap().bar, 1);
    }
}