From 7e1e98859643cc03c5db1509ad81f2b0e3d77914 Mon Sep 17 00:00:00 2001 From: kingchenc Date: Mon, 25 May 2026 20:06:46 +0200 Subject: [PATCH] feat(family-08): Pivots & Support/Resistance (7 indicators) (#47) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 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. --- CHANGELOG.md | 13 + README.md | 5 +- bindings/node/__tests__/indicators.test.js | 8 + bindings/node/src/lib.rs | 583 +++++++++++++++++- bindings/python/python/wickra/__init__.py | 16 + bindings/python/src/lib.rs | 535 ++++++++++++++++ bindings/python/tests/test_new_indicators.py | 157 ++++- bindings/wasm/src/lib.rs | 493 +++++++++++++++ .../src/indicators/camarilla_pivots.rs | 197 ++++++ .../src/indicators/classic_pivots.rs | 202 ++++++ .../src/indicators/demark_pivots.rs | 192 ++++++ .../src/indicators/fibonacci_pivots.rs | 195 ++++++ crates/wickra-core/src/indicators/mod.rs | 14 + .../src/indicators/williams_fractals.rs | 242 ++++++++ .../src/indicators/woodie_pivots.rs | 192 ++++++ crates/wickra-core/src/indicators/zig_zag.rs | 289 +++++++++ crates/wickra-core/src/lib.rs | 41 +- crates/wickra/benches/indicators.rs | 24 +- fuzz/fuzz_targets/indicator_update_candle.rs | 26 +- 19 files changed, 3379 insertions(+), 45 deletions(-) create mode 100644 crates/wickra-core/src/indicators/camarilla_pivots.rs create mode 100644 crates/wickra-core/src/indicators/classic_pivots.rs create mode 100644 crates/wickra-core/src/indicators/demark_pivots.rs create mode 100644 crates/wickra-core/src/indicators/fibonacci_pivots.rs create mode 100644 crates/wickra-core/src/indicators/williams_fractals.rs create mode 100644 crates/wickra-core/src/indicators/woodie_pivots.rs create mode 100644 crates/wickra-core/src/indicators/zig_zag.rs diff --git a/CHANGELOG.md b/CHANGELOG.md index 0bcc19e7..fb88321e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,19 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] ### Added +- **Family 08 — Pivots & Support/Resistance.** Seven new indicators land + the previously empty pivot family: Classic (Floor-Trader) Pivot Points + with three resistance and support tiers, Fibonacci Pivots spaced by + 0.382 / 0.618 / 1.000 of the prior range, Camarilla Pivots + (Nick Stott's four-tier `(H − L) · 1.1 / {12, 6, 4, 2}` levels), + Woodie Pivots with the close-weighted `PP = (H + L + 2·C) / 4`, + DeMark Pivots whose conditional `X` depends on whether the bar closed + up, down or flat, Williams Fractals as a five-bar swing detector and + ZigZag as a percent-threshold swing tracker. Every level/swing is + exposed across Rust, Python, Node and WASM with the standard + `update` / `batch` / `reset` / `is_ready` / `warmup_period` surface + and matching streaming-vs-batch and reference-value tests. The fuzz + candle target now covers all seven. - **Family 09 — Trailing Stops, seven new indicators.** Rounds out the trailing-stop family from 5 to 12: `HiLoActivator` (Crabel's SMA-of-high / SMA-of-low trail), `VoltyStop` (Cynthia Kase's diff --git a/README.md b/README.md index 742c6ec0..a8aaa6c6 100644 --- a/README.md +++ b/README.md @@ -109,7 +109,7 @@ python -m benchmarks.compare_libraries ## Indicators -128 streaming-first indicators across nine families. Every one passes the +135 streaming-first indicators across ten families. Every one passes the `batch == streaming` equivalence test, reference-value tests, and reset semantics tests. @@ -124,6 +124,7 @@ semantics tests. | Trailing Stops | Parabolic SAR, SuperTrend, Chandelier Exit, Chande Kroll Stop, ATR Trailing Stop, HiLo Activator, Volty Stop, Yo-Yo Exit, Donchian Channel Stop, Percentage Trailing Stop, Step Trailing Stop, Renko Trailing Stop | | Volume | OBV, VWAP (cumulative + rolling), ADL, Volume-Price Trend, Chaikin Money Flow, Chaikin Oscillator, Force Index, Ease of Movement, Klinger Volume Oscillator, Volume Oscillator, NVI, PVI, Williams A/D, Anchored VWAP, Demand Index, TSV, VZO, Market Facilitation Index | | Price Statistics | Typical Price, Median Price, Weighted Close, Linear Regression, Linear Regression Slope, Z-Score, Linear Regression Angle | +| Pivots & S/R | Classic Pivots, Fibonacci Pivots, Camarilla, Woodie Pivots, DeMark Pivots, Williams Fractals, ZigZag | Adding a new indicator means implementing one trait in Rust; all four bindings inherit it automatically. @@ -196,7 +197,7 @@ A Python live-trading example using the public `websockets` package lives at ``` wickra/ ├── crates/ -│ ├── wickra-core/ core engine + all 128 indicators +│ ├── wickra-core/ core engine + all 135 indicators │ ├── wickra/ top-level facade crate (publishes on crates.io) + benches/ │ └── wickra-data/ CSV reader, tick aggregator, live exchange feeds ├── bindings/ diff --git a/bindings/node/__tests__/indicators.test.js b/bindings/node/__tests__/indicators.test.js index 52807277..0ea4fc5b 100644 --- a/bindings/node/__tests__/indicators.test.js +++ b/bindings/node/__tests__/indicators.test.js @@ -192,6 +192,14 @@ const multi = { TtmSqueeze: { make: () => new wickra.TtmSqueeze(20, 2, 1.5), fields: ['squeeze', 'momentum'], step: (ind, i) => ind.update(high[i], low[i], close[i]), batch: (ind) => ind.batch(high, low, close) }, FractalChaosBands: { make: () => new wickra.FractalChaosBands(2), fields: ['upper', 'lower'], step: (ind, i) => ind.update(high[i], low[i]), batch: (ind) => ind.batch(high, low) }, VwapStdDevBands: { make: () => new wickra.VwapStdDevBands(2), fields: ['upper', 'middle', 'lower', 'stddev'], step: (ind, i) => ind.update(high[i], low[i], close[i], volume[i]), batch: (ind) => ind.batch(high, low, close, volume) }, + // Family 08: Pivots & Support/Resistance + ClassicPivots: { make: () => new wickra.ClassicPivots(), fields: ['pp', 'r1', 'r2', 'r3', 's1', 's2', 's3'], step: (ind, i) => ind.update(high[i], low[i], close[i]), batch: (ind) => ind.batch(high, low, close) }, + FibonacciPivots: { make: () => new wickra.FibonacciPivots(), fields: ['pp', 'r1', 'r2', 'r3', 's1', 's2', 's3'], step: (ind, i) => ind.update(high[i], low[i], close[i]), batch: (ind) => ind.batch(high, low, close) }, + Camarilla: { make: () => new wickra.Camarilla(), fields: ['pp', 'r1', 'r2', 'r3', 'r4', 's1', 's2', 's3', 's4'], step: (ind, i) => ind.update(high[i], low[i], close[i]), batch: (ind) => ind.batch(high, low, close) }, + WoodiePivots: { make: () => new wickra.WoodiePivots(), fields: ['pp', 'r1', 'r2', 's1', 's2'], step: (ind, i) => ind.update(high[i], low[i], close[i]), batch: (ind) => ind.batch(high, low, close) }, + DemarkPivots: { make: () => new wickra.DemarkPivots(), fields: ['pp', 'r1', 's1'], step: (ind, i) => ind.update(open[i], high[i], low[i], close[i]), batch: (ind) => ind.batch(open, high, low, close) }, + WilliamsFractals: { make: () => new wickra.WilliamsFractals(), fields: ['up', 'down'], step: (ind, i) => ind.update(high[i], low[i]), batch: (ind) => ind.batch(high, low) }, + ZigZag: { make: () => new wickra.ZigZag(0.02), fields: ['swing', 'direction'], step: (ind, i) => ind.update(high[i], low[i]), batch: (ind) => ind.batch(high, low) }, }; for (const [name, d] of Object.entries(multi)) { diff --git a/bindings/node/src/lib.rs b/bindings/node/src/lib.rs index be24a585..5b3285f5 100644 --- a/bindings/node/src/lib.rs +++ b/bindings/node/src/lib.rs @@ -5267,7 +5267,6 @@ impl VwmaNode { self.inner.warmup_period() as u32 } } - // ============================== Family 05: Bands & Channels ============================== // ---------- MA Envelope ---------- @@ -6026,3 +6025,585 @@ impl VwapStdDevBandsNode { self.inner.warmup_period() as u32 } } +// ============================== Pivots & S/R ============================== + +#[napi(object)] +pub struct ClassicPivotsValue { + pub pp: f64, + pub r1: f64, + pub r2: f64, + pub r3: f64, + pub s1: f64, + pub s2: f64, + pub s3: f64, +} + +#[napi(js_name = "ClassicPivots")] +pub struct ClassicPivotsNode { + inner: wc::ClassicPivots, +} + +#[napi] +impl ClassicPivotsNode { + #[napi(constructor)] + pub fn new() -> Self { + Self { + inner: wc::ClassicPivots::new(), + } + } + #[napi] + pub fn update( + &mut self, + high: f64, + low: f64, + close: f64, + ) -> napi::Result> { + Ok(self + .inner + .update(cnd(high, low, close, 0.0)?) + .map(|o| ClassicPivotsValue { + pp: o.pp, + r1: o.r1, + r2: o.r2, + r3: o.r3, + s1: o.s1, + s2: o.s2, + s3: o.s3, + })) + } + #[napi] + pub fn batch( + &mut self, + high: Vec, + low: Vec, + close: Vec, + ) -> napi::Result> { + if high.len() != low.len() || low.len() != close.len() { + return Err(NapiError::from_reason( + "high, low, close must be equal length".to_string(), + )); + } + let n = high.len(); + let mut out = vec![f64::NAN; n * 7]; + for i in 0..n { + if let Some(o) = self.inner.update(cnd(high[i], low[i], close[i], 0.0)?) { + out[i * 7] = o.pp; + out[i * 7 + 1] = o.r1; + out[i * 7 + 2] = o.r2; + out[i * 7 + 3] = o.r3; + out[i * 7 + 4] = o.s1; + out[i * 7 + 5] = o.s2; + out[i * 7 + 6] = o.s3; + } + } + Ok(out) + } + #[napi] + pub fn reset(&mut self) { + self.inner.reset(); + } + #[napi(js_name = "isReady")] + pub fn is_ready(&self) -> bool { + self.inner.is_ready() + } + #[napi(js_name = "warmupPeriod")] + pub fn warmup_period(&self) -> u32 { + self.inner.warmup_period() as u32 + } +} + +impl Default for ClassicPivotsNode { + fn default() -> Self { + Self::new() + } +} + +#[napi(object)] +pub struct FibonacciPivotsValue { + pub pp: f64, + pub r1: f64, + pub r2: f64, + pub r3: f64, + pub s1: f64, + pub s2: f64, + pub s3: f64, +} + +#[napi(js_name = "FibonacciPivots")] +pub struct FibonacciPivotsNode { + inner: wc::FibonacciPivots, +} + +#[napi] +impl FibonacciPivotsNode { + #[napi(constructor)] + pub fn new() -> Self { + Self { + inner: wc::FibonacciPivots::new(), + } + } + #[napi] + pub fn update( + &mut self, + high: f64, + low: f64, + close: f64, + ) -> napi::Result> { + Ok(self + .inner + .update(cnd(high, low, close, 0.0)?) + .map(|o| FibonacciPivotsValue { + pp: o.pp, + r1: o.r1, + r2: o.r2, + r3: o.r3, + s1: o.s1, + s2: o.s2, + s3: o.s3, + })) + } + #[napi] + pub fn batch( + &mut self, + high: Vec, + low: Vec, + close: Vec, + ) -> napi::Result> { + if high.len() != low.len() || low.len() != close.len() { + return Err(NapiError::from_reason( + "high, low, close must be equal length".to_string(), + )); + } + let n = high.len(); + let mut out = vec![f64::NAN; n * 7]; + for i in 0..n { + if let Some(o) = self.inner.update(cnd(high[i], low[i], close[i], 0.0)?) { + out[i * 7] = o.pp; + out[i * 7 + 1] = o.r1; + out[i * 7 + 2] = o.r2; + out[i * 7 + 3] = o.r3; + out[i * 7 + 4] = o.s1; + out[i * 7 + 5] = o.s2; + out[i * 7 + 6] = o.s3; + } + } + Ok(out) + } + #[napi] + pub fn reset(&mut self) { + self.inner.reset(); + } + #[napi(js_name = "isReady")] + pub fn is_ready(&self) -> bool { + self.inner.is_ready() + } + #[napi(js_name = "warmupPeriod")] + pub fn warmup_period(&self) -> u32 { + self.inner.warmup_period() as u32 + } +} + +impl Default for FibonacciPivotsNode { + fn default() -> Self { + Self::new() + } +} + +#[napi(object)] +pub struct CamarillaValue { + pub pp: f64, + pub r1: f64, + pub r2: f64, + pub r3: f64, + pub r4: f64, + pub s1: f64, + pub s2: f64, + pub s3: f64, + pub s4: f64, +} + +#[napi(js_name = "Camarilla")] +pub struct CamarillaNode { + inner: wc::Camarilla, +} + +#[napi] +impl CamarillaNode { + #[napi(constructor)] + pub fn new() -> Self { + Self { + inner: wc::Camarilla::new(), + } + } + #[napi] + pub fn update( + &mut self, + high: f64, + low: f64, + close: f64, + ) -> napi::Result> { + Ok(self + .inner + .update(cnd(high, low, close, 0.0)?) + .map(|o| CamarillaValue { + pp: o.pp, + r1: o.r1, + r2: o.r2, + r3: o.r3, + r4: o.r4, + s1: o.s1, + s2: o.s2, + s3: o.s3, + s4: o.s4, + })) + } + #[napi] + pub fn batch( + &mut self, + high: Vec, + low: Vec, + close: Vec, + ) -> napi::Result> { + if high.len() != low.len() || low.len() != close.len() { + return Err(NapiError::from_reason( + "high, low, close must be equal length".to_string(), + )); + } + let n = high.len(); + let mut out = vec![f64::NAN; n * 9]; + for i in 0..n { + if let Some(o) = self.inner.update(cnd(high[i], low[i], close[i], 0.0)?) { + out[i * 9] = o.pp; + out[i * 9 + 1] = o.r1; + out[i * 9 + 2] = o.r2; + out[i * 9 + 3] = o.r3; + out[i * 9 + 4] = o.r4; + out[i * 9 + 5] = o.s1; + out[i * 9 + 6] = o.s2; + out[i * 9 + 7] = o.s3; + out[i * 9 + 8] = o.s4; + } + } + Ok(out) + } + #[napi] + pub fn reset(&mut self) { + self.inner.reset(); + } + #[napi(js_name = "isReady")] + pub fn is_ready(&self) -> bool { + self.inner.is_ready() + } + #[napi(js_name = "warmupPeriod")] + pub fn warmup_period(&self) -> u32 { + self.inner.warmup_period() as u32 + } +} + +impl Default for CamarillaNode { + fn default() -> Self { + Self::new() + } +} + +#[napi(object)] +pub struct WoodiePivotsValue { + pub pp: f64, + pub r1: f64, + pub r2: f64, + pub s1: f64, + pub s2: f64, +} + +#[napi(js_name = "WoodiePivots")] +pub struct WoodiePivotsNode { + inner: wc::WoodiePivots, +} + +#[napi] +impl WoodiePivotsNode { + #[napi(constructor)] + pub fn new() -> Self { + Self { + inner: wc::WoodiePivots::new(), + } + } + #[napi] + pub fn update( + &mut self, + high: f64, + low: f64, + close: f64, + ) -> napi::Result> { + Ok(self + .inner + .update(cnd(high, low, close, 0.0)?) + .map(|o| WoodiePivotsValue { + pp: o.pp, + r1: o.r1, + r2: o.r2, + s1: o.s1, + s2: o.s2, + })) + } + #[napi] + pub fn batch( + &mut self, + high: Vec, + low: Vec, + close: Vec, + ) -> napi::Result> { + if high.len() != low.len() || low.len() != close.len() { + return Err(NapiError::from_reason( + "high, low, close must be equal length".to_string(), + )); + } + let n = high.len(); + let mut out = vec![f64::NAN; n * 5]; + for i in 0..n { + if let Some(o) = self.inner.update(cnd(high[i], low[i], close[i], 0.0)?) { + out[i * 5] = o.pp; + out[i * 5 + 1] = o.r1; + out[i * 5 + 2] = o.r2; + out[i * 5 + 3] = o.s1; + out[i * 5 + 4] = o.s2; + } + } + Ok(out) + } + #[napi] + pub fn reset(&mut self) { + self.inner.reset(); + } + #[napi(js_name = "isReady")] + pub fn is_ready(&self) -> bool { + self.inner.is_ready() + } + #[napi(js_name = "warmupPeriod")] + pub fn warmup_period(&self) -> u32 { + self.inner.warmup_period() as u32 + } +} + +impl Default for WoodiePivotsNode { + fn default() -> Self { + Self::new() + } +} + +#[napi(object)] +pub struct DemarkPivotsValue { + pub pp: f64, + pub r1: f64, + pub s1: f64, +} + +#[napi(js_name = "DemarkPivots")] +pub struct DemarkPivotsNode { + inner: wc::DemarkPivots, +} + +#[napi] +impl DemarkPivotsNode { + #[napi(constructor)] + pub fn new() -> Self { + Self { + inner: wc::DemarkPivots::new(), + } + } + #[napi] + pub fn update( + &mut self, + open: f64, + high: f64, + low: f64, + close: f64, + ) -> napi::Result> { + let candle = wc::Candle::new(open, high, low, close, 0.0, 0).map_err(map_err)?; + Ok(self.inner.update(candle).map(|o| DemarkPivotsValue { + pp: o.pp, + r1: o.r1, + s1: o.s1, + })) + } + #[napi] + pub fn batch( + &mut self, + open: Vec, + high: Vec, + low: Vec, + close: Vec, + ) -> napi::Result> { + if open.len() != high.len() || high.len() != low.len() || low.len() != close.len() { + return Err(NapiError::from_reason( + "open, high, low, close must be equal length".to_string(), + )); + } + let n = open.len(); + let mut out = vec![f64::NAN; n * 3]; + for i in 0..n { + let candle = + wc::Candle::new(open[i], high[i], low[i], close[i], 0.0, 0).map_err(map_err)?; + if let Some(o) = self.inner.update(candle) { + out[i * 3] = o.pp; + out[i * 3 + 1] = o.r1; + out[i * 3 + 2] = o.s1; + } + } + Ok(out) + } + #[napi] + pub fn reset(&mut self) { + self.inner.reset(); + } + #[napi(js_name = "isReady")] + pub fn is_ready(&self) -> bool { + self.inner.is_ready() + } + #[napi(js_name = "warmupPeriod")] + pub fn warmup_period(&self) -> u32 { + self.inner.warmup_period() as u32 + } +} + +impl Default for DemarkPivotsNode { + fn default() -> Self { + Self::new() + } +} + +#[napi(object)] +pub struct WilliamsFractalsValue { + /// Up fractal price; NaN when no up fractal was confirmed on this bar. + pub up: f64, + /// Down fractal price; NaN when no down fractal was confirmed on this bar. + pub down: f64, +} + +#[napi(js_name = "WilliamsFractals")] +pub struct WilliamsFractalsNode { + inner: wc::WilliamsFractals, +} + +#[napi] +impl WilliamsFractalsNode { + #[napi(constructor)] + pub fn new() -> Self { + Self { + inner: wc::WilliamsFractals::new(), + } + } + #[napi] + pub fn update(&mut self, high: f64, low: f64) -> napi::Result> { + Ok(self + .inner + .update(cnd(high, low, low, 0.0)?) + .map(|o| WilliamsFractalsValue { + up: o.up.unwrap_or(f64::NAN), + down: o.down.unwrap_or(f64::NAN), + })) + } + #[napi] + pub fn batch(&mut self, high: Vec, low: Vec) -> napi::Result> { + if high.len() != low.len() { + return Err(NapiError::from_reason( + "high and low must be equal length".to_string(), + )); + } + let n = high.len(); + let mut out = vec![f64::NAN; n * 2]; + for i in 0..n { + if let Some(o) = self.inner.update(cnd(high[i], low[i], low[i], 0.0)?) { + if let Some(v) = o.up { + out[i * 2] = v; + } + if let Some(v) = o.down { + out[i * 2 + 1] = v; + } + } + } + Ok(out) + } + #[napi] + pub fn reset(&mut self) { + self.inner.reset(); + } + #[napi(js_name = "isReady")] + pub fn is_ready(&self) -> bool { + self.inner.is_ready() + } + #[napi(js_name = "warmupPeriod")] + pub fn warmup_period(&self) -> u32 { + self.inner.warmup_period() as u32 + } +} + +impl Default for WilliamsFractalsNode { + fn default() -> Self { + Self::new() + } +} + +#[napi(object)] +pub struct ZigZagValue { + pub swing: f64, + pub direction: f64, +} + +#[napi(js_name = "ZigZag")] +pub struct ZigZagNode { + inner: wc::ZigZag, +} + +#[napi] +impl ZigZagNode { + #[napi(constructor)] + pub fn new(threshold: f64) -> napi::Result { + Ok(Self { + inner: wc::ZigZag::new(threshold).map_err(map_err)?, + }) + } + #[napi] + pub fn update(&mut self, high: f64, low: f64) -> napi::Result> { + Ok(self + .inner + .update(cnd(high, low, low, 0.0)?) + .map(|o| ZigZagValue { + swing: o.swing, + direction: o.direction, + })) + } + #[napi] + pub fn batch(&mut self, high: Vec, low: Vec) -> napi::Result> { + if high.len() != low.len() { + return Err(NapiError::from_reason( + "high and low must be equal length".to_string(), + )); + } + let n = high.len(); + let mut out = vec![f64::NAN; n * 2]; + for i in 0..n { + if let Some(o) = self.inner.update(cnd(high[i], low[i], low[i], 0.0)?) { + out[i * 2] = o.swing; + out[i * 2 + 1] = o.direction; + } + } + Ok(out) + } + #[napi(getter)] + pub fn threshold(&self) -> f64 { + self.inner.threshold() + } + #[napi] + pub fn reset(&mut self) { + self.inner.reset(); + } + #[napi(js_name = "isReady")] + pub fn is_ready(&self) -> bool { + self.inner.is_ready() + } + #[napi(js_name = "warmupPeriod")] + pub fn warmup_period(&self) -> u32 { + self.inner.warmup_period() as u32 + } +} diff --git a/bindings/python/python/wickra/__init__.py b/bindings/python/python/wickra/__init__.py index ff21bccf..26d42212 100644 --- a/bindings/python/python/wickra/__init__.py +++ b/bindings/python/python/wickra/__init__.py @@ -161,6 +161,14 @@ from ._wickra import ( TtmSqueeze, FractalChaosBands, VwapStdDevBands, + # Pivots & S/R + ClassicPivots, + FibonacciPivots, + Camarilla, + WoodiePivots, + DemarkPivots, + WilliamsFractals, + ZigZag, ) __all__ = [ @@ -301,4 +309,12 @@ __all__ = [ "TtmSqueeze", "FractalChaosBands", "VwapStdDevBands", + # Pivots & S/R + "ClassicPivots", + "FibonacciPivots", + "Camarilla", + "WoodiePivots", + "DemarkPivots", + "WilliamsFractals", + "ZigZag", ] diff --git a/bindings/python/src/lib.rs b/bindings/python/src/lib.rs index fef438f4..a1f21e89 100644 --- a/bindings/python/src/lib.rs +++ b/bindings/python/src/lib.rs @@ -8047,6 +8047,534 @@ impl PyVwapStdDevBands { } } +// ============================== Classic Pivots ============================== + +#[pyclass(name = "ClassicPivots", module = "wickra._wickra", skip_from_py_object)] +#[derive(Clone)] +struct PyClassicPivots { + inner: wc::ClassicPivots, +} + +#[pymethods] +impl PyClassicPivots { + #[new] + fn new() -> Self { + Self { + inner: wc::ClassicPivots::new(), + } + } + /// Returns `(pp, r1, r2, r3, s1, s2, s3)` or None during warmup. + fn update( + &mut self, + candle: &Bound<'_, PyAny>, + ) -> PyResult> { + let c = extract_candle(candle)?; + Ok(self + .inner + .update(c) + .map(|o| (o.pp, o.r1, o.r2, o.r3, o.s1, o.s2, o.s3))) + } + /// Batch over numpy columns high, low, close. Returns shape `(n, 7)` for + /// `[pp, r1, r2, r3, s1, s2, s3]`. + fn batch<'py>( + &mut self, + py: Python<'py>, + high: PyReadonlyArray1<'py, f64>, + low: PyReadonlyArray1<'py, f64>, + close: PyReadonlyArray1<'py, f64>, + ) -> PyResult>> { + let h = high + .as_slice() + .map_err(|_| PyValueError::new_err(NON_CONTIGUOUS))?; + let l = low + .as_slice() + .map_err(|_| PyValueError::new_err(NON_CONTIGUOUS))?; + let c = close + .as_slice() + .map_err(|_| PyValueError::new_err(NON_CONTIGUOUS))?; + if h.len() != l.len() || l.len() != c.len() { + return Err(PyValueError::new_err( + "high, low, close must be equal length", + )); + } + let n = h.len(); + let mut out = vec![f64::NAN; n * 7]; + for i in 0..n { + let candle = wc::Candle::new(c[i], h[i], l[i], c[i], 0.0, 0).map_err(map_err)?; + if let Some(o) = self.inner.update(candle) { + out[i * 7] = o.pp; + out[i * 7 + 1] = o.r1; + out[i * 7 + 2] = o.r2; + out[i * 7 + 3] = o.r3; + out[i * 7 + 4] = o.s1; + out[i * 7 + 5] = o.s2; + out[i * 7 + 6] = o.s3; + } + } + Ok(numpy::ndarray::Array2::from_shape_vec((n, 7), out) + .expect("shape consistent") + .into_pyarray(py)) + } + fn reset(&mut self) { + self.inner.reset(); + } + fn is_ready(&self) -> bool { + self.inner.is_ready() + } + fn warmup_period(&self) -> usize { + self.inner.warmup_period() + } +} + +// ============================== Fibonacci Pivots ============================== + +#[pyclass( + name = "FibonacciPivots", + module = "wickra._wickra", + skip_from_py_object +)] +#[derive(Clone)] +struct PyFibonacciPivots { + inner: wc::FibonacciPivots, +} + +#[pymethods] +impl PyFibonacciPivots { + #[new] + fn new() -> Self { + Self { + inner: wc::FibonacciPivots::new(), + } + } + fn update( + &mut self, + candle: &Bound<'_, PyAny>, + ) -> PyResult> { + let c = extract_candle(candle)?; + Ok(self + .inner + .update(c) + .map(|o| (o.pp, o.r1, o.r2, o.r3, o.s1, o.s2, o.s3))) + } + fn batch<'py>( + &mut self, + py: Python<'py>, + high: PyReadonlyArray1<'py, f64>, + low: PyReadonlyArray1<'py, f64>, + close: PyReadonlyArray1<'py, f64>, + ) -> PyResult>> { + let h = high + .as_slice() + .map_err(|_| PyValueError::new_err(NON_CONTIGUOUS))?; + let l = low + .as_slice() + .map_err(|_| PyValueError::new_err(NON_CONTIGUOUS))?; + let c = close + .as_slice() + .map_err(|_| PyValueError::new_err(NON_CONTIGUOUS))?; + if h.len() != l.len() || l.len() != c.len() { + return Err(PyValueError::new_err( + "high, low, close must be equal length", + )); + } + let n = h.len(); + let mut out = vec![f64::NAN; n * 7]; + for i in 0..n { + let candle = wc::Candle::new(c[i], h[i], l[i], c[i], 0.0, 0).map_err(map_err)?; + if let Some(o) = self.inner.update(candle) { + out[i * 7] = o.pp; + out[i * 7 + 1] = o.r1; + out[i * 7 + 2] = o.r2; + out[i * 7 + 3] = o.r3; + out[i * 7 + 4] = o.s1; + out[i * 7 + 5] = o.s2; + out[i * 7 + 6] = o.s3; + } + } + Ok(numpy::ndarray::Array2::from_shape_vec((n, 7), out) + .expect("shape consistent") + .into_pyarray(py)) + } + fn reset(&mut self) { + self.inner.reset(); + } + fn is_ready(&self) -> bool { + self.inner.is_ready() + } + fn warmup_period(&self) -> usize { + self.inner.warmup_period() + } +} + +// ============================== Camarilla Pivots ============================== + +#[pyclass(name = "Camarilla", module = "wickra._wickra", skip_from_py_object)] +#[derive(Clone)] +struct PyCamarilla { + inner: wc::Camarilla, +} + +#[pymethods] +impl PyCamarilla { + #[new] + fn new() -> Self { + Self { + inner: wc::Camarilla::new(), + } + } + /// Returns `(pp, r1, r2, r3, r4, s1, s2, s3, s4)` or None during warmup. + #[allow(clippy::type_complexity)] + fn update( + &mut self, + candle: &Bound<'_, PyAny>, + ) -> PyResult> { + let c = extract_candle(candle)?; + Ok(self + .inner + .update(c) + .map(|o| (o.pp, o.r1, o.r2, o.r3, o.r4, o.s1, o.s2, o.s3, o.s4))) + } + /// Batch over numpy columns high, low, close. Returns shape `(n, 9)` for + /// `[pp, r1, r2, r3, r4, s1, s2, s3, s4]`. + fn batch<'py>( + &mut self, + py: Python<'py>, + high: PyReadonlyArray1<'py, f64>, + low: PyReadonlyArray1<'py, f64>, + close: PyReadonlyArray1<'py, f64>, + ) -> PyResult>> { + let h = high + .as_slice() + .map_err(|_| PyValueError::new_err(NON_CONTIGUOUS))?; + let l = low + .as_slice() + .map_err(|_| PyValueError::new_err(NON_CONTIGUOUS))?; + let c = close + .as_slice() + .map_err(|_| PyValueError::new_err(NON_CONTIGUOUS))?; + if h.len() != l.len() || l.len() != c.len() { + return Err(PyValueError::new_err( + "high, low, close must be equal length", + )); + } + let n = h.len(); + let mut out = vec![f64::NAN; n * 9]; + for i in 0..n { + let candle = wc::Candle::new(c[i], h[i], l[i], c[i], 0.0, 0).map_err(map_err)?; + if let Some(o) = self.inner.update(candle) { + out[i * 9] = o.pp; + out[i * 9 + 1] = o.r1; + out[i * 9 + 2] = o.r2; + out[i * 9 + 3] = o.r3; + out[i * 9 + 4] = o.r4; + out[i * 9 + 5] = o.s1; + out[i * 9 + 6] = o.s2; + out[i * 9 + 7] = o.s3; + out[i * 9 + 8] = o.s4; + } + } + Ok(numpy::ndarray::Array2::from_shape_vec((n, 9), out) + .expect("shape consistent") + .into_pyarray(py)) + } + fn reset(&mut self) { + self.inner.reset(); + } + fn is_ready(&self) -> bool { + self.inner.is_ready() + } + fn warmup_period(&self) -> usize { + self.inner.warmup_period() + } +} + +// ============================== Woodie Pivots ============================== + +#[pyclass(name = "WoodiePivots", module = "wickra._wickra", skip_from_py_object)] +#[derive(Clone)] +struct PyWoodiePivots { + inner: wc::WoodiePivots, +} + +#[pymethods] +impl PyWoodiePivots { + #[new] + fn new() -> Self { + Self { + inner: wc::WoodiePivots::new(), + } + } + /// Returns `(pp, r1, r2, s1, s2)` or None during warmup. + fn update(&mut self, candle: &Bound<'_, PyAny>) -> PyResult> { + let c = extract_candle(candle)?; + Ok(self.inner.update(c).map(|o| (o.pp, o.r1, o.r2, o.s1, o.s2))) + } + /// Batch over numpy columns high, low, close. Returns shape `(n, 5)` for + /// `[pp, r1, r2, s1, s2]`. + fn batch<'py>( + &mut self, + py: Python<'py>, + high: PyReadonlyArray1<'py, f64>, + low: PyReadonlyArray1<'py, f64>, + close: PyReadonlyArray1<'py, f64>, + ) -> PyResult>> { + let h = high + .as_slice() + .map_err(|_| PyValueError::new_err(NON_CONTIGUOUS))?; + let l = low + .as_slice() + .map_err(|_| PyValueError::new_err(NON_CONTIGUOUS))?; + let c = close + .as_slice() + .map_err(|_| PyValueError::new_err(NON_CONTIGUOUS))?; + if h.len() != l.len() || l.len() != c.len() { + return Err(PyValueError::new_err( + "high, low, close must be equal length", + )); + } + let n = h.len(); + let mut out = vec![f64::NAN; n * 5]; + for i in 0..n { + let candle = wc::Candle::new(c[i], h[i], l[i], c[i], 0.0, 0).map_err(map_err)?; + if let Some(o) = self.inner.update(candle) { + out[i * 5] = o.pp; + out[i * 5 + 1] = o.r1; + out[i * 5 + 2] = o.r2; + out[i * 5 + 3] = o.s1; + out[i * 5 + 4] = o.s2; + } + } + Ok(numpy::ndarray::Array2::from_shape_vec((n, 5), out) + .expect("shape consistent") + .into_pyarray(py)) + } + fn reset(&mut self) { + self.inner.reset(); + } + fn is_ready(&self) -> bool { + self.inner.is_ready() + } + fn warmup_period(&self) -> usize { + self.inner.warmup_period() + } +} + +// ============================== DeMark Pivots ============================== + +#[pyclass(name = "DemarkPivots", module = "wickra._wickra", skip_from_py_object)] +#[derive(Clone)] +struct PyDemarkPivots { + inner: wc::DemarkPivots, +} + +#[pymethods] +impl PyDemarkPivots { + #[new] + fn new() -> Self { + Self { + inner: wc::DemarkPivots::new(), + } + } + /// Returns `(pp, r1, s1)` or None during warmup. + fn update(&mut self, candle: &Bound<'_, PyAny>) -> PyResult> { + let c = extract_candle(candle)?; + Ok(self.inner.update(c).map(|o| (o.pp, o.r1, o.s1))) + } + /// Batch over numpy columns open, high, low, close. Returns shape `(n, 3)` + /// for `[pp, r1, s1]`. + fn batch<'py>( + &mut self, + py: Python<'py>, + open: PyReadonlyArray1<'py, f64>, + high: PyReadonlyArray1<'py, f64>, + low: PyReadonlyArray1<'py, f64>, + close: PyReadonlyArray1<'py, f64>, + ) -> PyResult>> { + let o = open + .as_slice() + .map_err(|_| PyValueError::new_err(NON_CONTIGUOUS))?; + let h = high + .as_slice() + .map_err(|_| PyValueError::new_err(NON_CONTIGUOUS))?; + let l = low + .as_slice() + .map_err(|_| PyValueError::new_err(NON_CONTIGUOUS))?; + let c = close + .as_slice() + .map_err(|_| PyValueError::new_err(NON_CONTIGUOUS))?; + if o.len() != h.len() || h.len() != l.len() || l.len() != c.len() { + return Err(PyValueError::new_err( + "open, high, low, close must be equal length", + )); + } + let n = o.len(); + let mut out = vec![f64::NAN; n * 3]; + for i in 0..n { + let candle = wc::Candle::new(o[i], h[i], l[i], c[i], 0.0, 0).map_err(map_err)?; + if let Some(v) = self.inner.update(candle) { + out[i * 3] = v.pp; + out[i * 3 + 1] = v.r1; + out[i * 3 + 2] = v.s1; + } + } + Ok(numpy::ndarray::Array2::from_shape_vec((n, 3), out) + .expect("shape consistent") + .into_pyarray(py)) + } + fn reset(&mut self) { + self.inner.reset(); + } + fn is_ready(&self) -> bool { + self.inner.is_ready() + } + fn warmup_period(&self) -> usize { + self.inner.warmup_period() + } +} + +// ============================== Williams Fractals ============================== + +#[pyclass( + name = "WilliamsFractals", + module = "wickra._wickra", + skip_from_py_object +)] +#[derive(Clone)] +struct PyWilliamsFractals { + inner: wc::WilliamsFractals, +} + +#[pymethods] +impl PyWilliamsFractals { + #[new] + fn new() -> Self { + Self { + inner: wc::WilliamsFractals::new(), + } + } + /// Returns `(up, down)` where each component is either the fractal price + /// or `None` if no fractal was confirmed at the centre of the current + /// 5-bar window. The outer `None` is returned during warmup (first 4 bars). + fn update( + &mut self, + candle: &Bound<'_, PyAny>, + ) -> PyResult, Option)>> { + let c = extract_candle(candle)?; + Ok(self.inner.update(c).map(|o| (o.up, o.down))) + } + /// Batch over numpy columns high, low. Returns shape `(n, 2)` for + /// `[up_fractal, down_fractal]`. Values are NaN both during warmup and on + /// bars where no fractal was confirmed. + fn batch<'py>( + &mut self, + py: Python<'py>, + high: PyReadonlyArray1<'py, f64>, + low: PyReadonlyArray1<'py, f64>, + ) -> PyResult>> { + let h = high + .as_slice() + .map_err(|_| PyValueError::new_err(NON_CONTIGUOUS))?; + let l = low + .as_slice() + .map_err(|_| PyValueError::new_err(NON_CONTIGUOUS))?; + if h.len() != l.len() { + return Err(PyValueError::new_err("high and low must be equal length")); + } + let n = h.len(); + let mut out = vec![f64::NAN; n * 2]; + for i in 0..n { + let candle = wc::Candle::new(l[i], h[i], l[i], l[i], 0.0, 0).map_err(map_err)?; + if let Some(o) = self.inner.update(candle) { + if let Some(v) = o.up { + out[i * 2] = v; + } + if let Some(v) = o.down { + out[i * 2 + 1] = v; + } + } + } + Ok(numpy::ndarray::Array2::from_shape_vec((n, 2), out) + .expect("shape consistent") + .into_pyarray(py)) + } + fn reset(&mut self) { + self.inner.reset(); + } + fn is_ready(&self) -> bool { + self.inner.is_ready() + } + fn warmup_period(&self) -> usize { + self.inner.warmup_period() + } +} + +// ============================== ZigZag ============================== + +#[pyclass(name = "ZigZag", module = "wickra._wickra", skip_from_py_object)] +#[derive(Clone)] +struct PyZigZag { + inner: wc::ZigZag, +} + +#[pymethods] +impl PyZigZag { + #[new] + #[pyo3(signature = (threshold=0.05))] + fn new(threshold: f64) -> PyResult { + Ok(Self { + inner: wc::ZigZag::new(threshold).map_err(map_err)?, + }) + } + /// Returns `(swing, direction)` if a swing was confirmed on this bar, + /// else `None`. + fn update(&mut self, candle: &Bound<'_, PyAny>) -> PyResult> { + let c = extract_candle(candle)?; + Ok(self.inner.update(c).map(|o| (o.swing, o.direction))) + } + /// Batch over numpy columns high, low. Returns shape `(n, 2)` for + /// `[swing_price, direction]`. NaN on bars without a confirmed swing. + fn batch<'py>( + &mut self, + py: Python<'py>, + high: PyReadonlyArray1<'py, f64>, + low: PyReadonlyArray1<'py, f64>, + ) -> PyResult>> { + let h = high + .as_slice() + .map_err(|_| PyValueError::new_err(NON_CONTIGUOUS))?; + let l = low + .as_slice() + .map_err(|_| PyValueError::new_err(NON_CONTIGUOUS))?; + if h.len() != l.len() { + return Err(PyValueError::new_err("high and low must be equal length")); + } + let n = h.len(); + let mut out = vec![f64::NAN; n * 2]; + for i in 0..n { + let candle = wc::Candle::new(l[i], h[i], l[i], l[i], 0.0, 0).map_err(map_err)?; + if let Some(o) = self.inner.update(candle) { + out[i * 2] = o.swing; + out[i * 2 + 1] = o.direction; + } + } + Ok(numpy::ndarray::Array2::from_shape_vec((n, 2), out) + .expect("shape consistent") + .into_pyarray(py)) + } + #[getter] + fn threshold(&self) -> f64 { + self.inner.threshold() + } + fn reset(&mut self) { + self.inner.reset(); + } + fn is_ready(&self) -> bool { + self.inner.is_ready() + } + fn warmup_period(&self) -> usize { + self.inner.warmup_period() + } +} // ============================== Module ============================== #[pymodule] @@ -8183,5 +8711,12 @@ fn _wickra(_py: Python<'_>, m: &Bound<'_, PyModule>) -> PyResult<()> { m.add_class::()?; m.add_class::()?; m.add_class::()?; + m.add_class::()?; + m.add_class::()?; + m.add_class::()?; + m.add_class::()?; + m.add_class::()?; + m.add_class::()?; + m.add_class::()?; Ok(()) } diff --git a/bindings/python/tests/test_new_indicators.py b/bindings/python/tests/test_new_indicators.py index ea7a864c..d49cf58c 100644 --- a/bindings/python/tests/test_new_indicators.py +++ b/bindings/python/tests/test_new_indicators.py @@ -304,38 +304,90 @@ def test_candle_scalar_streaming_matches_batch(name, ohlcv): # --- Candle-input, multi-output indicators -------------------------------- MULTI = { - "Vortex": (lambda: ta.Vortex(14), lambda ind, h, l, c, v: ind.batch(h, l, c)), - "RWI": (lambda: ta.RWI(14), lambda ind, h, l, c, v: ind.batch(h, l, c)), + "Vortex": ( + lambda: ta.Vortex(14), + lambda ind, h, l, c, v: ind.batch(h, l, c), + 2, + ), + "RWI": ( + lambda: ta.RWI(14), + lambda ind, h, l, c, v: ind.batch(h, l, c), + 2, + ), "WaveTrend": ( lambda: ta.WaveTrend.classic(), lambda ind, h, l, c, v: ind.batch(h, l, c), + 2, ), "SuperTrend": ( lambda: ta.SuperTrend(10, 3.0), lambda ind, h, l, c, v: ind.batch(h, l, c), + 2, ), "ChandelierExit": ( lambda: ta.ChandelierExit(22, 3.0), lambda ind, h, l, c, v: ind.batch(h, l, c), + 2, ), "ChandeKrollStop": ( lambda: ta.ChandeKrollStop(10, 1.0, 9), lambda ind, h, l, c, v: ind.batch(h, l, c), + 2, + ), + "ClassicPivots": ( + lambda: ta.ClassicPivots(), + lambda ind, h, l, c, v: ind.batch(h, l, c), + 7, + ), + "FibonacciPivots": ( + lambda: ta.FibonacciPivots(), + lambda ind, h, l, c, v: ind.batch(h, l, c), + 7, + ), + "Camarilla": ( + lambda: ta.Camarilla(), + lambda ind, h, l, c, v: ind.batch(h, l, c), + 9, + ), + "WoodiePivots": ( + lambda: ta.WoodiePivots(), + lambda ind, h, l, c, v: ind.batch(h, l, c), + 5, + ), + "DemarkPivots": ( + # batch needs open; pass close in for open since the synthetic OHLCV + # streaming feeds open=close as well. + lambda: ta.DemarkPivots(), + lambda ind, h, l, c, v: ind.batch(c, h, l, c), + 3, + ), + "WilliamsFractals": ( + lambda: ta.WilliamsFractals(), + lambda ind, h, l, c, v: ind.batch(h, l), + 2, + ), + "ZigZag": ( + lambda: ta.ZigZag(0.02), + lambda ind, h, l, c, v: ind.batch(h, l), + 2, ), "DonchianStop": ( lambda: ta.DonchianStop(10), lambda ind, h, l, c, v: ind.batch(h, l), + 2, ), # Family 05 candle-input bands. Each entry is - # `(factory, batch_call, output_arity, streaming_fields)` where - # `streaming_fields` is the tuple shape returned by `update(...)`. + # `(factory, batch_call, output_arity)` where the third element is the + # tuple shape returned by `update(...)`. "TtmSqueeze": ( lambda: ta.TtmSqueeze(20, 2.0, 1.5), lambda ind, h, l, c, v: ind.batch(h, l, c), + 2, ), "FractalChaosBands": ( lambda: ta.FractalChaosBands(2), lambda ind, h, l, c, v: ind.batch(h, l), + 2, ), } @@ -364,10 +416,10 @@ MULTI_SCALAR_INPUT = { @pytest.mark.parametrize("name", list(MULTI)) def test_multi_streaming_matches_batch(name, ohlcv): high, low, close, volume = ohlcv - make, batch_call = MULTI[name] + make, batch_call, k = MULTI[name] batch = batch_call(make(), high, low, close, volume) - assert batch.shape == (close.size, 2) + assert batch.shape == (close.size, k) streamer = make() rows = [] @@ -381,7 +433,13 @@ def test_multi_streaming_matches_batch(name, ohlcv): i, ) v = streamer.update(candle) - rows.append([math.nan, math.nan] if v is None else list(v)) + if v is None: + rows.append([math.nan] * k) + else: + # WilliamsFractals returns (Optional[float], Optional[float]); the + # batch helper writes NaN for None. Normalise tuple/list entries + # the same way before the equality check. + rows.append([math.nan if x is None else float(x) for x in v]) assert _eq_nan(batch, np.array(rows, dtype=np.float64)), f"{name} mismatch" @@ -757,6 +815,89 @@ def test_z_score_reference(): assert out[1] == pytest.approx(1.0) +def test_classic_pivots_reference(): + # H=110, L=90, C=105 -> PP = 305/3, R1 = 2·PP − L, S1 = 2·PP − H. + cp = ta.ClassicPivots() + pp, r1, r2, r3, s1, s2, s3 = cp.update((105.0, 110.0, 90.0, 105.0, 1.0, 0)) + expected_pp = 305.0 / 3.0 + assert pp == pytest.approx(expected_pp) + assert r1 == pytest.approx(2 * expected_pp - 90.0) + assert s1 == pytest.approx(2 * expected_pp - 110.0) + assert r2 == pytest.approx(expected_pp + 20.0) + assert s2 == pytest.approx(expected_pp - 20.0) + assert r3 > r2 and s3 < s2 + + +def test_fibonacci_pivots_reference(): + # H=110, L=90, C=100 -> PP=100, range=20, R1=PP+0.382·range, etc. + fp = ta.FibonacciPivots() + pp, r1, r2, r3, s1, s2, s3 = fp.update((100.0, 110.0, 90.0, 100.0, 1.0, 0)) + assert pp == pytest.approx(100.0) + assert r1 == pytest.approx(100.0 + 0.382 * 20.0) + assert r2 == pytest.approx(100.0 + 0.618 * 20.0) + assert r3 == pytest.approx(100.0 + 20.0) + assert s1 == pytest.approx(100.0 - 0.382 * 20.0) + assert s3 == pytest.approx(100.0 - 20.0) + + +def test_camarilla_pivots_reference(): + # H=110, L=90, C=105 -> R4 = C + range · 1.1 / 2 = 105 + 11 = 116. + cm = ta.Camarilla() + pp, r1, r2, r3, r4, s1, s2, s3, s4 = cm.update((105.0, 110.0, 90.0, 105.0, 1.0, 0)) + range_ = 20.0 + assert r1 == pytest.approx(105.0 + range_ * 1.1 / 12.0) + assert r4 == pytest.approx(105.0 + range_ * 1.1 / 2.0) + assert s4 == pytest.approx(105.0 - range_ * 1.1 / 2.0) + assert pp == pytest.approx(305.0 / 3.0) + # Strict widening with index. + assert r4 > r3 > r2 > r1 + assert s4 < s3 < s2 < s1 + + +def test_woodie_pivots_reference(): + # H=110, L=90, C=108 -> PP = (110 + 90 + 216) / 4 = 104. + wp = ta.WoodiePivots() + pp, r1, r2, s1, s2 = wp.update((108.0, 110.0, 90.0, 108.0, 1.0, 0)) + assert pp == pytest.approx(104.0) + assert r1 == pytest.approx(2 * 104.0 - 90.0) + assert s1 == pytest.approx(2 * 104.0 - 110.0) + assert r2 == pytest.approx(104.0 + 20.0) + assert s2 == pytest.approx(104.0 - 20.0) + + +def test_demark_pivots_up_bar_reference(): + # Up bar: O=100, H=120, L=80, C=110 -> X = H + 2L + C = 390, PP = 97.5. + dp = ta.DemarkPivots() + pp, r1, s1 = dp.update((100.0, 120.0, 80.0, 110.0, 1.0, 0)) + assert pp == pytest.approx(97.5) + assert r1 == pytest.approx(195.0 - 80.0) + assert s1 == pytest.approx(195.0 - 120.0) + + +def test_williams_fractals_isolated_peak(): + # Highs 1, 2, 5, 2, 1; the centre is strictly above both neighbours. + wf = ta.WilliamsFractals() + last = None + for i, h in enumerate([1.0, 2.0, 5.0, 2.0, 1.0]): + last = wf.update((h, h, h - 0.5, h, 1.0, i)) + assert last is not None + up, down = last + assert up == pytest.approx(5.0) + assert down is None + + +def test_zigzag_confirms_after_threshold_reversal(): + # 100 -> 120 (uptrend pivot) -> 100 (16.7% drop confirms swing high). + zz = ta.ZigZag(0.10) + assert zz.update((100.0, 100.5, 99.5, 100.0, 1.0, 0)) is None + assert zz.update((120.0, 120.5, 119.5, 120.0, 1.0, 1)) is None + confirmed = zz.update((100.0, 100.5, 99.5, 100.0, 1.0, 2)) + assert confirmed is not None + swing, direction = confirmed + assert swing == pytest.approx(120.5) + assert direction == 1.0 + + # --- Family 05 reference values --------------------------------------------- @@ -864,7 +1005,7 @@ def test_fractal_chaos_bands_detects_peak_and_trough(): def test_new_indicators_expose_lifecycle(): instances = [make() for make, _ in CANDLE_SCALAR.values()] - instances += [make() for make, _ in MULTI.values()] + instances += [t[0]() for t in MULTI.values()] instances += [make() for make, _ in MULTI_SCALAR_INPUT.values()] instances += [cls(*args) for cls, args in SCALAR] instances += [make() for make, _ in SCALAR_MULTI.values()] diff --git a/bindings/wasm/src/lib.rs b/bindings/wasm/src/lib.rs index 7a6456c0..fe3f53b6 100644 --- a/bindings/wasm/src/lib.rs +++ b/bindings/wasm/src/lib.rs @@ -4152,6 +4152,499 @@ impl WasmVwapStdDevBands { } } +// ============================== Pivots & S/R ============================== + +#[wasm_bindgen(js_name = ClassicPivots)] +pub struct WasmClassicPivots { + inner: wc::ClassicPivots, +} + +impl Default for WasmClassicPivots { + fn default() -> Self { + Self::new() + } +} + +#[wasm_bindgen(js_class = ClassicPivots)] +impl WasmClassicPivots { + #[wasm_bindgen(constructor)] + pub fn new() -> WasmClassicPivots { + Self { + inner: wc::ClassicPivots::new(), + } + } + pub fn update(&mut self, high: f64, low: f64, close: f64) -> Result { + let c = make_candle(high, low, close, 0.0)?; + Ok(match self.inner.update(c) { + Some(o) => { + let obj = Object::new(); + Reflect::set(&obj, &"pp".into(), &o.pp.into()).ok(); + Reflect::set(&obj, &"r1".into(), &o.r1.into()).ok(); + Reflect::set(&obj, &"r2".into(), &o.r2.into()).ok(); + Reflect::set(&obj, &"r3".into(), &o.r3.into()).ok(); + Reflect::set(&obj, &"s1".into(), &o.s1.into()).ok(); + Reflect::set(&obj, &"s2".into(), &o.s2.into()).ok(); + Reflect::set(&obj, &"s3".into(), &o.s3.into()).ok(); + obj.into() + } + None => JsValue::NULL, + }) + } + pub fn batch( + &mut self, + high: &[f64], + low: &[f64], + close: &[f64], + ) -> Result { + if high.len() != low.len() || low.len() != close.len() { + return Err(JsError::new("high, low, close must be equal length")); + } + let n = high.len(); + let mut out = vec![f64::NAN; n * 7]; + for i in 0..n { + let c = make_candle(high[i], low[i], close[i], 0.0)?; + if let Some(o) = self.inner.update(c) { + out[i * 7] = o.pp; + out[i * 7 + 1] = o.r1; + out[i * 7 + 2] = o.r2; + out[i * 7 + 3] = o.r3; + out[i * 7 + 4] = o.s1; + out[i * 7 + 5] = o.s2; + out[i * 7 + 6] = o.s3; + } + } + Ok(Float64Array::from(out.as_slice())) + } + pub fn reset(&mut self) { + self.inner.reset(); + } + #[wasm_bindgen(js_name = isReady)] + pub fn is_ready(&self) -> bool { + self.inner.is_ready() + } + #[wasm_bindgen(js_name = warmupPeriod)] + pub fn warmup_period(&self) -> usize { + self.inner.warmup_period() + } +} + +#[wasm_bindgen(js_name = FibonacciPivots)] +pub struct WasmFibonacciPivots { + inner: wc::FibonacciPivots, +} + +impl Default for WasmFibonacciPivots { + fn default() -> Self { + Self::new() + } +} + +#[wasm_bindgen(js_class = FibonacciPivots)] +impl WasmFibonacciPivots { + #[wasm_bindgen(constructor)] + pub fn new() -> WasmFibonacciPivots { + Self { + inner: wc::FibonacciPivots::new(), + } + } + pub fn update(&mut self, high: f64, low: f64, close: f64) -> Result { + let c = make_candle(high, low, close, 0.0)?; + Ok(match self.inner.update(c) { + Some(o) => { + let obj = Object::new(); + Reflect::set(&obj, &"pp".into(), &o.pp.into()).ok(); + Reflect::set(&obj, &"r1".into(), &o.r1.into()).ok(); + Reflect::set(&obj, &"r2".into(), &o.r2.into()).ok(); + Reflect::set(&obj, &"r3".into(), &o.r3.into()).ok(); + Reflect::set(&obj, &"s1".into(), &o.s1.into()).ok(); + Reflect::set(&obj, &"s2".into(), &o.s2.into()).ok(); + Reflect::set(&obj, &"s3".into(), &o.s3.into()).ok(); + obj.into() + } + None => JsValue::NULL, + }) + } + pub fn batch( + &mut self, + high: &[f64], + low: &[f64], + close: &[f64], + ) -> Result { + if high.len() != low.len() || low.len() != close.len() { + return Err(JsError::new("high, low, close must be equal length")); + } + let n = high.len(); + let mut out = vec![f64::NAN; n * 7]; + for i in 0..n { + let c = make_candle(high[i], low[i], close[i], 0.0)?; + if let Some(o) = self.inner.update(c) { + out[i * 7] = o.pp; + out[i * 7 + 1] = o.r1; + out[i * 7 + 2] = o.r2; + out[i * 7 + 3] = o.r3; + out[i * 7 + 4] = o.s1; + out[i * 7 + 5] = o.s2; + out[i * 7 + 6] = o.s3; + } + } + Ok(Float64Array::from(out.as_slice())) + } + pub fn reset(&mut self) { + self.inner.reset(); + } + #[wasm_bindgen(js_name = isReady)] + pub fn is_ready(&self) -> bool { + self.inner.is_ready() + } + #[wasm_bindgen(js_name = warmupPeriod)] + pub fn warmup_period(&self) -> usize { + self.inner.warmup_period() + } +} + +#[wasm_bindgen(js_name = Camarilla)] +pub struct WasmCamarilla { + inner: wc::Camarilla, +} + +impl Default for WasmCamarilla { + fn default() -> Self { + Self::new() + } +} + +#[wasm_bindgen(js_class = Camarilla)] +impl WasmCamarilla { + #[wasm_bindgen(constructor)] + pub fn new() -> WasmCamarilla { + Self { + inner: wc::Camarilla::new(), + } + } + pub fn update(&mut self, high: f64, low: f64, close: f64) -> Result { + let c = make_candle(high, low, close, 0.0)?; + Ok(match self.inner.update(c) { + Some(o) => { + let obj = Object::new(); + Reflect::set(&obj, &"pp".into(), &o.pp.into()).ok(); + Reflect::set(&obj, &"r1".into(), &o.r1.into()).ok(); + Reflect::set(&obj, &"r2".into(), &o.r2.into()).ok(); + Reflect::set(&obj, &"r3".into(), &o.r3.into()).ok(); + Reflect::set(&obj, &"r4".into(), &o.r4.into()).ok(); + Reflect::set(&obj, &"s1".into(), &o.s1.into()).ok(); + Reflect::set(&obj, &"s2".into(), &o.s2.into()).ok(); + Reflect::set(&obj, &"s3".into(), &o.s3.into()).ok(); + Reflect::set(&obj, &"s4".into(), &o.s4.into()).ok(); + obj.into() + } + None => JsValue::NULL, + }) + } + pub fn batch( + &mut self, + high: &[f64], + low: &[f64], + close: &[f64], + ) -> Result { + if high.len() != low.len() || low.len() != close.len() { + return Err(JsError::new("high, low, close must be equal length")); + } + let n = high.len(); + let mut out = vec![f64::NAN; n * 9]; + for i in 0..n { + let c = make_candle(high[i], low[i], close[i], 0.0)?; + if let Some(o) = self.inner.update(c) { + out[i * 9] = o.pp; + out[i * 9 + 1] = o.r1; + out[i * 9 + 2] = o.r2; + out[i * 9 + 3] = o.r3; + out[i * 9 + 4] = o.r4; + out[i * 9 + 5] = o.s1; + out[i * 9 + 6] = o.s2; + out[i * 9 + 7] = o.s3; + out[i * 9 + 8] = o.s4; + } + } + Ok(Float64Array::from(out.as_slice())) + } + pub fn reset(&mut self) { + self.inner.reset(); + } + #[wasm_bindgen(js_name = isReady)] + pub fn is_ready(&self) -> bool { + self.inner.is_ready() + } + #[wasm_bindgen(js_name = warmupPeriod)] + pub fn warmup_period(&self) -> usize { + self.inner.warmup_period() + } +} + +#[wasm_bindgen(js_name = WoodiePivots)] +pub struct WasmWoodiePivots { + inner: wc::WoodiePivots, +} + +impl Default for WasmWoodiePivots { + fn default() -> Self { + Self::new() + } +} + +#[wasm_bindgen(js_class = WoodiePivots)] +impl WasmWoodiePivots { + #[wasm_bindgen(constructor)] + pub fn new() -> WasmWoodiePivots { + Self { + inner: wc::WoodiePivots::new(), + } + } + pub fn update(&mut self, high: f64, low: f64, close: f64) -> Result { + let c = make_candle(high, low, close, 0.0)?; + Ok(match self.inner.update(c) { + Some(o) => { + let obj = Object::new(); + Reflect::set(&obj, &"pp".into(), &o.pp.into()).ok(); + Reflect::set(&obj, &"r1".into(), &o.r1.into()).ok(); + Reflect::set(&obj, &"r2".into(), &o.r2.into()).ok(); + Reflect::set(&obj, &"s1".into(), &o.s1.into()).ok(); + Reflect::set(&obj, &"s2".into(), &o.s2.into()).ok(); + obj.into() + } + None => JsValue::NULL, + }) + } + pub fn batch( + &mut self, + high: &[f64], + low: &[f64], + close: &[f64], + ) -> Result { + if high.len() != low.len() || low.len() != close.len() { + return Err(JsError::new("high, low, close must be equal length")); + } + let n = high.len(); + let mut out = vec![f64::NAN; n * 5]; + for i in 0..n { + let c = make_candle(high[i], low[i], close[i], 0.0)?; + if let Some(o) = self.inner.update(c) { + out[i * 5] = o.pp; + out[i * 5 + 1] = o.r1; + out[i * 5 + 2] = o.r2; + out[i * 5 + 3] = o.s1; + out[i * 5 + 4] = o.s2; + } + } + Ok(Float64Array::from(out.as_slice())) + } + pub fn reset(&mut self) { + self.inner.reset(); + } + #[wasm_bindgen(js_name = isReady)] + pub fn is_ready(&self) -> bool { + self.inner.is_ready() + } + #[wasm_bindgen(js_name = warmupPeriod)] + pub fn warmup_period(&self) -> usize { + self.inner.warmup_period() + } +} + +#[wasm_bindgen(js_name = DemarkPivots)] +pub struct WasmDemarkPivots { + inner: wc::DemarkPivots, +} + +impl Default for WasmDemarkPivots { + fn default() -> Self { + Self::new() + } +} + +#[wasm_bindgen(js_class = DemarkPivots)] +impl WasmDemarkPivots { + #[wasm_bindgen(constructor)] + pub fn new() -> WasmDemarkPivots { + Self { + inner: wc::DemarkPivots::new(), + } + } + pub fn update( + &mut self, + open: f64, + high: f64, + low: f64, + close: f64, + ) -> Result { + let c = wc::Candle::new(open, high, low, close, 0.0, 0).map_err(map_err)?; + Ok(match self.inner.update(c) { + Some(o) => { + let obj = Object::new(); + Reflect::set(&obj, &"pp".into(), &o.pp.into()).ok(); + Reflect::set(&obj, &"r1".into(), &o.r1.into()).ok(); + Reflect::set(&obj, &"s1".into(), &o.s1.into()).ok(); + obj.into() + } + None => JsValue::NULL, + }) + } + pub fn batch( + &mut self, + open: &[f64], + high: &[f64], + low: &[f64], + close: &[f64], + ) -> Result { + if open.len() != high.len() || high.len() != low.len() || low.len() != close.len() { + return Err(JsError::new("open, high, low, close must be equal length")); + } + let n = open.len(); + let mut out = vec![f64::NAN; n * 3]; + for i in 0..n { + let c = wc::Candle::new(open[i], high[i], low[i], close[i], 0.0, 0).map_err(map_err)?; + if let Some(o) = self.inner.update(c) { + out[i * 3] = o.pp; + out[i * 3 + 1] = o.r1; + out[i * 3 + 2] = o.s1; + } + } + Ok(Float64Array::from(out.as_slice())) + } + pub fn reset(&mut self) { + self.inner.reset(); + } + #[wasm_bindgen(js_name = isReady)] + pub fn is_ready(&self) -> bool { + self.inner.is_ready() + } + #[wasm_bindgen(js_name = warmupPeriod)] + pub fn warmup_period(&self) -> usize { + self.inner.warmup_period() + } +} + +#[wasm_bindgen(js_name = WilliamsFractals)] +pub struct WasmWilliamsFractals { + inner: wc::WilliamsFractals, +} + +impl Default for WasmWilliamsFractals { + fn default() -> Self { + Self::new() + } +} + +#[wasm_bindgen(js_class = WilliamsFractals)] +impl WasmWilliamsFractals { + #[wasm_bindgen(constructor)] + pub fn new() -> WasmWilliamsFractals { + Self { + inner: wc::WilliamsFractals::new(), + } + } + /// Returns `{ up, down }` where each is the fractal price or `NaN` when no + /// fractal was confirmed at the centre of the most recent 5-bar window. + /// Returns `null` during the four-bar warmup. + pub fn update(&mut self, high: f64, low: f64) -> Result { + let c = make_candle(high, low, low, 0.0)?; + Ok(match self.inner.update(c) { + Some(o) => { + let obj = Object::new(); + Reflect::set(&obj, &"up".into(), &o.up.unwrap_or(f64::NAN).into()).ok(); + Reflect::set(&obj, &"down".into(), &o.down.unwrap_or(f64::NAN).into()).ok(); + obj.into() + } + None => JsValue::NULL, + }) + } + pub fn batch(&mut self, high: &[f64], low: &[f64]) -> Result { + if high.len() != low.len() { + return Err(JsError::new("high and low must be equal length")); + } + let n = high.len(); + let mut out = vec![f64::NAN; n * 2]; + for i in 0..n { + let c = make_candle(high[i], low[i], low[i], 0.0)?; + if let Some(o) = self.inner.update(c) { + if let Some(v) = o.up { + out[i * 2] = v; + } + if let Some(v) = o.down { + out[i * 2 + 1] = v; + } + } + } + Ok(Float64Array::from(out.as_slice())) + } + pub fn reset(&mut self) { + self.inner.reset(); + } + #[wasm_bindgen(js_name = isReady)] + pub fn is_ready(&self) -> bool { + self.inner.is_ready() + } + #[wasm_bindgen(js_name = warmupPeriod)] + pub fn warmup_period(&self) -> usize { + self.inner.warmup_period() + } +} + +#[wasm_bindgen(js_name = ZigZag)] +pub struct WasmZigZag { + inner: wc::ZigZag, +} + +#[wasm_bindgen(js_class = ZigZag)] +impl WasmZigZag { + #[wasm_bindgen(constructor)] + pub fn new(threshold: f64) -> Result { + Ok(Self { + inner: wc::ZigZag::new(threshold).map_err(map_err)?, + }) + } + pub fn update(&mut self, high: f64, low: f64) -> Result { + let c = make_candle(high, low, low, 0.0)?; + Ok(match self.inner.update(c) { + Some(o) => { + let obj = Object::new(); + Reflect::set(&obj, &"swing".into(), &o.swing.into()).ok(); + Reflect::set(&obj, &"direction".into(), &o.direction.into()).ok(); + obj.into() + } + None => JsValue::NULL, + }) + } + pub fn batch(&mut self, high: &[f64], low: &[f64]) -> Result { + if high.len() != low.len() { + return Err(JsError::new("high and low must be equal length")); + } + let n = high.len(); + let mut out = vec![f64::NAN; n * 2]; + for i in 0..n { + let c = make_candle(high[i], low[i], low[i], 0.0)?; + if let Some(o) = self.inner.update(c) { + out[i * 2] = o.swing; + out[i * 2 + 1] = o.direction; + } + } + Ok(Float64Array::from(out.as_slice())) + } + #[wasm_bindgen(js_name = threshold)] + pub fn threshold(&self) -> f64 { + self.inner.threshold() + } + pub fn reset(&mut self) { + self.inner.reset(); + } + #[wasm_bindgen(js_name = isReady)] + pub fn is_ready(&self) -> bool { + self.inner.is_ready() + } + #[wasm_bindgen(js_name = warmupPeriod)] + pub fn warmup_period(&self) -> usize { + self.inner.warmup_period() + } +} #[cfg(test)] mod tests { use super::*; diff --git a/crates/wickra-core/src/indicators/camarilla_pivots.rs b/crates/wickra-core/src/indicators/camarilla_pivots.rs new file mode 100644 index 00000000..7195dabd --- /dev/null +++ b/crates/wickra-core/src/indicators/camarilla_pivots.rs @@ -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 { + 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 = (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::>() + ); + } + + #[test] + fn accessors_and_metadata() { + let p = Camarilla::new(); + assert_eq!(p.warmup_period(), 1); + assert_eq!(p.name(), "Camarilla"); + } +} diff --git a/crates/wickra-core/src/indicators/classic_pivots.rs b/crates/wickra-core/src/indicators/classic_pivots.rs new file mode 100644 index 00000000..b160cd80 --- /dev/null +++ b/crates/wickra-core/src/indicators/classic_pivots.rs @@ -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 { + 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 = (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::>() + ); + } + + #[test] + fn accessors_and_metadata() { + let pp = ClassicPivots::new(); + assert_eq!(pp.warmup_period(), 1); + assert_eq!(pp.name(), "ClassicPivots"); + } +} diff --git a/crates/wickra-core/src/indicators/demark_pivots.rs b/crates/wickra-core/src/indicators/demark_pivots.rs new file mode 100644 index 00000000..28c29bd7 --- /dev/null +++ b/crates/wickra-core/src/indicators/demark_pivots.rs @@ -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 { + 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 = (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::>() + ); + } + + #[test] + fn accessors_and_metadata() { + let p = DemarkPivots::new(); + assert_eq!(p.warmup_period(), 1); + assert_eq!(p.name(), "DemarkPivots"); + } +} diff --git a/crates/wickra-core/src/indicators/fibonacci_pivots.rs b/crates/wickra-core/src/indicators/fibonacci_pivots.rs new file mode 100644 index 00000000..e011e8f4 --- /dev/null +++ b/crates/wickra-core/src/indicators/fibonacci_pivots.rs @@ -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 { + 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 = (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::>() + ); + } + + #[test] + fn accessors_and_metadata() { + let p = FibonacciPivots::new(); + assert_eq!(p.warmup_period(), 1); + assert_eq!(p.name(), "FibonacciPivots"); + } +} diff --git a/crates/wickra-core/src/indicators/mod.rs b/crates/wickra-core/src/indicators/mod.rs index 13c9fcb1..5752319d 100644 --- a/crates/wickra-core/src/indicators/mod.rs +++ b/crates/wickra-core/src/indicators/mod.rs @@ -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; diff --git a/crates/wickra-core/src/indicators/williams_fractals.rs b/crates/wickra-core/src/indicators/williams_fractals.rs new file mode 100644 index 00000000..b40d3ce7 --- /dev/null +++ b/crates/wickra-core/src/indicators/williams_fractals.rs @@ -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, + /// 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, +} + +/// 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 { + 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 = (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::>() + ); + } + + #[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()); + } +} diff --git a/crates/wickra-core/src/indicators/woodie_pivots.rs b/crates/wickra-core/src/indicators/woodie_pivots.rs new file mode 100644 index 00000000..40e34779 --- /dev/null +++ b/crates/wickra-core/src/indicators/woodie_pivots.rs @@ -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 { + 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 = (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::>() + ); + } + + #[test] + fn accessors_and_metadata() { + let p = WoodiePivots::new(); + assert_eq!(p.warmup_period(), 1); + assert_eq!(p.name(), "WoodiePivots"); + } +} diff --git a/crates/wickra-core/src/indicators/zig_zag.rs b/crates/wickra-core/src/indicators/zig_zag.rs new file mode 100644 index 00000000..3175b71b --- /dev/null +++ b/crates/wickra-core/src/indicators/zig_zag.rs @@ -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, +} + +#[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 { + 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 { + 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 = (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::>() + ); + } + + #[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"); + } +} diff --git a/crates/wickra-core/src/lib.rs b/crates/wickra-core/src/lib.rs index 8ca8b655..2a81e38e 100644 --- a/crates/wickra-core/src/lib.rs +++ b/crates/wickra-core/src/lib.rs @@ -48,25 +48,28 @@ pub use indicators::{ AdxOutput, Adxr, Alligator, AlligatorOutput, Alma, AnchoredVwap, Apo, Aroon, AroonOscillator, AroonOutput, Atr, AtrBands, AtrBandsOutput, AtrTrailingStop, AwesomeOscillator, AwesomeOscillatorHistogram, BalanceOfPower, BollingerBands, BollingerBandwidth, - BollingerOutput, Cci, Cfo, ChaikinMoneyFlow, ChaikinOscillator, ChaikinVolatility, - ChandeKrollStop, ChandeKrollStopOutput, ChandelierExit, ChandelierExitOutput, ChoppinessIndex, - Cmo, ConnorsRsi, Coppock, Dema, DemandIndex, Donchian, DonchianOutput, DonchianStop, - DonchianStopOutput, DoubleBollinger, DoubleBollingerOutput, Dpo, EaseOfMovement, ElderImpulse, - Ema, Evwma, ForceIndex, FractalChaosBands, FractalChaosBandsOutput, Frama, - GarmanKlassVolatility, HiLoActivator, HistoricalVolatility, Hma, HurstChannel, - HurstChannelOutput, Inertia, Jma, Kama, Keltner, KeltnerOutput, Kst, KstOutput, Kvo, - LaguerreRsi, LinRegAngle, LinRegChannel, LinRegChannelOutput, LinRegSlope, LinearRegression, - MaEnvelope, MaEnvelopeOutput, MacdIndicator, MacdOutput, MarketFacilitationIndex, MassIndex, - McGinleyDynamic, MedianPrice, Mfi, Mom, Natr, Nvi, Obv, ParkinsonVolatility, PercentB, - PercentageTrailingStop, Pgo, Pmo, Ppo, Psar, Pvi, RenkoTrailingStop, Roc, - RogersSatchellVolatility, RollingVwap, Rsi, Rvi, RviVolatility, Rwi, RwiOutput, Sma, Smi, Smma, - StandardErrorBands, StandardErrorBandsOutput, StarcBands, StarcBandsOutput, Stc, StdDev, - StepTrailingStop, StochRsi, Stochastic, StochasticOutput, SuperTrend, SuperTrendOutput, Tema, - Tii, Trima, Trix, TrueRange, Tsi, Tsv, TtmSqueeze, TtmSqueezeOutput, TypicalPrice, UlcerIndex, - UltimateOscillator, VerticalHorizontalFilter, Vidya, VoltyStop, VolumeOscillator, - VolumePriceTrend, Vortex, VortexOutput, Vwap, VwapStdDevBands, VwapStdDevBandsOutput, Vwma, - Vzo, WaveTrend, WaveTrendOutput, WeightedClose, WilliamsR, Wma, YangZhangVolatility, YoyoExit, - ZScore, ZeroLagMacd, ZeroLagMacdOutput, Zlema, T3, + BollingerOutput, Camarilla, CamarillaPivotsOutput, Cci, Cfo, ChaikinMoneyFlow, + ChaikinOscillator, ChaikinVolatility, ChandeKrollStop, ChandeKrollStopOutput, ChandelierExit, + ChandelierExitOutput, ChoppinessIndex, ClassicPivots, ClassicPivotsOutput, Cmo, ConnorsRsi, + Coppock, Dema, DemandIndex, DemarkPivots, DemarkPivotsOutput, Donchian, DonchianOutput, + DonchianStop, DonchianStopOutput, DoubleBollinger, DoubleBollingerOutput, Dpo, EaseOfMovement, + ElderImpulse, Ema, Evwma, FibonacciPivots, FibonacciPivotsOutput, ForceIndex, + FractalChaosBands, FractalChaosBandsOutput, Frama, GarmanKlassVolatility, HiLoActivator, + HistoricalVolatility, Hma, HurstChannel, HurstChannelOutput, Inertia, Jma, Kama, Keltner, + KeltnerOutput, Kst, KstOutput, Kvo, LaguerreRsi, LinRegAngle, LinRegChannel, + LinRegChannelOutput, LinRegSlope, LinearRegression, MaEnvelope, MaEnvelopeOutput, + MacdIndicator, MacdOutput, MarketFacilitationIndex, MassIndex, McGinleyDynamic, MedianPrice, + Mfi, Mom, Natr, Nvi, Obv, ParkinsonVolatility, PercentB, PercentageTrailingStop, Pgo, Pmo, Ppo, + Psar, Pvi, RenkoTrailingStop, Roc, RogersSatchellVolatility, RollingVwap, Rsi, Rvi, + RviVolatility, Rwi, RwiOutput, Sma, Smi, Smma, StandardErrorBands, StandardErrorBandsOutput, + StarcBands, StarcBandsOutput, Stc, StdDev, StepTrailingStop, StochRsi, Stochastic, + StochasticOutput, SuperTrend, SuperTrendOutput, Tema, Tii, Trima, Trix, TrueRange, Tsi, Tsv, + TtmSqueeze, TtmSqueezeOutput, TypicalPrice, UlcerIndex, UltimateOscillator, + VerticalHorizontalFilter, Vidya, VoltyStop, VolumeOscillator, VolumePriceTrend, Vortex, + VortexOutput, Vwap, VwapStdDevBands, VwapStdDevBandsOutput, Vwma, Vzo, WaveTrend, + WaveTrendOutput, WeightedClose, WilliamsFractals, WilliamsFractalsOutput, WilliamsR, Wma, + WoodiePivots, WoodiePivotsOutput, YangZhangVolatility, YoyoExit, ZScore, ZeroLagMacd, + ZeroLagMacdOutput, ZigZag, ZigZagOutput, Zlema, T3, }; pub use ohlcv::{Candle, Tick}; pub use traits::{BatchExt, Chain, Indicator}; diff --git a/crates/wickra/benches/indicators.rs b/crates/wickra/benches/indicators.rs index 95c72dcf..47e4d489 100644 --- a/crates/wickra/benches/indicators.rs +++ b/crates/wickra/benches/indicators.rs @@ -20,13 +20,14 @@ use criterion::{criterion_group, criterion_main, BenchmarkId, Criterion, Through use std::hint::black_box; use wickra::{ AccelerationBands, AdOscillator, Adxr, Alma, AnchoredVwap, Atr, AtrBands, BatchExt, - BollingerBands, Candle, DemandIndex, DonchianStop, DoubleBollinger, Ema, FractalChaosBands, - Frama, GarmanKlassVolatility, HiLoActivator, HurstChannel, Indicator, Jma, Kst, Kvo, - LinRegChannel, MaEnvelope, MacdIndicator, MarketFacilitationIndex, McGinleyDynamic, Nvi, Obv, - ParkinsonVolatility, PercentageTrailingStop, Pgo, Pvi, RenkoTrailingStop, - RogersSatchellVolatility, Rsi, Rvi, RviVolatility, Rwi, Sma, StandardErrorBands, StarcBands, - StepTrailingStop, Stochastic, Tii, Tsv, TtmSqueeze, Vidya, VoltyStop, VolumeOscillator, - VwapStdDevBands, Vzo, WaveTrend, Wma, YangZhangVolatility, YoyoExit, + BollingerBands, Camarilla, Candle, ClassicPivots, DemandIndex, DemarkPivots, DonchianStop, + DoubleBollinger, Ema, FibonacciPivots, FractalChaosBands, Frama, GarmanKlassVolatility, + HiLoActivator, HurstChannel, Indicator, Jma, Kst, Kvo, LinRegChannel, MaEnvelope, + MacdIndicator, MarketFacilitationIndex, McGinleyDynamic, Nvi, Obv, ParkinsonVolatility, + PercentageTrailingStop, Pgo, Pvi, RenkoTrailingStop, RogersSatchellVolatility, Rsi, Rvi, + RviVolatility, Rwi, Sma, StandardErrorBands, StarcBands, StepTrailingStop, Stochastic, Tii, + Tsv, TtmSqueeze, Vidya, VoltyStop, VolumeOscillator, VwapStdDevBands, Vzo, WaveTrend, + WilliamsFractals, Wma, WoodiePivots, YangZhangVolatility, YoyoExit, ZigZag, }; use wickra_data::csv::CandleReader; @@ -182,6 +183,15 @@ fn benches(c: &mut Criterion) { bench_candle_input(c, "stochastic", &candles, Stochastic::classic); bench_candle_input(c, "obv", &candles, Obv::new); + // --- Family 08: Pivots & Support/Resistance --- + bench_candle_input(c, "classic_pivots", &candles, ClassicPivots::new); + bench_candle_input(c, "fibonacci_pivots", &candles, FibonacciPivots::new); + bench_candle_input(c, "camarilla", &candles, Camarilla::new); + bench_candle_input(c, "woodie_pivots", &candles, WoodiePivots::new); + bench_candle_input(c, "demark_pivots", &candles, DemarkPivots::new); + bench_candle_input(c, "williams_fractals", &candles, WilliamsFractals::new); + bench_candle_input(c, "zig_zag", &candles, || ZigZag::new(0.05).unwrap()); + // --- Family 09: Trailing Stops --- bench_candle_input(c, "hilo_activator", &candles, HiLoActivator::classic); bench_candle_input(c, "volty_stop", &candles, VoltyStop::classic); diff --git a/fuzz/fuzz_targets/indicator_update_candle.rs b/fuzz/fuzz_targets/indicator_update_candle.rs index 98d9264c..70ace913 100644 --- a/fuzz/fuzz_targets/indicator_update_candle.rs +++ b/fuzz/fuzz_targets/indicator_update_candle.rs @@ -25,15 +25,16 @@ use libfuzzer_sys::fuzz_target; use wickra_core::{ AccelerationBands, AcceleratorOscillator, AdOscillator, Adl, Adx, Adxr, Alligator, AnchoredVwap, Aroon, AroonOscillator, Atr, AtrBands, AtrTrailingStop, AwesomeOscillator, - AwesomeOscillatorHistogram, BalanceOfPower, BatchExt, Candle, Cci, ChaikinMoneyFlow, + AwesomeOscillatorHistogram, BalanceOfPower, BatchExt, Camarilla, Candle, Cci, ChaikinMoneyFlow, ChaikinOscillator, ChaikinVolatility, ChandeKrollStop, ChandelierExit, ChoppinessIndex, - DemandIndex, Donchian, DonchianStop, EaseOfMovement, Evwma, ForceIndex, FractalChaosBands, - GarmanKlassVolatility, HiLoActivator, HurstChannel, Indicator, Inertia, Keltner, Kvo, - MarketFacilitationIndex, MassIndex, MedianPrice, Mfi, Natr, Nvi, Obv, ParkinsonVolatility, Pgo, - Psar, Pvi, RogersSatchellVolatility, RollingVwap, Rvi, Rwi, Smi, StarcBands, Stochastic, - SuperTrend, TrueRange, Tsv, TtmSqueeze, TypicalPrice, UltimateOscillator, VoltyStop, - VolumeOscillator, VolumePriceTrend, Vortex, Vwap, VwapStdDevBands, Vwma, Vzo, WaveTrend, - WeightedClose, WilliamsR, YangZhangVolatility, YoyoExit, + ClassicPivots, DemandIndex, DemarkPivots, Donchian, DonchianStop, EaseOfMovement, Evwma, + FibonacciPivots, ForceIndex, FractalChaosBands, GarmanKlassVolatility, HiLoActivator, + HurstChannel, Indicator, Inertia, Keltner, Kvo, MarketFacilitationIndex, MassIndex, MedianPrice, + Mfi, Natr, Nvi, Obv, ParkinsonVolatility, Pgo, Psar, Pvi, RogersSatchellVolatility, RollingVwap, + Rvi, Rwi, Smi, StarcBands, Stochastic, SuperTrend, TrueRange, Tsv, TtmSqueeze, TypicalPrice, + UltimateOscillator, VoltyStop, VolumeOscillator, VolumePriceTrend, Vortex, Vwap, VwapStdDevBands, + Vwma, Vzo, WaveTrend, WeightedClose, WilliamsFractals, WilliamsR, WoodiePivots, + YangZhangVolatility, YoyoExit, ZigZag, }; /// Convert a flat `f64` stream into a `Vec` by chunking it into @@ -163,6 +164,15 @@ fuzz_target!(|data: Vec| { let _ = Stochastic::new(14, 3).unwrap().batch(&candles); } + // --- Pivots & Support/Resistance (multi-output) --- + drive(ClassicPivots::new, &candles); + drive(FibonacciPivots::new, &candles); + drive(Camarilla::new, &candles); + drive(WoodiePivots::new, &candles); + drive(DemarkPivots::new, &candles); + drive(WilliamsFractals::new, &candles); + drive(|| ZigZag::new(0.05).unwrap(), &candles); + // --- Donchian Stop (multi-output) --- { let mut s = DonchianStop::new(10).unwrap();