feat(indicators): A5b Fibonacci tools (geometric) (#172)

Completes the **Fibonacci** family with the four geometric/time tools (catalogue 373 -> 377). All extend the internal `pattern_swing` ZigZag tracker with a per-pivot bar index and a current-bar counter (additive — the chart/harmonic detectors are unaffected), and emit `Candle -> struct` outputs via custom Python/Node/WASM bindings.

| Tool | Output |
|------|--------|
| `FibFan` | three trendlines fanning from a swing start through its 38.2/50/61.8% retracement levels, extended to the current bar |
| `FibArcs` | semicircular retracement levels centred on the swing end, normalised by the leg's bar-width (chart-scale-free) |
| `FibChannel` | a sloped base trendline plus parallel lines at Fibonacci multiples of the channel width |
| `FibTimeZones` | markers at Fibonacci bar-distances (1/2/3/5/8/...) from the latest swing pivot |

The geometric tools are novel as streaming indicators; each normalises its geometry to the swing leg's bar-width so the output is chart-scale-free. Formulas are documented in each module and deep-dive.

Fully wired: core (100% unit-tested branches incl. the new `pattern_swing` bar tracking), Python/Node/WASM struct bindings, fuzz, reference + streaming-vs-batch tests.

Verification: `cargo test --workspace` green, clippy `-D warnings` clean, node 454 tests, python 768 tests.
This commit is contained in:
kingchenc
2026-06-04 01:12:09 +02:00
committed by GitHub
parent ea9da12d86
commit 5a1d607807
19 changed files with 1757 additions and 13 deletions
@@ -0,0 +1,198 @@
//! Fibonacci Arcs — semicircular retracement levels centred on the swing end,
//! decaying back toward it as time elapses.
use crate::indicators::pattern_swing::{SwingTracker, SWING_THRESHOLD};
use crate::ohlcv::Candle;
use crate::traits::Indicator;
/// The three arc ratios drawn (38.2% / 50% / 61.8%).
const RATIOS: [f64; 3] = [0.382, 0.5, 0.618];
/// Fibonacci Arc prices evaluated at the current bar.
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct FibArcsOutput {
/// Price of the 38.2% arc at the current bar.
pub arc_382: f64,
/// Price of the 50% arc at the current bar.
pub arc_500: f64,
/// Price of the 61.8% arc at the current bar.
pub arc_618: f64,
}
/// Fibonacci Arcs (`FibArcs`).
///
/// Three arcs centred on the end of the most recent confirmed swing leg. Time is
/// normalised by the leg's bar-width so the construction is chart-scale-free: at
/// the leg's end bar each arc sits exactly on its retracement level, and as time
/// elapses the arc curves back toward the swing-end price, reaching it one leg
/// width later.
///
/// ```text
/// u = (cur - end_bar) / (end_bar - start_bar)
/// arc(r) = end + (start - end) * r * sqrt(max(0, 1 - u^2))
/// ```
///
/// Parameter-free; construction is infallible. Returns `None` until the first
/// leg is complete.
///
/// See `crates/wickra-core/src/indicators/fib_arcs.rs`.
#[derive(Debug, Clone)]
pub struct FibArcs {
swing: SwingTracker,
}
impl FibArcs {
/// Construct a new Fibonacci Arcs tracker.
#[must_use]
pub const fn new() -> Self {
Self {
swing: SwingTracker::new(SWING_THRESHOLD, 2),
}
}
fn arcs(&self) -> Option<FibArcsOutput> {
let pivots = self.swing.pivots();
let start = pivots.first()?;
let end = pivots.get(1)?;
// Consecutive pivots occur at strictly increasing bars → span >= 1 bar.
let span_bars = (end.bar - start.bar) as f64;
let u = (self.swing.current_bar() - end.bar) as f64 / span_bars;
let curve = (1.0 - u * u).max(0.0).sqrt();
let arc = |r: f64| end.price + (start.price - end.price) * r * curve;
Some(FibArcsOutput {
arc_382: arc(RATIOS[0]),
arc_500: arc(RATIOS[1]),
arc_618: arc(RATIOS[2]),
})
}
}
impl Default for FibArcs {
fn default() -> Self {
Self::new()
}
}
impl Indicator for FibArcs {
type Input = Candle;
type Output = FibArcsOutput;
fn update(&mut self, candle: Candle) -> Option<FibArcsOutput> {
self.swing.update(candle);
self.arcs()
}
fn reset(&mut self) {
self.swing.reset();
}
fn warmup_period(&self) -> usize {
2
}
fn is_ready(&self) -> bool {
self.swing.pivots().len() >= 2
}
fn name(&self) -> &'static str {
"FibArcs"
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::traits::BatchExt;
use approx::assert_relative_eq;
fn c(high: f64, low: f64, ts: i64) -> Candle {
Candle::new(low, high, low, low, 1.0, ts).unwrap()
}
/// Leg start=200 (bar 0) -> end=100 (bar 2), confirmed at bar 3 so the arc is
/// first reported with `u = (3 - 2) / (2 - 0) = 0.5`.
fn down_leg() -> Vec<Candle> {
vec![
c(200.0, 199.0, 0),
c(190.0, 160.0, 1), // confirm high @200
c(150.0, 100.0, 2), // extend low to 100 (bar 2)
c(110.0, 105.0, 3), // confirm low @100 -> two pivots
]
}
#[test]
fn accessors_and_metadata() {
let indicator = FibArcs::new();
assert_eq!(indicator.name(), "FibArcs");
assert_eq!(indicator.warmup_period(), 2);
assert!(!indicator.is_ready());
assert!(!FibArcs::default().is_ready());
}
#[test]
fn no_output_before_two_pivots() {
let mut indicator = FibArcs::new();
let outputs: Vec<_> = [c(200.0, 199.0, 0), c(190.0, 150.0, 1)]
.into_iter()
.map(|x| indicator.update(x))
.collect();
assert!(outputs.iter().all(Option::is_none));
assert!(!indicator.is_ready());
}
#[test]
fn arcs_curve_back_toward_the_swing_end() {
let mut indicator = FibArcs::new();
let mut last = None;
for candle in down_leg() {
last = indicator.update(candle);
}
let v = last.unwrap();
assert!(indicator.is_ready());
// u = 0.5 → curve = sqrt(0.75); arc(r) = 100 + 100 * r * curve.
let curve = 0.75_f64.sqrt();
assert_relative_eq!(v.arc_382, 100.0 + 100.0 * 0.382 * curve);
assert_relative_eq!(v.arc_500, 100.0 + 100.0 * 0.5 * curve);
assert_relative_eq!(v.arc_618, 100.0 + 100.0 * 0.618 * curve);
}
#[test]
fn arc_clamps_to_zero_beyond_one_leg_width() {
// Extend far past the end pivot so u > 1; the curve clamps to 0 and the
// arcs collapse onto the swing-end price.
let mut indicator = FibArcs::new();
for candle in down_leg() {
let _ = indicator.update(candle);
}
// Feed flat bars that neither extend nor confirm a new pivot.
let mut last = None;
for ts in 4..12 {
last = indicator.update(c(108.0, 106.0, ts));
}
let v = last.unwrap();
assert_relative_eq!(v.arc_382, 100.0);
assert_relative_eq!(v.arc_618, 100.0);
}
#[test]
fn reset_clears_state() {
let mut indicator = FibArcs::new();
for candle in down_leg() {
let _ = indicator.update(candle);
}
indicator.reset();
assert!(!indicator.is_ready());
assert!(indicator.update(c(100.0, 99.5, 0)).is_none());
}
#[test]
fn batch_equals_streaming() {
let candles = down_leg();
let mut a = FibArcs::new();
let mut b = FibArcs::new();
assert_eq!(
a.batch(&candles),
candles.iter().map(|x| b.update(*x)).collect::<Vec<_>>()
);
}
}
@@ -0,0 +1,192 @@
//! Fibonacci Channel — a sloped base trendline plus parallel lines offset by
//! Fibonacci multiples of the channel width.
use crate::indicators::pattern_swing::{SwingTracker, SWING_THRESHOLD};
use crate::ohlcv::Candle;
use crate::traits::Indicator;
/// The parallel-line ratios above the base (61.8% / 100% / 161.8% of the width).
const RATIOS: [f64; 3] = [0.618, 1.0, 1.618];
/// Fibonacci Channel line prices evaluated at the current bar.
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct FibChannelOutput {
/// The base trendline price at the current bar.
pub base: f64,
/// Base + 61.8% of the channel width.
pub level_618: f64,
/// Base + 100% of the width — the opposite channel boundary.
pub level_1000: f64,
/// Base + 161.8% of the width.
pub level_1618: f64,
}
/// Fibonacci Channel (`FibChannel`).
///
/// From the last three confirmed pivots, the two same-direction outer pivots
/// define a sloped base trendline and the opposite middle pivot sets the channel
/// width (its signed distance from the base line). Parallel lines are then offset
/// by Fibonacci multiples of that width and reported at the current bar.
///
/// ```text
/// slope = (p2 - p0) / (bar2 - bar0)
/// base(bar) = p0 + slope * (bar - bar0)
/// width = p1 - base(bar1)
/// level(r) = base(cur) + r * width
/// ```
///
/// Parameter-free; construction is infallible. Returns `None` until three pivots
/// have confirmed.
///
/// See `crates/wickra-core/src/indicators/fib_channel.rs`.
#[derive(Debug, Clone)]
pub struct FibChannel {
swing: SwingTracker,
}
impl FibChannel {
/// Construct a new Fibonacci Channel tracker.
#[must_use]
pub const fn new() -> Self {
Self {
swing: SwingTracker::new(SWING_THRESHOLD, 3),
}
}
fn channel(&self) -> Option<FibChannelOutput> {
let pivots = self.swing.pivots();
let p0 = pivots.first()?;
let p1 = pivots.get(1)?;
let p2 = pivots.get(2)?;
// p0 and p2 are the same-direction outer pivots; their bars differ
// strictly, so the slope denominator is non-zero.
let slope = (p2.price - p0.price) / (p2.bar - p0.bar) as f64;
let base_at = |bar: usize| p0.price + slope * (bar - p0.bar) as f64;
let width = p1.price - base_at(p1.bar);
let base = base_at(self.swing.current_bar());
Some(FibChannelOutput {
base,
level_618: base + RATIOS[0] * width,
level_1000: base + RATIOS[1] * width,
level_1618: base + RATIOS[2] * width,
})
}
}
impl Default for FibChannel {
fn default() -> Self {
Self::new()
}
}
impl Indicator for FibChannel {
type Input = Candle;
type Output = FibChannelOutput;
fn update(&mut self, candle: Candle) -> Option<FibChannelOutput> {
self.swing.update(candle);
self.channel()
}
fn reset(&mut self) {
self.swing.reset();
}
fn warmup_period(&self) -> usize {
3
}
fn is_ready(&self) -> bool {
self.swing.pivots().len() >= 3
}
fn name(&self) -> &'static str {
"FibChannel"
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::traits::BatchExt;
use approx::assert_relative_eq;
fn c(high: f64, low: f64, ts: i64) -> Candle {
Candle::new(low, high, low, low, 1.0, ts).unwrap()
}
/// Pivots: high 200 (bar 0), low 100 (bar 1), high 220 (bar 3); confirmed at
/// bar 4 so the channel is first reported at current bar 4.
fn three_pivots() -> Vec<Candle> {
vec![
c(200.0, 199.0, 0),
c(190.0, 100.0, 1), // confirm high @200, low candidate @100
c(110.0, 108.0, 2), // confirm low @100, high candidate @110
c(220.0, 210.0, 3), // extend high to 220 (bar 3)
c(200.0, 150.0, 4), // confirm high @220 -> three pivots
]
}
#[test]
fn accessors_and_metadata() {
let indicator = FibChannel::new();
assert_eq!(indicator.name(), "FibChannel");
assert_eq!(indicator.warmup_period(), 3);
assert!(!indicator.is_ready());
assert!(!FibChannel::default().is_ready());
}
#[test]
fn no_output_before_three_pivots() {
let mut indicator = FibChannel::new();
let outputs: Vec<_> = [c(200.0, 199.0, 0), c(190.0, 100.0, 1), c(110.0, 108.0, 2)]
.into_iter()
.map(|x| indicator.update(x))
.collect();
// Only two pivots confirm within these three bars.
assert!(outputs.iter().all(Option::is_none));
assert!(!indicator.is_ready());
}
#[test]
fn channel_levels_from_three_pivots() {
let mut indicator = FibChannel::new();
let mut last = None;
for candle in three_pivots() {
last = indicator.update(candle);
}
let v = last.unwrap();
assert!(indicator.is_ready());
// Base through highs (0,200) and (3,220); width from low (1,100); cur = 4.
let slope = (220.0 - 200.0) / 3.0;
let base_cur = 200.0 + slope * 4.0;
let width = 100.0 - (200.0 + slope * 1.0);
assert_relative_eq!(v.base, base_cur);
assert_relative_eq!(v.level_1000, base_cur + width);
assert_relative_eq!(v.level_618, base_cur + 0.618 * width);
assert_relative_eq!(v.level_1618, base_cur + 1.618 * width);
}
#[test]
fn reset_clears_state() {
let mut indicator = FibChannel::new();
for candle in three_pivots() {
let _ = indicator.update(candle);
}
assert!(indicator.is_ready());
indicator.reset();
assert!(!indicator.is_ready());
assert!(indicator.update(c(100.0, 99.5, 0)).is_none());
}
#[test]
fn batch_equals_streaming() {
let candles = three_pivots();
let mut a = FibChannel::new();
let mut b = FibChannel::new();
assert_eq!(
a.batch(&candles),
candles.iter().map(|x| b.update(*x)).collect::<Vec<_>>()
);
}
}
@@ -0,0 +1,180 @@
//! Fibonacci Fan — trendlines fanning from a swing start through the
//! retracement levels at the swing end, extended to the current bar.
use crate::indicators::pattern_swing::{SwingTracker, SWING_THRESHOLD};
use crate::ohlcv::Candle;
use crate::traits::Indicator;
/// The three fan ratios drawn (38.2% / 50% / 61.8%).
const RATIOS: [f64; 3] = [0.382, 0.5, 0.618];
/// Fibonacci Fan line prices evaluated at the current bar.
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct FibFanOutput {
/// Price of the 38.2% fan line at the current bar.
pub fan_382: f64,
/// Price of the 50% fan line at the current bar.
pub fan_500: f64,
/// Price of the 61.8% fan line at the current bar.
pub fan_618: f64,
}
/// Fibonacci Fan (`FibFan`).
///
/// Anchored at the start of the most recent confirmed swing leg, three lines fan
/// out through the 38.2% / 50% / 61.8% retracement levels located at the leg's
/// end bar, then extend to the current bar. Each line's price is reported as the
/// fan opens with elapsed time.
///
/// ```text
/// line(r) = start + r * (end - start) * (cur - start_bar) / (end_bar - start_bar)
/// ```
///
/// Parameter-free; construction is infallible. Returns `None` until the first
/// leg is complete.
///
/// See `crates/wickra-core/src/indicators/fib_fan.rs`.
#[derive(Debug, Clone)]
pub struct FibFan {
swing: SwingTracker,
}
impl FibFan {
/// Construct a new Fibonacci Fan tracker.
#[must_use]
pub const fn new() -> Self {
Self {
swing: SwingTracker::new(SWING_THRESHOLD, 2),
}
}
fn fan(&self) -> Option<FibFanOutput> {
let pivots = self.swing.pivots();
let start = pivots.first()?;
let end = pivots.get(1)?;
// Consecutive pivots occur at strictly increasing bars, so the span is
// always at least one bar — no division by zero.
let span_bars = (end.bar - start.bar) as f64;
let elapsed = (self.swing.current_bar() - start.bar) as f64;
let progress = elapsed / span_bars;
let line = |r: f64| start.price + r * (end.price - start.price) * progress;
Some(FibFanOutput {
fan_382: line(RATIOS[0]),
fan_500: line(RATIOS[1]),
fan_618: line(RATIOS[2]),
})
}
}
impl Default for FibFan {
fn default() -> Self {
Self::new()
}
}
impl Indicator for FibFan {
type Input = Candle;
type Output = FibFanOutput;
fn update(&mut self, candle: Candle) -> Option<FibFanOutput> {
self.swing.update(candle);
self.fan()
}
fn reset(&mut self) {
self.swing.reset();
}
fn warmup_period(&self) -> usize {
2
}
fn is_ready(&self) -> bool {
self.swing.pivots().len() >= 2
}
fn name(&self) -> &'static str {
"FibFan"
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::traits::BatchExt;
use approx::assert_relative_eq;
fn c(high: f64, low: f64, ts: i64) -> Candle {
Candle::new(low, high, low, low, 1.0, ts).unwrap()
}
/// Drive a leg start=200 (bar 0) -> end=100 (bar 2), confirmed at bar 3, so
/// the fan is first reported at bar 3 with `progress = 3 / 2 = 1.5`.
fn down_leg() -> Vec<Candle> {
vec![
c(200.0, 199.0, 0), // bootstrap high @200 (bar 0)
c(190.0, 160.0, 1), // confirm high @200, low candidate @160
c(150.0, 100.0, 2), // extend low to 100 (bar 2)
c(110.0, 105.0, 3), // confirm low @100 -> two pivots
]
}
#[test]
fn accessors_and_metadata() {
let indicator = FibFan::new();
assert_eq!(indicator.name(), "FibFan");
assert_eq!(indicator.warmup_period(), 2);
assert!(!indicator.is_ready());
assert!(!FibFan::default().is_ready());
}
#[test]
fn no_output_before_two_pivots() {
let mut indicator = FibFan::new();
// Only the high confirms here; no end pivot yet.
let outputs: Vec<_> = [c(200.0, 199.0, 0), c(190.0, 150.0, 1)]
.into_iter()
.map(|x| indicator.update(x))
.collect();
assert!(outputs.iter().all(Option::is_none));
assert!(!indicator.is_ready());
}
#[test]
fn fan_lines_open_with_elapsed_time() {
let mut indicator = FibFan::new();
let mut last = None;
for candle in down_leg() {
last = indicator.update(candle);
}
let v = last.unwrap();
assert!(indicator.is_ready());
// progress = (3 - 0) / (2 - 0) = 1.5; line(r) = 200 + r*(-100)*1.5.
assert_relative_eq!(v.fan_382, 200.0 - 0.382 * 150.0);
assert_relative_eq!(v.fan_500, 125.0);
assert_relative_eq!(v.fan_618, 200.0 - 0.618 * 150.0);
}
#[test]
fn reset_clears_state() {
let mut indicator = FibFan::new();
for candle in down_leg() {
let _ = indicator.update(candle);
}
assert!(indicator.is_ready());
indicator.reset();
assert!(!indicator.is_ready());
assert!(indicator.update(c(100.0, 99.5, 0)).is_none());
}
#[test]
fn batch_equals_streaming() {
let candles = down_leg();
let mut a = FibFan::new();
let mut b = FibFan::new();
assert_eq!(
a.batch(&candles),
candles.iter().map(|x| b.update(*x)).collect::<Vec<_>>()
);
}
}
@@ -0,0 +1,181 @@
//! Fibonacci Time Zones — vertical markers at Fibonacci bar-distances from the
//! most recent swing pivot.
use crate::indicators::pattern_swing::{SwingTracker, SWING_THRESHOLD};
use crate::ohlcv::Candle;
use crate::traits::Indicator;
/// Where the current bar sits relative to the Fibonacci time-zone grid anchored
/// on the most recent confirmed pivot.
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct FibTimeZonesOutput {
/// `1.0` when the current bar lands on a Fibonacci time zone (a bar distance
/// of 1, 2, 3, 5, 8, 13, … from the anchor pivot), otherwise `0.0`.
pub on_zone: f64,
/// Number of bars until the next Fibonacci time zone (`0` is never returned —
/// when on a zone this is the gap to the following one).
pub bars_to_next: f64,
}
/// Fibonacci Time Zones (`FibTimeZones`).
///
/// Anchored on the most recent confirmed swing pivot, the Fibonacci sequence
/// `1, 2, 3, 5, 8, 13, …` marks bars at which trend changes are classically
/// anticipated. Reports whether the current bar is on a zone and how many bars
/// remain until the next one.
///
/// Parameter-free; construction is infallible. Returns `None` until the first
/// pivot has confirmed.
///
/// See `crates/wickra-core/src/indicators/fib_time_zones.rs`.
#[derive(Debug, Clone)]
pub struct FibTimeZones {
swing: SwingTracker,
}
impl FibTimeZones {
/// Construct a new Fibonacci Time Zones tracker.
#[must_use]
pub const fn new() -> Self {
Self {
swing: SwingTracker::new(SWING_THRESHOLD, 2),
}
}
fn zones(&self) -> Option<FibTimeZonesOutput> {
let anchor = self.swing.pivots().last()?;
let distance = self.swing.current_bar() - anchor.bar;
// Walk the time-zone sequence 1, 2, 3, 5, 8, … : `lo` advances through the
// members, `on_zone` records a hit, and the loop exits with `lo` holding
// the smallest member strictly greater than `distance`.
let (mut lo, mut hi) = (1usize, 2usize);
let mut on_zone = false;
while lo <= distance {
if lo == distance {
on_zone = true;
}
let next = lo + hi;
lo = hi;
hi = next;
}
Some(FibTimeZonesOutput {
on_zone: f64::from(u8::from(on_zone)),
bars_to_next: (lo - distance) as f64,
})
}
}
impl Default for FibTimeZones {
fn default() -> Self {
Self::new()
}
}
impl Indicator for FibTimeZones {
type Input = Candle;
type Output = FibTimeZonesOutput;
fn update(&mut self, candle: Candle) -> Option<FibTimeZonesOutput> {
self.swing.update(candle);
self.zones()
}
fn reset(&mut self) {
self.swing.reset();
}
fn warmup_period(&self) -> usize {
2
}
fn is_ready(&self) -> bool {
!self.swing.pivots().is_empty()
}
fn name(&self) -> &'static str {
"FibTimeZones"
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::traits::BatchExt;
use approx::assert_relative_eq;
fn c(high: f64, low: f64, ts: i64) -> Candle {
Candle::new(low, high, low, low, 1.0, ts).unwrap()
}
/// One pivot confirms at bar 0 (high @200, confirmed at bar 1); subsequent
/// flat bars neither extend nor confirm, so the anchor stays at bar 0 and the
/// distance equals the current bar index.
fn anchored_run() -> Vec<Candle> {
let mut bars = vec![c(200.0, 199.0, 0), c(190.0, 150.0, 1)];
for ts in 2..=5 {
bars.push(c(155.0, 151.0, ts));
}
bars
}
#[test]
fn accessors_and_metadata() {
let indicator = FibTimeZones::new();
assert_eq!(indicator.name(), "FibTimeZones");
assert_eq!(indicator.warmup_period(), 2);
assert!(!indicator.is_ready());
assert!(!FibTimeZones::default().is_ready());
}
#[test]
fn no_output_before_first_pivot() {
let mut indicator = FibTimeZones::new();
// The bootstrap bar confirms nothing.
assert!(indicator.update(c(200.0, 199.0, 0)).is_none());
assert!(!indicator.is_ready());
}
#[test]
fn flags_zones_and_counts_to_next() {
let mut indicator = FibTimeZones::new();
let out: Vec<_> = anchored_run()
.into_iter()
.map(|x| indicator.update(x))
.collect();
assert!(out[0].is_none()); // bootstrap, no pivot yet
assert!(indicator.is_ready());
// out[i] is reported at current bar i; anchor at bar 0 → distance = i.
let d1 = out[1].unwrap(); // distance 1 → a zone
assert_relative_eq!(d1.on_zone, 1.0);
assert_relative_eq!(d1.bars_to_next, 1.0); // next zone at 2
let d4 = out[4].unwrap(); // distance 4 → not a zone
assert_relative_eq!(d4.on_zone, 0.0);
assert_relative_eq!(d4.bars_to_next, 1.0); // next zone at 5
let d5 = out[5].unwrap(); // distance 5 → a zone
assert_relative_eq!(d5.on_zone, 1.0);
assert_relative_eq!(d5.bars_to_next, 3.0); // next zone at 8
}
#[test]
fn reset_clears_state() {
let mut indicator = FibTimeZones::new();
for candle in anchored_run() {
let _ = indicator.update(candle);
}
assert!(indicator.is_ready());
indicator.reset();
assert!(!indicator.is_ready());
assert!(indicator.update(c(100.0, 99.5, 0)).is_none());
}
#[test]
fn batch_equals_streaming() {
let candles = anchored_run();
let mut a = FibTimeZones::new();
let mut b = FibTimeZones::new();
assert_eq!(
a.batch(&candles),
candles.iter().map(|x| b.update(*x)).collect::<Vec<_>>()
);
}
}
+13 -1
View File
@@ -111,10 +111,14 @@ mod evening_doji_star;
mod evwma;
mod falling_three_methods;
mod fama;
mod fib_arcs;
mod fib_channel;
mod fib_confluence;
mod fib_extension;
mod fib_fan;
mod fib_projection;
mod fib_retracement;
mod fib_time_zones;
mod fibonacci_pivots;
mod fisher_transform;
mod flag_pennant;
@@ -484,10 +488,14 @@ pub use evening_doji_star::EveningDojiStar;
pub use evwma::Evwma;
pub use falling_three_methods::FallingThreeMethods;
pub use fama::Fama;
pub use fib_arcs::{FibArcs, FibArcsOutput};
pub use fib_channel::{FibChannel, FibChannelOutput};
pub use fib_confluence::{FibConfluence, FibConfluenceOutput};
pub use fib_extension::{FibExtension, FibExtensionOutput};
pub use fib_fan::{FibFan, FibFanOutput};
pub use fib_projection::{FibProjection, FibProjectionOutput};
pub use fib_retracement::{FibRetracement, FibRetracementOutput};
pub use fib_time_zones::{FibTimeZones, FibTimeZonesOutput};
pub use fibonacci_pivots::{FibonacciPivots, FibonacciPivotsOutput};
pub use fisher_transform::FisherTransform;
pub use flag_pennant::FlagPennant;
@@ -1243,6 +1251,10 @@ pub const FAMILIES: &[(&str, &[&str])] = &[
"AutoFib",
"GoldenPocket",
"FibConfluence",
"FibFan",
"FibArcs",
"FibChannel",
"FibTimeZones",
],
),
];
@@ -1273,6 +1285,6 @@ mod family_tests {
// the actual indicator count is the early-warning signal that an
// indicator was added without being assigned a family.
let total: usize = FAMILIES.iter().map(|(_, ns)| ns.len()).sum();
assert_eq!(total, 373, "FAMILIES total drifted from indicator count");
assert_eq!(total, 377, "FAMILIES total drifted from indicator count");
}
}
@@ -27,14 +27,19 @@ pub(crate) const SWING_THRESHOLD: f64 = 0.05;
/// flat boundary of a rectangle.
pub(crate) const LEVEL_TOLERANCE: f64 = 0.03;
/// A confirmed swing pivot: the extreme price the swing turned from and its
/// direction (`+1.0` for a swing high, `-1.0` for a swing low).
/// 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.
@@ -50,6 +55,8 @@ pub(crate) struct Pivot {
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>,
}
@@ -61,6 +68,8 @@ struct State {
direction: f64,
/// The running candidate extreme price.
extreme: f64,
/// Bar index at which the running extreme was last set.
extreme_bar: usize,
}
impl SwingTracker {
@@ -75,6 +84,7 @@ impl SwingTracker {
Self {
threshold,
cap,
bars_seen: 0,
state: None,
pivots: Vec::new(),
}
@@ -82,11 +92,14 @@ impl SwingTracker {
/// 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;
};
@@ -97,6 +110,7 @@ impl SwingTracker {
self.state = Some(State {
direction: 1.0,
extreme: candle.high,
extreme_bar: bar,
});
return false;
}
@@ -105,10 +119,12 @@ impl SwingTracker {
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;
}
@@ -119,6 +135,7 @@ impl SwingTracker {
self.state = Some(State {
direction: -1.0,
extreme: candle.low,
extreme_bar: bar,
});
return false;
}
@@ -127,10 +144,12 @@ impl SwingTracker {
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;
}
@@ -138,6 +157,12 @@ impl SwingTracker {
}
}
/// 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 {
@@ -152,6 +177,7 @@ impl SwingTracker {
/// 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();
}
@@ -312,9 +338,11 @@ mod tests {
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.
@@ -323,9 +351,11 @@ mod tests {
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,
})
);
}
@@ -382,18 +412,22 @@ mod tests {
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));
@@ -402,18 +436,22 @@ mod tests {
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));
@@ -425,26 +463,32 @@ mod tests {
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);
@@ -483,4 +527,16 @@ mod tests {
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);
}
}