feat(family-08): Pivots & Support/Resistance (7 indicators) (#47)

* feat(family-08): add Classic, Fibonacci, Camarilla, Woodie and DeMark pivots + Williams Fractals + ZigZag

Seven new indicators land the previously empty Pivots & S/R family
(family 08), each implemented in wickra-core with the full Indicator
trait surface (update / reset / warmup_period / is_ready / name),
exposed across Python (PyO3), Node (napi-rs) and WASM (wasm-bindgen)
with the standard streaming + batch APIs, and covered by Rust unit
tests, Python streaming-vs-batch + reference-value tests, Node
streaming-vs-batch tests, the candle-input fuzz target and Rust
microbenchmarks.

- ClassicPivots (7 levels): PP = (H+L+C)/3, three R/S tiers per the
  floor-trader formulas.
- FibonacciPivots (7 levels): PP plus R/S spaced by 0.382 / 0.618 /
  1.000 of the prior range.
- Camarilla (9 levels): Nick Stott's four-tier `C +/- (H - L) * 1.1 /
  {12, 6, 4, 2}` levels.
- WoodiePivots (5 levels): close-weighted PP = (H + L + 2*C) / 4 plus
  two R/S tiers.
- DemarkPivots (3 levels): conditional X sum based on the previous
  bar's open-vs-close relationship.
- WilliamsFractals: five-bar swing detector emitting optional up/down
  fractal prices at the centre of each window.
- ZigZag: percent-threshold swing tracker, non-repainting; emits the
  just-completed extreme and direction on confirmed reversals only.

README family table updated to nine families / 78 indicators;
CHANGELOG records the family-08 addition under [Unreleased].

* fix(family-08 tests): unify MULTI dict to 3-tuple (factory, batch_call, k)

The HEAD-side family-08 test parametrised MULTI[name] as
`(factory, batch_call, output_arity)` so that pivots with arity 3/5/7/9
fit the same harness. Main's entries arrived as 2-tuples; convert them
all to the 3-tuple shape so `make, batch_call, k = MULTI[name]` unpacks
cleanly. Lifecycle test now indexes the tuple instead of destructuring.

* test(zig_zag): tighten flat-oscillation test (drop dead counter branch)

The previous version of `small_oscillations_yield_no_swings` counted
emitted swings, but the assertion proves the counter never increments
so codecov flagged `emitted += 1` as uncovered. Switch to a per-bar
`assert!(...is_none())` — same coverage of the no-swing path, no dead
branch.
This commit is contained in:
kingchenc
2026-05-25 20:06:46 +02:00
committed by GitHub
parent f10b8c2e2d
commit 7e1e988596
19 changed files with 3379 additions and 45 deletions
@@ -0,0 +1,197 @@
//! Camarilla Pivot Points (Nick Stott).
use crate::ohlcv::Candle;
use crate::traits::Indicator;
/// Camarilla Pivot Points output: four resistances, the pivot, four supports.
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct CamarillaPivotsOutput {
/// Pivot Point: `(H + L + C) / 3` (informational, not in the Camarilla R/S formulas).
pub pp: f64,
/// Resistance 1: `C + (H L)·1.1/12`.
pub r1: f64,
/// Resistance 2: `C + (H L)·1.1/6`.
pub r2: f64,
/// Resistance 3: `C + (H L)·1.1/4`.
pub r3: f64,
/// Resistance 4: `C + (H L)·1.1/2`.
pub r4: f64,
/// Support 1: `C (H L)·1.1/12`.
pub s1: f64,
/// Support 2: `C (H L)·1.1/6`.
pub s2: f64,
/// Support 3: `C (H L)·1.1/4`.
pub s3: f64,
/// Support 4: `C (H L)·1.1/2`.
pub s4: f64,
}
/// Camarilla Pivot Points — Nick Stott's four-tier range-based level set.
/// Anchored on the prior close rather than the typical price, with widths
/// scaled by the constant `1.1` divided by `{12, 6, 4, 2}`.
///
/// ```text
/// PP = (H + L + C) / 3
/// R_n = C + (H L) · 1.1 / d_n S_n = C (H L) · 1.1 / d_n
/// where d_1 = 12, d_2 = 6, d_3 = 4, d_4 = 2
/// ```
///
/// R3/S3 are typically used as reversal levels; R4/S4 as breakout levels. As
/// with the other pivot variants there are no parameters and no warmup — the
/// first candle produces the first set of levels.
///
/// # Example
///
/// ```
/// use wickra_core::{Camarilla, Candle, Indicator};
///
/// let prev = Candle::new(100.0, 110.0, 90.0, 105.0, 1.0, 0).unwrap();
/// let levels = Camarilla::new().update(prev).unwrap();
/// assert!(levels.r4 > levels.r3);
/// assert!(levels.s4 < levels.s3);
/// ```
#[derive(Debug, Clone, Default)]
pub struct Camarilla {
ready: bool,
}
impl Camarilla {
/// Construct a new Camarilla Pivot Points indicator.
pub const fn new() -> Self {
Self { ready: false }
}
}
const CAM: f64 = 1.1;
impl Indicator for Camarilla {
type Input = Candle;
type Output = CamarillaPivotsOutput;
fn update(&mut self, candle: Candle) -> Option<CamarillaPivotsOutput> {
let (h, l, c) = (candle.high, candle.low, candle.close);
let range = h - l;
let pp = (h + l + c) / 3.0;
let w1 = range * CAM / 12.0;
let w2 = range * CAM / 6.0;
let w3 = range * CAM / 4.0;
let w4 = range * CAM / 2.0;
let out = CamarillaPivotsOutput {
pp,
r1: c + w1,
r2: c + w2,
r3: c + w3,
r4: c + w4,
s1: c - w1,
s2: c - w2,
s3: c - w3,
s4: c - w4,
};
self.ready = true;
Some(out)
}
fn reset(&mut self) {
self.ready = false;
}
fn warmup_period(&self) -> usize {
1
}
fn is_ready(&self) -> bool {
self.ready
}
fn name(&self) -> &'static str {
"Camarilla"
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::traits::BatchExt;
fn c(h: f64, l: f64, close: f64, ts: i64) -> Candle {
Candle::new(close, h, l, close, 1.0, ts).unwrap()
}
#[test]
fn formula_reference_values() {
// H=110, L=90, C=105, range=20.
let levels = Camarilla::new().update(c(110.0, 90.0, 105.0, 0)).unwrap();
let range = 20.0;
assert!((levels.r1 - (105.0 + range * 1.1 / 12.0)).abs() < 1e-12);
assert!((levels.r2 - (105.0 + range * 1.1 / 6.0)).abs() < 1e-12);
assert!((levels.r3 - (105.0 + range * 1.1 / 4.0)).abs() < 1e-12);
assert!((levels.r4 - (105.0 + range * 1.1 / 2.0)).abs() < 1e-12);
assert!((levels.s1 - (105.0 - range * 1.1 / 12.0)).abs() < 1e-12);
assert!((levels.s4 - (105.0 - range * 1.1 / 2.0)).abs() < 1e-12);
}
#[test]
fn resistance_strictly_widens_with_index() {
let levels = Camarilla::new().update(c(120.0, 80.0, 110.0, 0)).unwrap();
assert!(levels.r4 > levels.r3);
assert!(levels.r3 > levels.r2);
assert!(levels.r2 > levels.r1);
assert!(levels.r1 > 110.0);
assert!(levels.s1 < 110.0);
assert!(levels.s2 < levels.s1);
assert!(levels.s3 < levels.s2);
assert!(levels.s4 < levels.s3);
}
#[test]
fn constant_series_collapses_levels() {
let levels = Camarilla::new().update(c(50.0, 50.0, 50.0, 0)).unwrap();
assert_eq!(levels.r4, 50.0);
assert_eq!(levels.s4, 50.0);
assert_eq!(levels.pp, 50.0);
}
#[test]
fn warmup_and_ready() {
let mut p = Camarilla::new();
assert!(!p.is_ready());
assert_eq!(p.warmup_period(), 1);
p.update(c(11.0, 9.0, 10.0, 0));
assert!(p.is_ready());
}
#[test]
fn reset_clears_state() {
let mut p = Camarilla::new();
p.update(c(11.0, 9.0, 10.0, 0));
p.reset();
assert!(!p.is_ready());
}
#[test]
fn batch_equals_streaming() {
let candles: Vec<Candle> = (0_i32..40)
.map(|i| {
c(
f64::from(i) + 2.0,
f64::from(i),
f64::from(i) + 1.0,
i.into(),
)
})
.collect();
let mut a = Camarilla::new();
let mut b = Camarilla::new();
assert_eq!(
a.batch(&candles),
candles.iter().map(|x| b.update(*x)).collect::<Vec<_>>()
);
}
#[test]
fn accessors_and_metadata() {
let p = Camarilla::new();
assert_eq!(p.warmup_period(), 1);
assert_eq!(p.name(), "Camarilla");
}
}
@@ -0,0 +1,202 @@
//! Classic (Floor-Trader) Pivot Points.
use crate::ohlcv::Candle;
use crate::traits::Indicator;
/// Classic Pivot Points output: pivot plus three resistances and three supports.
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct ClassicPivotsOutput {
/// Pivot Point: `(H + L + C) / 3`.
pub pp: f64,
/// Resistance 1: `2·PP L`.
pub r1: f64,
/// Resistance 2: `PP + (H L)`.
pub r2: f64,
/// Resistance 3: `H + 2·(PP L)`.
pub r3: f64,
/// Support 1: `2·PP H`.
pub s1: f64,
/// Support 2: `PP (H L)`.
pub s2: f64,
/// Support 3: `L 2·(H PP)`.
pub s3: f64,
}
/// Classic (Floor-Trader) Pivot Points — the standard pivot/resistance/support
/// levels computed from a completed candle's high, low and close.
///
/// ```text
/// PP = (H + L + C) / 3
/// R1 = 2·PP L S1 = 2·PP H
/// R2 = PP + (H L) S2 = PP (H L)
/// R3 = H + 2·(PP L) S3 = L 2·(H PP)
/// ```
///
/// Pivots are typically computed once per session (day, week, month) from the
/// **previous** session's bar and used as fixed reference levels for the next
/// session. The streaming API here simply re-evaluates the formula on every
/// candle it sees, which makes it a one-step transform you can wire to any
/// pre-aggregated session bar. There are no parameters and no warmup — the
/// first candle produces the first set of levels.
///
/// # Example
///
/// ```
/// use wickra_core::{Candle, ClassicPivots, Indicator};
///
/// let prev = Candle::new(100.0, 110.0, 90.0, 105.0, 1.0, 0).unwrap();
/// let mut pp = ClassicPivots::new();
/// let levels = pp.update(prev).unwrap();
/// assert!((levels.pp - 101.6666666666).abs() < 1e-9);
/// assert!(levels.r1 > levels.pp);
/// assert!(levels.s1 < levels.pp);
/// ```
#[derive(Debug, Clone, Default)]
pub struct ClassicPivots {
ready: bool,
}
impl ClassicPivots {
/// Construct a new Classic Pivot Points indicator. The indicator has no
/// parameters and no warmup.
pub const fn new() -> Self {
Self { ready: false }
}
}
impl Indicator for ClassicPivots {
type Input = Candle;
type Output = ClassicPivotsOutput;
fn update(&mut self, candle: Candle) -> Option<ClassicPivotsOutput> {
let (h, l, c) = (candle.high, candle.low, candle.close);
let pp = (h + l + c) / 3.0;
let range = h - l;
let out = ClassicPivotsOutput {
pp,
r1: 2.0 * pp - l,
r2: pp + range,
r3: h + 2.0 * (pp - l),
s1: 2.0 * pp - h,
s2: pp - range,
s3: l - 2.0 * (h - pp),
};
self.ready = true;
Some(out)
}
fn reset(&mut self) {
self.ready = false;
}
fn warmup_period(&self) -> usize {
1
}
fn is_ready(&self) -> bool {
self.ready
}
fn name(&self) -> &'static str {
"ClassicPivots"
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::traits::BatchExt;
fn c(h: f64, l: f64, close: f64, ts: i64) -> Candle {
Candle::new(close, h, l, close, 1.0, ts).unwrap()
}
#[test]
fn formula_reference_values() {
// H=110, L=90, C=105 -> PP = 305/3 ≈ 101.6667.
let levels = ClassicPivots::new()
.update(c(110.0, 90.0, 105.0, 0))
.unwrap();
let pp = 305.0 / 3.0;
let range = 20.0;
assert!((levels.pp - pp).abs() < 1e-12);
assert!((levels.r1 - (2.0 * pp - 90.0)).abs() < 1e-12);
assert!((levels.s1 - (2.0 * pp - 110.0)).abs() < 1e-12);
assert!((levels.r2 - (pp + range)).abs() < 1e-12);
assert!((levels.s2 - (pp - range)).abs() < 1e-12);
assert!((levels.r3 - (110.0 + 2.0 * (pp - 90.0))).abs() < 1e-12);
assert!((levels.s3 - (90.0 - 2.0 * (110.0 - pp))).abs() < 1e-12);
}
#[test]
fn ordering_resistance_above_pivot_above_support() {
// For any non-degenerate bar with H > L, R-levels exceed PP and S-levels lie below.
let levels = ClassicPivots::new()
.update(c(200.0, 100.0, 150.0, 0))
.unwrap();
assert!(levels.r3 >= levels.r2);
assert!(levels.r2 >= levels.r1);
assert!(levels.r1 >= levels.pp);
assert!(levels.pp >= levels.s1);
assert!(levels.s1 >= levels.s2);
assert!(levels.s2 >= levels.s3);
}
#[test]
fn constant_series_collapses_levels() {
// H = L = C means range = 0 and every level equals the close.
let levels = ClassicPivots::new().update(c(50.0, 50.0, 50.0, 0)).unwrap();
assert_eq!(levels.pp, 50.0);
assert_eq!(levels.r1, 50.0);
assert_eq!(levels.s1, 50.0);
assert_eq!(levels.r2, 50.0);
assert_eq!(levels.s2, 50.0);
assert_eq!(levels.r3, 50.0);
assert_eq!(levels.s3, 50.0);
}
#[test]
fn ready_after_first_update_warmup_is_one() {
let mut pp = ClassicPivots::new();
assert!(!pp.is_ready());
assert_eq!(pp.warmup_period(), 1);
pp.update(c(11.0, 9.0, 10.0, 0));
assert!(pp.is_ready());
}
#[test]
fn reset_clears_state() {
let mut pp = ClassicPivots::new();
pp.update(c(11.0, 9.0, 10.0, 0));
assert!(pp.is_ready());
pp.reset();
assert!(!pp.is_ready());
}
#[test]
fn batch_equals_streaming() {
let candles: Vec<Candle> = (0_i32..40)
.map(|i| {
c(
f64::from(i) + 2.0,
f64::from(i),
f64::from(i) + 1.0,
i.into(),
)
})
.collect();
let mut a = ClassicPivots::new();
let mut b = ClassicPivots::new();
assert_eq!(
a.batch(&candles),
candles.iter().map(|x| b.update(*x)).collect::<Vec<_>>()
);
}
#[test]
fn accessors_and_metadata() {
let pp = ClassicPivots::new();
assert_eq!(pp.warmup_period(), 1);
assert_eq!(pp.name(), "ClassicPivots");
}
}
@@ -0,0 +1,192 @@
//! `DeMark` Pivot Points.
use crate::ohlcv::Candle;
use crate::traits::Indicator;
/// `DeMark` Pivot Points output: a single resistance, pivot and support.
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct DemarkPivotsOutput {
/// Pivot Point: `X / 4` where `X` is the conditional sum (see [`DemarkPivots`]).
pub pp: f64,
/// Resistance 1: `X / 2 L`.
pub r1: f64,
/// Support 1: `X / 2 H`.
pub s1: f64,
}
/// `DeMark` Pivot Points — Tom `DeMark`'s conditional pivot formulation, derived
/// from a sum `X` that depends on whether the bar closed up, down or flat.
///
/// ```text
/// X = 2·H + L + C if C < O (down bar)
/// H + 2·L + C if C > O (up bar)
/// H + L + 2·C if C == O (doji)
///
/// PP = X / 4
/// R1 = X / 2 L
/// S1 = X / 2 H
/// ```
///
/// Unlike the classic pivots, only one resistance and one support are
/// produced; `DeMark`'s intent is a tighter, condition-sensitive set rather than
/// a multi-tier fan. The branching means a bar's open carries information that
/// other pivot variants discard.
///
/// # Example
///
/// ```
/// use wickra_core::{Candle, DemarkPivots, Indicator};
///
/// // Up bar: O=100, H=120, L=80, C=110 -> X = H + 2·L + C = 390.
/// let up = Candle::new(100.0, 120.0, 80.0, 110.0, 1.0, 0).unwrap();
/// let lv = DemarkPivots::new().update(up).unwrap();
/// assert!((lv.pp - 97.5).abs() < 1e-9);
/// ```
#[derive(Debug, Clone, Default)]
pub struct DemarkPivots {
ready: bool,
}
impl DemarkPivots {
/// Construct a new `DeMark` Pivot Points indicator.
pub const fn new() -> Self {
Self { ready: false }
}
}
impl Indicator for DemarkPivots {
type Input = Candle;
type Output = DemarkPivotsOutput;
fn update(&mut self, candle: Candle) -> Option<DemarkPivotsOutput> {
let open = candle.open;
let high = candle.high;
let low = candle.low;
let close = candle.close;
let x = if close < open {
2.0 * high + low + close
} else if close > open {
high + 2.0 * low + close
} else {
high + low + 2.0 * close
};
let pp = x / 4.0;
let half = x / 2.0;
let out = DemarkPivotsOutput {
pp,
r1: half - low,
s1: half - high,
};
self.ready = true;
Some(out)
}
fn reset(&mut self) {
self.ready = false;
}
fn warmup_period(&self) -> usize {
1
}
fn is_ready(&self) -> bool {
self.ready
}
fn name(&self) -> &'static str {
"DemarkPivots"
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::traits::BatchExt;
#[test]
fn down_bar_uses_2h_plus_l_plus_c() {
// O=110, H=120, L=80, C=100 (close < open) -> X = 2·120 + 80 + 100 = 420.
let cd = Candle::new(110.0, 120.0, 80.0, 100.0, 1.0, 0).unwrap();
let lv = DemarkPivots::new().update(cd).unwrap();
assert!((lv.pp - 105.0).abs() < 1e-12);
assert!((lv.r1 - (210.0 - 80.0)).abs() < 1e-12);
assert!((lv.s1 - (210.0 - 120.0)).abs() < 1e-12);
}
#[test]
fn up_bar_uses_h_plus_2l_plus_c() {
// O=100, H=120, L=80, C=110 (close > open) -> X = 120 + 160 + 110 = 390.
let cd = Candle::new(100.0, 120.0, 80.0, 110.0, 1.0, 0).unwrap();
let lv = DemarkPivots::new().update(cd).unwrap();
assert!((lv.pp - 97.5).abs() < 1e-12);
assert!((lv.r1 - (195.0 - 80.0)).abs() < 1e-12);
assert!((lv.s1 - (195.0 - 120.0)).abs() < 1e-12);
}
#[test]
fn doji_uses_h_plus_l_plus_2c() {
// O = C = 100, H=120, L=80 -> X = 120 + 80 + 200 = 400.
let cd = Candle::new(100.0, 120.0, 80.0, 100.0, 1.0, 0).unwrap();
let lv = DemarkPivots::new().update(cd).unwrap();
assert!((lv.pp - 100.0).abs() < 1e-12);
}
#[test]
fn ordering_resistance_above_pivot_above_support() {
let cd = Candle::new(100.0, 120.0, 80.0, 110.0, 1.0, 0).unwrap();
let lv = DemarkPivots::new().update(cd).unwrap();
assert!(lv.r1 >= lv.pp);
assert!(lv.pp >= lv.s1);
}
#[test]
fn constant_series_collapses_levels() {
let cd = Candle::new(50.0, 50.0, 50.0, 50.0, 1.0, 0).unwrap();
let lv = DemarkPivots::new().update(cd).unwrap();
assert_eq!(lv.pp, 50.0);
assert_eq!(lv.r1, 50.0);
assert_eq!(lv.s1, 50.0);
}
#[test]
fn warmup_and_ready() {
let mut p = DemarkPivots::new();
assert!(!p.is_ready());
assert_eq!(p.warmup_period(), 1);
let cd = Candle::new(10.0, 11.0, 9.0, 10.0, 1.0, 0).unwrap();
p.update(cd);
assert!(p.is_ready());
}
#[test]
fn reset_clears_state() {
let mut p = DemarkPivots::new();
let cd = Candle::new(10.0, 11.0, 9.0, 10.0, 1.0, 0).unwrap();
p.update(cd);
p.reset();
assert!(!p.is_ready());
}
#[test]
fn batch_equals_streaming() {
let candles: Vec<Candle> = (0..40)
.map(|i| {
let base = f64::from(i);
Candle::new(base, base + 2.0, base - 0.5, base + 1.0, 1.0, i64::from(i)).unwrap()
})
.collect();
let mut a = DemarkPivots::new();
let mut b = DemarkPivots::new();
assert_eq!(
a.batch(&candles),
candles.iter().map(|x| b.update(*x)).collect::<Vec<_>>()
);
}
#[test]
fn accessors_and_metadata() {
let p = DemarkPivots::new();
assert_eq!(p.warmup_period(), 1);
assert_eq!(p.name(), "DemarkPivots");
}
}
@@ -0,0 +1,195 @@
//! Fibonacci Pivot Points.
use crate::ohlcv::Candle;
use crate::traits::Indicator;
/// Fibonacci Pivot Points output: pivot plus three Fib-spaced resistances and
/// supports.
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct FibonacciPivotsOutput {
/// Pivot Point: `(H + L + C) / 3`.
pub pp: f64,
/// Resistance 1: `PP + 0.382·(H L)`.
pub r1: f64,
/// Resistance 2: `PP + 0.618·(H L)`.
pub r2: f64,
/// Resistance 3: `PP + 1.000·(H L)`.
pub r3: f64,
/// Support 1: `PP 0.382·(H L)`.
pub s1: f64,
/// Support 2: `PP 0.618·(H L)`.
pub s2: f64,
/// Support 3: `PP 1.000·(H L)`.
pub s3: f64,
}
/// Fibonacci Pivot Points — the classic pivot plus three resistances and
/// supports spaced by the Fibonacci ratios 0.382 / 0.618 / 1.000 applied to
/// the prior bar's range.
///
/// ```text
/// PP = (H + L + C) / 3
/// R1 = PP + 0.382·(H L) S1 = PP 0.382·(H L)
/// R2 = PP + 0.618·(H L) S2 = PP 0.618·(H L)
/// R3 = PP + 1.000·(H L) S3 = PP 1.000·(H L)
/// ```
///
/// As with [`crate::ClassicPivots`], levels are typically built from the
/// previous session's bar. There are no parameters and no warmup.
///
/// # Example
///
/// ```
/// use wickra_core::{Candle, FibonacciPivots, Indicator};
///
/// let prev = Candle::new(100.0, 110.0, 90.0, 105.0, 1.0, 0).unwrap();
/// let levels = FibonacciPivots::new().update(prev).unwrap();
/// assert!(levels.r3 > levels.r2);
/// assert!(levels.r2 > levels.r1);
/// assert!(levels.s1 > levels.s2);
/// ```
#[derive(Debug, Clone, Default)]
pub struct FibonacciPivots {
ready: bool,
}
impl FibonacciPivots {
/// Construct a new Fibonacci Pivot Points indicator.
pub const fn new() -> Self {
Self { ready: false }
}
}
const FIB1: f64 = 0.382;
const FIB2: f64 = 0.618;
const FIB3: f64 = 1.000;
impl Indicator for FibonacciPivots {
type Input = Candle;
type Output = FibonacciPivotsOutput;
fn update(&mut self, candle: Candle) -> Option<FibonacciPivotsOutput> {
let (h, l, c) = (candle.high, candle.low, candle.close);
let pp = (h + l + c) / 3.0;
let range = h - l;
let out = FibonacciPivotsOutput {
pp,
r1: pp + FIB1 * range,
r2: pp + FIB2 * range,
r3: pp + FIB3 * range,
s1: pp - FIB1 * range,
s2: pp - FIB2 * range,
s3: pp - FIB3 * range,
};
self.ready = true;
Some(out)
}
fn reset(&mut self) {
self.ready = false;
}
fn warmup_period(&self) -> usize {
1
}
fn is_ready(&self) -> bool {
self.ready
}
fn name(&self) -> &'static str {
"FibonacciPivots"
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::traits::BatchExt;
fn c(h: f64, l: f64, close: f64, ts: i64) -> Candle {
Candle::new(close, h, l, close, 1.0, ts).unwrap()
}
#[test]
fn formula_reference_values() {
// H=110, L=90, range=20, PP = (110+90+100)/3 = 100.
let levels = FibonacciPivots::new()
.update(c(110.0, 90.0, 100.0, 0))
.unwrap();
assert!((levels.pp - 100.0).abs() < 1e-12);
assert!((levels.r1 - (100.0 + 0.382 * 20.0)).abs() < 1e-12);
assert!((levels.r2 - (100.0 + 0.618 * 20.0)).abs() < 1e-12);
assert!((levels.r3 - (100.0 + 20.0)).abs() < 1e-12);
assert!((levels.s1 - (100.0 - 0.382 * 20.0)).abs() < 1e-12);
assert!((levels.s2 - (100.0 - 0.618 * 20.0)).abs() < 1e-12);
assert!((levels.s3 - (100.0 - 20.0)).abs() < 1e-12);
}
#[test]
fn resistances_strictly_above_pp_supports_strictly_below() {
let levels = FibonacciPivots::new()
.update(c(120.0, 80.0, 110.0, 0))
.unwrap();
assert!(levels.r3 > levels.r2);
assert!(levels.r2 > levels.r1);
assert!(levels.r1 > levels.pp);
assert!(levels.pp > levels.s1);
assert!(levels.s1 > levels.s2);
assert!(levels.s2 > levels.s3);
}
#[test]
fn constant_series_collapses_levels() {
let levels = FibonacciPivots::new()
.update(c(50.0, 50.0, 50.0, 0))
.unwrap();
assert_eq!(levels.pp, 50.0);
assert_eq!(levels.r1, 50.0);
assert_eq!(levels.s3, 50.0);
}
#[test]
fn warmup_and_ready() {
let mut p = FibonacciPivots::new();
assert!(!p.is_ready());
assert_eq!(p.warmup_period(), 1);
p.update(c(11.0, 9.0, 10.0, 0));
assert!(p.is_ready());
}
#[test]
fn reset_clears_state() {
let mut p = FibonacciPivots::new();
p.update(c(11.0, 9.0, 10.0, 0));
p.reset();
assert!(!p.is_ready());
}
#[test]
fn batch_equals_streaming() {
let candles: Vec<Candle> = (0_i32..40)
.map(|i| {
c(
f64::from(i) + 2.0,
f64::from(i),
f64::from(i) + 1.0,
i.into(),
)
})
.collect();
let mut a = FibonacciPivots::new();
let mut b = FibonacciPivots::new();
assert_eq!(
a.batch(&candles),
candles.iter().map(|x| b.update(*x)).collect::<Vec<_>>()
);
}
#[test]
fn accessors_and_metadata() {
let p = FibonacciPivots::new();
assert_eq!(p.warmup_period(), 1);
assert_eq!(p.name(), "FibonacciPivots");
}
}
+14
View File
@@ -24,6 +24,7 @@ mod awesome_oscillator_histogram;
mod balance_of_power;
mod bollinger;
mod bollinger_bandwidth;
mod camarilla_pivots;
mod cci;
mod cfo;
mod chaikin_oscillator;
@@ -31,12 +32,14 @@ mod chaikin_volatility;
mod chande_kroll_stop;
mod chandelier_exit;
mod choppiness_index;
mod classic_pivots;
mod cmf;
mod cmo;
mod connors_rsi;
mod coppock;
mod dema;
mod demand_index;
mod demark_pivots;
mod donchian;
mod donchian_stop;
mod double_bollinger;
@@ -45,6 +48,7 @@ mod ease_of_movement;
mod elder_impulse;
mod ema;
mod evwma;
mod fibonacci_pivots;
mod force_index;
mod fractal_chaos_bands;
mod frama;
@@ -125,12 +129,15 @@ mod vwma;
mod vzo;
mod wave_trend;
mod weighted_close;
mod williams_fractals;
mod williams_r;
mod wma;
mod woodie_pivots;
mod yang_zhang;
mod yoyo_exit;
mod z_score;
mod zero_lag_macd;
mod zig_zag;
mod zlema;
pub use acceleration_bands::{AccelerationBands, AccelerationBandsOutput};
@@ -153,6 +160,7 @@ pub use awesome_oscillator_histogram::AwesomeOscillatorHistogram;
pub use balance_of_power::BalanceOfPower;
pub use bollinger::{BollingerBands, BollingerOutput};
pub use bollinger_bandwidth::BollingerBandwidth;
pub use camarilla_pivots::{Camarilla, CamarillaPivotsOutput};
pub use cci::Cci;
pub use cfo::Cfo;
pub use chaikin_oscillator::ChaikinOscillator;
@@ -160,12 +168,14 @@ pub use chaikin_volatility::ChaikinVolatility;
pub use chande_kroll_stop::{ChandeKrollStop, ChandeKrollStopOutput};
pub use chandelier_exit::{ChandelierExit, ChandelierExitOutput};
pub use choppiness_index::ChoppinessIndex;
pub use classic_pivots::{ClassicPivots, ClassicPivotsOutput};
pub use cmf::ChaikinMoneyFlow;
pub use cmo::Cmo;
pub use connors_rsi::ConnorsRsi;
pub use coppock::Coppock;
pub use dema::Dema;
pub use demand_index::DemandIndex;
pub use demark_pivots::{DemarkPivots, DemarkPivotsOutput};
pub use donchian::{Donchian, DonchianOutput};
pub use donchian_stop::{DonchianStop, DonchianStopOutput};
pub use double_bollinger::{DoubleBollinger, DoubleBollingerOutput};
@@ -174,6 +184,7 @@ pub use ease_of_movement::EaseOfMovement;
pub use elder_impulse::ElderImpulse;
pub use ema::Ema;
pub use evwma::Evwma;
pub use fibonacci_pivots::{FibonacciPivots, FibonacciPivotsOutput};
pub use force_index::ForceIndex;
pub use fractal_chaos_bands::{FractalChaosBands, FractalChaosBandsOutput};
pub use frama::Frama;
@@ -254,10 +265,13 @@ pub use vwma::Vwma;
pub use vzo::Vzo;
pub use wave_trend::{WaveTrend, WaveTrendOutput};
pub use weighted_close::WeightedClose;
pub use williams_fractals::{WilliamsFractals, WilliamsFractalsOutput};
pub use williams_r::WilliamsR;
pub use wma::Wma;
pub use woodie_pivots::{WoodiePivots, WoodiePivotsOutput};
pub use yang_zhang::YangZhangVolatility;
pub use yoyo_exit::YoyoExit;
pub use z_score::ZScore;
pub use zero_lag_macd::{ZeroLagMacd, ZeroLagMacdOutput};
pub use zig_zag::{ZigZag, ZigZagOutput};
pub use zlema::Zlema;
@@ -0,0 +1,242 @@
//! Williams Fractals (Bill Williams).
use std::collections::VecDeque;
use crate::ohlcv::Candle;
use crate::traits::Indicator;
/// Williams Fractals output for one bar.
///
/// Each field is `Some(price)` when a fractal high/low was confirmed at the
/// **centre** of the most recent five-bar window, and `None` otherwise. Up and
/// down fractals are independent and can coincide (a centre bar can be both
/// the maximum high and the minimum low of the window).
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct WilliamsFractalsOutput {
/// Up fractal: the centre bar's high, if it is strictly greater than the
/// two highs to its left and the two highs to its right.
pub up: Option<f64>,
/// Down fractal: the centre bar's low, if it is strictly less than the
/// two lows to its left and the two lows to its right.
pub down: Option<f64>,
}
/// Williams Fractals — Bill Williams' five-bar swing detector. A bar is an
/// **up fractal** if its high is strictly above the highs of the two bars
/// immediately before and the two bars immediately after. A bar is a
/// **down fractal** if its low is strictly below the lows of those same four
/// neighbours. Because confirmation requires two bars to the right of the
/// candidate, the indicator inherently lags by two bars.
///
/// The first output lands at the fifth candle and corresponds to the third
/// candle (the centre of the window). Subsequent outputs slide the window by
/// one bar.
///
/// # Example
///
/// ```
/// use wickra_core::{Candle, Indicator, WilliamsFractals};
///
/// let mut wf = WilliamsFractals::new();
/// // Build a V-shape with a clear high at index 2.
/// let highs = [1.0, 2.0, 5.0, 2.0, 1.0];
/// for (i, &h) in highs.iter().enumerate() {
/// let c = Candle::new(h, h, h - 0.5, h, 1.0, i as i64).unwrap();
/// let _ = wf.update(c);
/// }
/// // At candle 5 the third bar's high of 5.0 is confirmed as an up fractal.
/// ```
#[derive(Debug, Clone)]
pub struct WilliamsFractals {
// Five-bar window of (high, low) pairs. The centre is at index 2.
window: VecDeque<(f64, f64)>,
}
impl Default for WilliamsFractals {
fn default() -> Self {
Self::new()
}
}
impl WilliamsFractals {
/// Construct a new Williams Fractals indicator. The window size is fixed
/// at five bars (two left, centre, two right).
pub fn new() -> Self {
Self {
window: VecDeque::with_capacity(5),
}
}
}
impl Indicator for WilliamsFractals {
type Input = Candle;
type Output = WilliamsFractalsOutput;
fn update(&mut self, candle: Candle) -> Option<WilliamsFractalsOutput> {
if self.window.len() == 5 {
self.window.pop_front();
}
self.window.push_back((candle.high, candle.low));
if self.window.len() < 5 {
return None;
}
let (h0, _) = self.window[0];
let (h1, _) = self.window[1];
let (h2, l2) = self.window[2];
let (h3, _) = self.window[3];
let (h4, _) = self.window[4];
let (_, l0) = self.window[0];
let (_, l1) = self.window[1];
let (_, l3) = self.window[3];
let (_, l4) = self.window[4];
let up = if h2 > h0 && h2 > h1 && h2 > h3 && h2 > h4 {
Some(h2)
} else {
None
};
let down = if l2 < l0 && l2 < l1 && l2 < l3 && l2 < l4 {
Some(l2)
} else {
None
};
Some(WilliamsFractalsOutput { up, down })
}
fn reset(&mut self) {
self.window.clear();
}
fn warmup_period(&self) -> usize {
5
}
fn is_ready(&self) -> bool {
self.window.len() == 5
}
fn name(&self) -> &'static str {
"WilliamsFractals"
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::traits::BatchExt;
fn c(h: f64, l: f64, ts: i64) -> Candle {
Candle::new(l, h, l, l, 1.0, ts).unwrap()
}
#[test]
fn isolated_peak_is_detected_as_up_fractal() {
let mut wf = WilliamsFractals::new();
// Highs 1, 2, 5, 2, 1 -> centre (5) is strictly above its four neighbours.
let highs = [1.0, 2.0, 5.0, 2.0, 1.0];
let mut last = None;
for (i, &h) in highs.iter().enumerate() {
last = wf.update(c(h, h - 0.5, i64::try_from(i).unwrap()));
}
let o = last.expect("fifth bar emits");
assert_eq!(o.up, Some(5.0));
assert_eq!(o.down, None);
}
#[test]
fn isolated_trough_is_detected_as_down_fractal() {
let mut wf = WilliamsFractals::new();
// Lows 5, 4, 1, 4, 5 -> centre is the trough.
let lows = [5.0, 4.0, 1.0, 4.0, 5.0];
let mut last = None;
for (i, &l) in lows.iter().enumerate() {
last = wf.update(c(l + 0.5, l, i64::try_from(i).unwrap()));
}
let o = last.expect("fifth bar emits");
assert_eq!(o.down, Some(1.0));
assert_eq!(o.up, None);
}
#[test]
fn monotonic_series_yields_no_fractals() {
let mut wf = WilliamsFractals::new();
let mut emitted = 0_usize;
for i in 0..10 {
let h = f64::from(i) + 2.0;
let l = f64::from(i);
if let Some(o) = wf.update(c(h, l, i64::from(i))) {
emitted += 1;
assert_eq!(o.up, None);
assert_eq!(o.down, None);
}
}
assert!(emitted >= 6);
}
#[test]
fn equal_neighbour_is_not_a_fractal() {
// Centre tied with neighbour -> strict inequality fails -> no fractal.
let mut wf = WilliamsFractals::new();
let highs = [1.0, 5.0, 5.0, 2.0, 1.0];
let mut last = None;
for (i, &h) in highs.iter().enumerate() {
last = wf.update(c(h, h - 0.5, i64::try_from(i).unwrap()));
}
let o = last.unwrap();
assert_eq!(o.up, None);
}
#[test]
fn first_four_bars_return_none() {
let mut wf = WilliamsFractals::new();
for i in 0..4 {
assert_eq!(wf.update(c(10.0, 9.0, i)), None);
}
assert!(!wf.is_ready());
}
#[test]
fn warmup_period_is_five() {
assert_eq!(WilliamsFractals::new().warmup_period(), 5);
}
#[test]
fn reset_clears_state() {
let mut wf = WilliamsFractals::new();
for i in 0..5 {
wf.update(c(10.0, 9.0, i));
}
assert!(wf.is_ready());
wf.reset();
assert!(!wf.is_ready());
assert_eq!(wf.update(c(10.0, 9.0, 0)), None);
}
#[test]
fn batch_equals_streaming() {
let candles: Vec<Candle> = (0..40)
.map(|i| c(f64::from(i) + 2.0, f64::from(i), i64::from(i)))
.collect();
let mut a = WilliamsFractals::new();
let mut b = WilliamsFractals::new();
assert_eq!(
a.batch(&candles),
candles.iter().map(|x| b.update(*x)).collect::<Vec<_>>()
);
}
#[test]
fn accessors_and_metadata() {
let wf = WilliamsFractals::new();
assert_eq!(wf.warmup_period(), 5);
assert_eq!(wf.name(), "WilliamsFractals");
}
#[test]
fn default_matches_new() {
let a = WilliamsFractals::new();
let b = WilliamsFractals::default();
assert_eq!(a.is_ready(), b.is_ready());
assert_eq!(a.warmup_period(), b.warmup_period());
}
}
@@ -0,0 +1,192 @@
//! Woodie Pivot Points (Tom Williams).
use crate::ohlcv::Candle;
use crate::traits::Indicator;
/// Woodie Pivot Points output: two resistances, pivot, two supports.
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct WoodiePivotsOutput {
/// Pivot Point: `(H + L + 2·C) / 4`.
pub pp: f64,
/// Resistance 1: `2·PP L`.
pub r1: f64,
/// Resistance 2: `PP + (H L)`.
pub r2: f64,
/// Support 1: `2·PP H`.
pub s1: f64,
/// Support 2: `PP (H L)`.
pub s2: f64,
}
/// Woodie Pivot Points — Tom Williams' close-weighted pivot variant.
///
/// ```text
/// PP = (H + L + 2·C) / 4
/// R1 = 2·PP L S1 = 2·PP H
/// R2 = PP + (H L) S2 = PP (H L)
/// ```
///
/// The double-weighted close shifts the pivot toward where most of the
/// session's activity actually settled — useful in trending markets where the
/// close is more meaningful than the midpoint.
///
/// # Example
///
/// ```
/// use wickra_core::{Candle, Indicator, WoodiePivots};
///
/// let prev = Candle::new(100.0, 110.0, 90.0, 108.0, 1.0, 0).unwrap();
/// let levels = WoodiePivots::new().update(prev).unwrap();
/// // Close-weighted PP = (110 + 90 + 2·108)/4 = 104.
/// assert!((levels.pp - 104.0).abs() < 1e-9);
/// ```
#[derive(Debug, Clone, Default)]
pub struct WoodiePivots {
ready: bool,
}
impl WoodiePivots {
/// Construct a new Woodie Pivot Points indicator.
pub const fn new() -> Self {
Self { ready: false }
}
}
impl Indicator for WoodiePivots {
type Input = Candle;
type Output = WoodiePivotsOutput;
fn update(&mut self, candle: Candle) -> Option<WoodiePivotsOutput> {
let (h, l, c) = (candle.high, candle.low, candle.close);
let pp = (h + l + 2.0 * c) / 4.0;
let range = h - l;
let out = WoodiePivotsOutput {
pp,
r1: 2.0 * pp - l,
r2: pp + range,
s1: 2.0 * pp - h,
s2: pp - range,
};
self.ready = true;
Some(out)
}
fn reset(&mut self) {
self.ready = false;
}
fn warmup_period(&self) -> usize {
1
}
fn is_ready(&self) -> bool {
self.ready
}
fn name(&self) -> &'static str {
"WoodiePivots"
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::traits::BatchExt;
fn c(h: f64, l: f64, close: f64, ts: i64) -> Candle {
Candle::new(close, h, l, close, 1.0, ts).unwrap()
}
#[test]
fn formula_reference_values() {
// H=110, L=90, C=108 -> PP = (110+90+216)/4 = 104.
let levels = WoodiePivots::new()
.update(c(110.0, 90.0, 108.0, 0))
.unwrap();
assert!((levels.pp - 104.0).abs() < 1e-12);
assert!((levels.r1 - (2.0 * 104.0 - 90.0)).abs() < 1e-12);
assert!((levels.s1 - (2.0 * 104.0 - 110.0)).abs() < 1e-12);
assert!((levels.r2 - (104.0 + 20.0)).abs() < 1e-12);
assert!((levels.s2 - (104.0 - 20.0)).abs() < 1e-12);
}
#[test]
fn pp_differs_from_classic_when_close_is_skewed() {
// Classic PP = (H+L+C)/3; Woodie PP weights close twice. They agree
// only when C equals (H+L)/2.
let levels = WoodiePivots::new()
.update(c(120.0, 80.0, 110.0, 0))
.unwrap();
let classic_pp = (120.0 + 80.0 + 110.0) / 3.0;
assert!((levels.pp - classic_pp).abs() > 1e-6);
// Equal when close = midpoint.
let mid = WoodiePivots::new()
.update(c(120.0, 80.0, 100.0, 0))
.unwrap();
let classic_mid = (120.0 + 80.0 + 100.0) / 3.0;
assert!((mid.pp - classic_mid).abs() < 1e-9);
}
#[test]
fn ordering_resistance_above_pivot_above_support() {
let levels = WoodiePivots::new()
.update(c(120.0, 80.0, 110.0, 0))
.unwrap();
assert!(levels.r2 >= levels.r1);
assert!(levels.r1 >= levels.pp);
assert!(levels.pp >= levels.s1);
assert!(levels.s1 >= levels.s2);
}
#[test]
fn constant_series_collapses_levels() {
let levels = WoodiePivots::new().update(c(50.0, 50.0, 50.0, 0)).unwrap();
assert_eq!(levels.pp, 50.0);
assert_eq!(levels.r2, 50.0);
assert_eq!(levels.s2, 50.0);
}
#[test]
fn warmup_and_ready() {
let mut p = WoodiePivots::new();
assert!(!p.is_ready());
assert_eq!(p.warmup_period(), 1);
p.update(c(11.0, 9.0, 10.0, 0));
assert!(p.is_ready());
}
#[test]
fn reset_clears_state() {
let mut p = WoodiePivots::new();
p.update(c(11.0, 9.0, 10.0, 0));
p.reset();
assert!(!p.is_ready());
}
#[test]
fn batch_equals_streaming() {
let candles: Vec<Candle> = (0_i32..40)
.map(|i| {
c(
f64::from(i) + 2.0,
f64::from(i),
f64::from(i) + 1.0,
i.into(),
)
})
.collect();
let mut a = WoodiePivots::new();
let mut b = WoodiePivots::new();
assert_eq!(
a.batch(&candles),
candles.iter().map(|x| b.update(*x)).collect::<Vec<_>>()
);
}
#[test]
fn accessors_and_metadata() {
let p = WoodiePivots::new();
assert_eq!(p.warmup_period(), 1);
assert_eq!(p.name(), "WoodiePivots");
}
}
@@ -0,0 +1,289 @@
//! `ZigZag` — percentage-threshold swing detector.
use crate::error::{Error, Result};
use crate::ohlcv::Candle;
use crate::traits::Indicator;
/// `ZigZag` output: the price of the bar that completed the most recent swing
/// and its direction (`+1.0` for a high swing, `-1.0` for a low swing).
///
/// The price is the high of the bar at which the high-swing was anchored, or
/// the low of the bar at which the low-swing was anchored — i.e. the actual
/// extreme that the swing turns from, not the bar that triggered confirmation.
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct ZigZagOutput {
/// Price of the confirmed swing extreme.
pub swing: f64,
/// Direction: `+1.0` if the swing is a high, `-1.0` if a low.
pub direction: f64,
}
/// `ZigZag` — a non-repainting percent-threshold swing detector. Tracks the most
/// recent extreme (high or low) and confirms a reversal once price has moved
/// the configured percentage away from it.
///
/// ```text
/// uptrend (last swing was a low):
/// while highs make new highs, keep updating the pivot high
/// once close (or low) drops by ≥ threshold·high → confirm pivot high
///
/// downtrend (last swing was a high):
/// while lows make new lows, keep updating the pivot low
/// once close (or high) rises by ≥ threshold·low → confirm pivot low
/// ```
///
/// The indicator emits `Some(swing)` only on the bar where a reversal is
/// confirmed, returning the price and direction of the **just-completed**
/// extreme. Bars between confirmations return `None`. The first bar bootstraps
/// the state — it determines an initial reference price but does not emit.
///
/// The threshold is a fractional change (`0.05` ≈ 5%); it must be strictly
/// positive and below `1.0`.
///
/// # Example
///
/// ```
/// use wickra_core::{Candle, Indicator, ZigZag};
///
/// let mut zz = ZigZag::new(0.10).unwrap();
/// for (i, p) in [100.0, 105.0, 115.0, 100.0, 90.0, 100.0].iter().enumerate() {
/// let c = Candle::new(*p, *p + 0.5, *p - 0.5, *p, 1.0, i as i64).unwrap();
/// let _ = zz.update(c);
/// }
/// ```
#[derive(Debug, Clone)]
pub struct ZigZag {
threshold: f64,
state: Option<State>,
}
#[derive(Debug, Clone, Copy)]
struct State {
/// Direction of the running trend: `+1.0` (uptrend tracking a pivot high)
/// or `-1.0` (downtrend tracking a pivot low).
direction: f64,
/// The current candidate extreme price (the running pivot).
extreme: f64,
}
impl ZigZag {
/// Construct a new `ZigZag` with a fractional reversal threshold (e.g. `0.05`
/// for a 5% swing).
///
/// # Errors
/// Returns [`Error::InvalidPeriod`] if `threshold` is not in `(0.0, 1.0)`
/// or is not finite.
pub fn new(threshold: f64) -> Result<Self> {
if !threshold.is_finite() || threshold <= 0.0 || threshold >= 1.0 {
return Err(Error::InvalidPeriod {
message: "ZigZag threshold must be a finite fraction in (0, 1)",
});
}
Ok(Self {
threshold,
state: None,
})
}
/// Configured reversal threshold (fractional).
pub const fn threshold(&self) -> f64 {
self.threshold
}
}
impl Indicator for ZigZag {
type Input = Candle;
type Output = ZigZagOutput;
fn update(&mut self, candle: Candle) -> Option<ZigZagOutput> {
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,
});
return None;
};
if s.direction > 0.0 {
// Uptrend: keep raising the candidate high; confirm reversal if
// the candle's low has dropped by threshold from the candidate.
if candle.high > s.extreme {
self.state = Some(State {
direction: 1.0,
extreme: candle.high,
});
return None;
}
if candle.low <= s.extreme * (1.0 - self.threshold) {
// Confirm the swing high; flip to downtrend tracking this bar's low.
let confirmed = ZigZagOutput {
swing: s.extreme,
direction: 1.0,
};
self.state = Some(State {
direction: -1.0,
extreme: candle.low,
});
return Some(confirmed);
}
None
} else {
// Downtrend: lower the candidate low; confirm reversal if the
// candle's high has risen by threshold from the candidate.
if candle.low < s.extreme {
self.state = Some(State {
direction: -1.0,
extreme: candle.low,
});
return None;
}
if candle.high >= s.extreme * (1.0 + self.threshold) {
let confirmed = ZigZagOutput {
swing: s.extreme,
direction: -1.0,
};
self.state = Some(State {
direction: 1.0,
extreme: candle.high,
});
return Some(confirmed);
}
None
}
}
fn reset(&mut self) {
self.state = None;
}
fn warmup_period(&self) -> usize {
// Bootstrap takes one bar; confirmation of the first swing needs at
// least one more move past the threshold. Best-case the first swing
// lands on the second bar.
2
}
fn is_ready(&self) -> bool {
self.state.is_some()
}
fn name(&self) -> &'static str {
"ZigZag"
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::traits::BatchExt;
fn c(price: f64, ts: i64) -> Candle {
Candle::new(price, price + 0.001, price - 0.001, price, 1.0, ts).unwrap()
}
fn c_hl(h: f64, l: f64, ts: i64) -> Candle {
Candle::new(l, h, l, l, 1.0, ts).unwrap()
}
#[test]
fn rejects_invalid_threshold() {
assert!(ZigZag::new(0.0).is_err());
assert!(ZigZag::new(-0.1).is_err());
assert!(ZigZag::new(1.0).is_err());
assert!(ZigZag::new(f64::NAN).is_err());
assert!(ZigZag::new(f64::INFINITY).is_err());
}
#[test]
fn first_bar_only_bootstraps() {
let mut zz = ZigZag::new(0.05).unwrap();
assert_eq!(zz.update(c(100.0, 0)), None);
assert!(zz.is_ready());
}
#[test]
fn confirms_high_swing_on_threshold_drop() {
let mut zz = ZigZag::new(0.10).unwrap();
// Up to a peak of 120, then a drop to 100 = 16.7% reversal → confirms.
let _ = zz.update(c_hl(100.0, 99.5, 0));
let _ = zz.update(c_hl(120.0, 119.5, 1));
let confirmed = zz.update(c_hl(101.0, 100.0, 2));
let o = confirmed.expect("the third bar's drop triggers confirmation");
assert!((o.swing - 120.0).abs() < 1e-9);
assert_eq!(o.direction, 1.0);
}
#[test]
fn confirms_low_swing_on_threshold_rise() {
let mut zz = ZigZag::new(0.10).unwrap();
// Up to 120 to seed the high pivot, drop to confirm it as a high,
// then rise from the new low pivot by 10% to confirm it as a low.
let _ = zz.update(c_hl(100.0, 99.5, 0));
let _ = zz.update(c_hl(120.0, 119.5, 1));
let _ = zz.update(c_hl(101.0, 90.0, 2)); // drop confirms 120-high; new low 90.
let _ = zz.update(c_hl(91.0, 90.5, 3));
// Rise to 100 from low 90 = 11.1% → confirms low.
let confirmed = zz.update(c_hl(100.0, 99.0, 4));
let o = confirmed.expect("the rise confirms the low swing");
assert!((o.swing - 90.0).abs() < 1e-9);
assert_eq!(o.direction, -1.0);
}
#[test]
fn small_oscillations_yield_no_swings() {
let mut zz = ZigZag::new(0.20).unwrap();
let _ = zz.update(c(100.0, 0));
for i in 1..20 {
// Bounce around 100 ± 5; never crosses the 20% threshold.
let p = 100.0 + ((f64::from(i)) * 0.3).sin() * 5.0;
assert!(
zz.update(c(p, i.into())).is_none(),
"unexpected swing at i={i}"
);
}
}
#[test]
fn warmup_and_ready_lifecycle() {
let mut zz = ZigZag::new(0.05).unwrap();
assert!(!zz.is_ready());
assert_eq!(zz.warmup_period(), 2);
zz.update(c(100.0, 0));
assert!(zz.is_ready());
}
#[test]
fn reset_clears_state() {
let mut zz = ZigZag::new(0.10).unwrap();
let _ = zz.update(c_hl(100.0, 99.0, 0));
let _ = zz.update(c_hl(120.0, 119.0, 1));
zz.reset();
assert!(!zz.is_ready());
assert_eq!(zz.update(c_hl(110.0, 109.0, 0)), None);
}
#[test]
fn batch_equals_streaming() {
let candles: Vec<Candle> = (0..40)
.map(|i| {
let p = 100.0 + (i as f64 * 0.3).sin() * 15.0;
c(p, i)
})
.collect();
let mut a = ZigZag::new(0.05).unwrap();
let mut b = ZigZag::new(0.05).unwrap();
assert_eq!(
a.batch(&candles),
candles.iter().map(|x| b.update(*x)).collect::<Vec<_>>()
);
}
#[test]
fn accessors_and_metadata() {
let zz = ZigZag::new(0.05).unwrap();
assert!((zz.threshold() - 0.05).abs() < 1e-12);
assert_eq!(zz.warmup_period(), 2);
assert_eq!(zz.name(), "ZigZag");
}
}