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:
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user