From 54194a4ff8c0588dd3b3cb69c9dbe9dc8d438208 Mon Sep 17 00:00:00 2001 From: kingchenc Date: Mon, 25 May 2026 18:37:12 +0200 Subject: [PATCH] feat: Family 05 Bands & Channels - 11 new price-envelope indicators (#43) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(bands-channels): add Family 05 with 11 indicators Eleven price-envelope overlays organised into a new "Bands & Channels" family, exposed across all four bindings (Rust core, Python, Node, WASM) plus fuzz/test/bench/docs coverage: - MaEnvelope - SMA centerline with fixed-percent envelope (the oldest band overlay still in regular use). - AccelerationBands (Price Headley) - momentum-biased bands that widen with the bar's relative range (H - L) / (H + L). - StarcBands (Stoller Average Range Channel) - SMA(close) +/- k*ATR; Keltner's SMA-centerline sibling. - AtrBands - close-anchored envelope of width k*ATR; the standard volatility-targeting stop/target band. - HurstChannel - SMA centerline wrapped by the rolling high-low range (Brian Millard / Hurst-cycle channel). - LinRegChannel - rolling OLS endpoint +/- k * population stddev of the residuals; dispersion about the trend rather than the mean. - StandardErrorBands - regression line +/- k * OLS standard error (denominator n - 2) for prediction-interval bands. - DoubleBollinger (Kathy Lien) - two concentric BB envelopes (typically +/- 1 sigma and +/- 2 sigma) for the zone-partition setup. - TtmSqueeze (John Carter) - BB-inside-KC squeeze flag paired with a detrended-close linear-regression momentum reading. - FractalChaosBands - Bill Williams 5-bar fractal high/low envelope. - VwapStdDevBands - cumulative VWAP with volume-weighted population standard deviation bands. Each indicator ships: - Core impl with the full Indicator trait, classic() where applicable, and unit tests (rejects_zero_period / multiplier, accessors, flat market, monotonic ordering, batch == streaming, reset, plus algebraically verifiable reference values). - Python PyO3 binding with multi-column NumPy batch (PyArray2). - Node napi binding with #[napi(object)] struct + interleaved flat batch. - WASM wasm-bindgen binding via Object/Reflect for update + Float64Array for batch. - Fuzz coverage in fuzz_targets/indicator_update{,_candle}.rs. - Python streaming-vs-batch parametric test + reference test. - Node streaming-vs-interleaved-batch test + reference test. - Criterion microbench under crates/wickra/benches/indicators.rs. README family table, README indicator-count line, and CHANGELOG Unreleased entry updated: indicator total rises from 71 to 82 across nine families. Wiki pages are updated in a separate commit in the wickra.wiki repo. * test(acceleration-bands): cover sum_hl==0 zero-price guard Exercises line 104 (`0.0` branch of the `sum_hl == 0.0` guard) which was the last patch-coverage miss on the family-05 PR. `Candle::new` accepts a fully-zero bar so the branch is reachable in principle — add a degenerate-candle unit test to hit it. --- CHANGELOG.md | 26 + README.md | 5 +- bindings/node/__tests__/indicators.test.js | 50 ++ bindings/node/index.js | 13 +- bindings/node/src/lib.rs | 759 ++++++++++++++++++ bindings/python/python/wickra/__init__.py | 24 + bindings/python/src/lib.rs | 738 +++++++++++++++++ bindings/python/tests/test_new_indicators.py | 202 +++++ bindings/wasm/src/lib.rs | 666 +++++++++++++++ .../src/indicators/acceleration_bands.rs | 277 +++++++ .../wickra-core/src/indicators/atr_bands.rs | 214 +++++ .../src/indicators/double_bollinger.rs | 261 ++++++ .../src/indicators/fractal_chaos_bands.rs | 271 +++++++ .../src/indicators/hurst_channel.rs | 236 ++++++ .../src/indicators/linreg_channel.rs | 266 ++++++ .../wickra-core/src/indicators/ma_envelope.rs | 216 +++++ crates/wickra-core/src/indicators/mod.rs | 22 + .../src/indicators/standard_error_bands.rs | 278 +++++++ .../wickra-core/src/indicators/starc_bands.rs | 229 ++++++ .../wickra-core/src/indicators/ttm_squeeze.rs | 302 +++++++ .../src/indicators/vwap_stddev_bands.rs | 251 ++++++ crates/wickra-core/src/lib.rs | 32 +- crates/wickra/benches/indicators.rs | 68 +- fuzz/fuzz_targets/indicator_update.rs | 41 +- fuzz/fuzz_targets/indicator_update_candle.rs | 68 +- 25 files changed, 5482 insertions(+), 33 deletions(-) create mode 100644 crates/wickra-core/src/indicators/acceleration_bands.rs create mode 100644 crates/wickra-core/src/indicators/atr_bands.rs create mode 100644 crates/wickra-core/src/indicators/double_bollinger.rs create mode 100644 crates/wickra-core/src/indicators/fractal_chaos_bands.rs create mode 100644 crates/wickra-core/src/indicators/hurst_channel.rs create mode 100644 crates/wickra-core/src/indicators/linreg_channel.rs create mode 100644 crates/wickra-core/src/indicators/ma_envelope.rs create mode 100644 crates/wickra-core/src/indicators/standard_error_bands.rs create mode 100644 crates/wickra-core/src/indicators/starc_bands.rs create mode 100644 crates/wickra-core/src/indicators/ttm_squeeze.rs create mode 100644 crates/wickra-core/src/indicators/vwap_stddev_bands.rs diff --git a/CHANGELOG.md b/CHANGELOG.md index 7d105e54..aae16b9d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,32 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] ### Added +- **Family 05 — Bands & Channels (11 new indicators).** Eleven additional + price-envelope overlays organised into the new "Bands & Channels" + family, exposed across all four bindings (Rust, Python, Node, WASM): + - `MaEnvelope` — SMA centerline with fixed-percent envelope (the oldest + band overlay still in use). + - `AccelerationBands` — Price Headley's momentum-biased bands that widen + with the bar's relative range `(H − L) / (H + L)`. + - `StarcBands` — Stoller Average Range Channel: SMA(close) ± k·ATR + (Keltner's SMA-centerline sibling). + - `AtrBands` — Close-anchored envelope of width `k · ATR`, the standard + volatility-targeting stop/target band. + - `HurstChannel` — SMA centerline wrapped by the rolling high-low range + (Brian Millard / Hurst-cycle channel). + - `LinRegChannel` — Linear-regression endpoint ± k·σ of the residuals, + measuring dispersion about the *trend* rather than the mean. + - `StandardErrorBands` — Linear regression with the OLS standard error + (denominator `n − 2`) for prediction-interval bands. + - `DoubleBollinger` — Kathy Lien's `±1σ` plus `±2σ` zone-partition setup. + - `TtmSqueeze` — John Carter's BB-inside-KC squeeze flag paired with a + detrended-close momentum reading. + - `FractalChaosBands` — Bill Williams 5-bar fractal high/low envelope. + - `VwapStdDevBands` — Cumulative VWAP with volume-weighted standard + deviation bands. + Indicator count rises from 71 to 82 across nine families; the README + family table and the wiki overview/sidebar/warmup pages were updated to + match. - **Yang-Zhang Volatility.** Yang & Zhang (2000) gold-standard OHLC estimator: a convex blend of overnight (close-to-open), open-to-close and Rogers-Satchell variances. The blending factor diff --git a/README.md b/README.md index 00406536..b6fc5d9e 100644 --- a/README.md +++ b/README.md @@ -109,7 +109,7 @@ python -m benchmarks.compare_libraries ## Indicators -96 streaming-first indicators across eight families. Every one passes the +107 streaming-first indicators across nine families. Every one passes the `batch == streaming` equivalence test, reference-value tests, and reset semantics tests. @@ -120,6 +120,7 @@ semantics tests. | Trend & Directional | MACD, ADX (+DI/-DI), Aroon, TRIX, Aroon Oscillator, Vortex, Mass Index, Choppiness Index, Vertical Horizontal Filter | | Price Oscillators | PPO, DPO, Coppock, Accelerator Oscillator, Balance of Power, APO, AO Histogram, CFO, Zero-Lag MACD, Elder Impulse, STC | | Volatility & Bands | ATR, Bollinger Bands, Keltner Channels, Donchian Channels, NATR, StdDev, Ulcer Index, Historical Volatility, Bollinger Bandwidth, %B, True Range, Chaikin Volatility, RVI (Relative Volatility Index), Parkinson Volatility, Garman-Klass Volatility, Rogers-Satchell Volatility, Yang-Zhang Volatility | +| Bands & Channels | MA Envelope, Acceleration Bands, STARC Bands, ATR Bands, Hurst Channel, LinReg Channel, Standard Error Bands, Double Bollinger Bands, TTM Squeeze, Fractal Chaos Bands, VWAP StdDev Bands | | Trailing Stops | Parabolic SAR, SuperTrend, Chandelier Exit, Chande Kroll Stop, ATR Trailing Stop | | Volume | OBV, VWAP (cumulative + rolling), ADL, Volume-Price Trend, Chaikin Money Flow, Chaikin Oscillator, Force Index, Ease of Movement | | Price Statistics | Typical Price, Median Price, Weighted Close, Linear Regression, Linear Regression Slope, Z-Score, Linear Regression Angle | @@ -195,7 +196,7 @@ A Python live-trading example using the public `websockets` package lives at ``` wickra/ ├── crates/ -│ ├── wickra-core/ core engine + all 96 indicators +│ ├── wickra-core/ core engine + all 107 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 e9d5917d..dd42f31a 100644 --- a/bindings/node/__tests__/indicators.test.js +++ b/bindings/node/__tests__/indicators.test.js @@ -158,6 +158,18 @@ const multi = { SuperTrend: { make: () => new wickra.SuperTrend(10, 3), fields: ['value', 'direction'], step: (ind, i) => ind.update(high[i], low[i], close[i]), batch: (ind) => ind.batch(high, low, close) }, ChandelierExit: { make: () => new wickra.ChandelierExit(22, 3), fields: ['longStop', 'shortStop'], step: (ind, i) => ind.update(high[i], low[i], close[i]), batch: (ind) => ind.batch(high, low, close) }, ChandeKrollStop: { make: () => new wickra.ChandeKrollStop(10, 1, 9), fields: ['stopLong', 'stopShort'], step: (ind, i) => ind.update(high[i], low[i], close[i]), batch: (ind) => ind.batch(high, low, close) }, + // Family 05: bands & channels + MaEnvelope: { make: () => new wickra.MaEnvelope(20, 0.025), fields: ['upper', 'middle', 'lower'], step: (ind, i) => ind.update(close[i]), batch: (ind) => ind.batch(close) }, + AccelerationBands: { make: () => new wickra.AccelerationBands(20, 0.001), fields: ['upper', 'middle', 'lower'], step: (ind, i) => ind.update(high[i], low[i], close[i]), batch: (ind) => ind.batch(high, low, close) }, + StarcBands: { make: () => new wickra.StarcBands(6, 15, 2), fields: ['upper', 'middle', 'lower'], step: (ind, i) => ind.update(high[i], low[i], close[i]), batch: (ind) => ind.batch(high, low, close) }, + AtrBands: { make: () => new wickra.AtrBands(14, 3), fields: ['upper', 'middle', 'lower'], step: (ind, i) => ind.update(high[i], low[i], close[i]), batch: (ind) => ind.batch(high, low, close) }, + HurstChannel: { make: () => new wickra.HurstChannel(10, 0.5), fields: ['upper', 'middle', 'lower'], step: (ind, i) => ind.update(high[i], low[i], close[i]), batch: (ind) => ind.batch(high, low, close) }, + LinRegChannel: { make: () => new wickra.LinRegChannel(20, 2), fields: ['upper', 'middle', 'lower'], step: (ind, i) => ind.update(close[i]), batch: (ind) => ind.batch(close) }, + StandardErrorBands: { make: () => new wickra.StandardErrorBands(21, 2), fields: ['upper', 'middle', 'lower'], step: (ind, i) => ind.update(close[i]), batch: (ind) => ind.batch(close) }, + DoubleBollinger: { make: () => new wickra.DoubleBollinger(20, 1, 2), fields: ['upperOuter', 'upperInner', 'middle', 'lowerInner', 'lowerOuter'], step: (ind, i) => ind.update(close[i]), batch: (ind) => ind.batch(close) }, + 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) }, }; for (const [name, d] of Object.entries(multi)) { @@ -284,6 +296,44 @@ test('LinRegAngle of a unit-slope series is 45 degrees', () => { assert.ok(Math.abs(out[4] - 45) < 1e-9); }); +test('MaEnvelope reference values', () => { + // SMA([10, 20, 30]) = 20; with percent 0.10: upper=22, lower=18. + const out = new wickra.MaEnvelope(3, 0.10).batch([10, 20, 30]); + assert.ok(Number.isNaN(out[0]) && Number.isNaN(out[3])); + assert.ok(Math.abs(out[2 * 3 + 0] - 22) < 1e-9); // upper + assert.ok(Math.abs(out[2 * 3 + 1] - 20) < 1e-9); // middle + assert.ok(Math.abs(out[2 * 3 + 2] - 18) < 1e-9); // lower +}); + +test('AccelerationBands single-bar reference', () => { + // high=12, low=8, close=10, factor=0.5, period=1. + // ratio=0.2, raw_up=13.2, raw_lo=7.2. + const v = new wickra.AccelerationBands(1, 0.5).update(12, 8, 10); + assert.ok(Math.abs(v.upper - 13.2) < 1e-9); + assert.ok(Math.abs(v.middle - 10) < 1e-9); + assert.ok(Math.abs(v.lower - 7.2) < 1e-9); +}); + +test('LinRegChannel reference values for [1, 2, 9]', () => { + // Line y=4x, endpoint=8, residuals=[1,-2,1], sigma=sqrt(2). + const out = new wickra.LinRegChannel(3, 2).batch([1, 2, 9]); + const s = Math.sqrt(2); + const i = 2; + assert.ok(Math.abs(out[i * 3 + 0] - (8 + 2 * s)) < 1e-9); + assert.ok(Math.abs(out[i * 3 + 1] - 8) < 1e-9); + assert.ok(Math.abs(out[i * 3 + 2] - (8 - 2 * s)) < 1e-9); +}); + +test('VwapStdDevBands two-bar reference', () => { + const v = new wickra.VwapStdDevBands(1.5); + v.update(8, 8, 8, 1); + const o = v.update(12, 12, 12, 1); + assert.ok(Math.abs(o.upper - 13) < 1e-9); + assert.ok(Math.abs(o.middle - 10) < 1e-9); + assert.ok(Math.abs(o.lower - 7) < 1e-9); + assert.ok(Math.abs(o.stddev - 2) < 1e-9); +}); + test('RVIVolatility pure uptrend saturates at 100', () => { const prices = Array.from({ length: 40 }, (_, i) => i + 1); const out = new wickra.RVIVolatility(5).batch(prices); diff --git a/bindings/node/index.js b/bindings/node/index.js index d6eb4f39..1f713e53 100644 --- a/bindings/node/index.js +++ b/bindings/node/index.js @@ -310,7 +310,7 @@ if (!nativeBinding) { throw new Error(`Failed to load native binding`) } -const { version, SMA, EMA, WMA, RSI, DEMA, TEMA, HMA, ROC, TRIX, SMMA, TRIMA, ZLEMA, MOM, CMO, DPO, StdDev, UlcerIndex, VerticalHorizontalFilter, ZScore, MACD, BollingerBands, ATR, Stochastic, OBV, ADX, CCI, WilliamsR, MFI, PSAR, Keltner, Donchian, VWAP, RollingVWAP, AwesomeOscillator, Aroon, KAMA, RVI, PGO, KST, SMI, LaguerreRSI, ConnorsRSI, Inertia, ALMA, McGinleyDynamic, FRAMA, VIDYA, JMA, Alligator, EVWMA, APO, AwesomeOscillatorHistogram, CFO, ZeroLagMACD, ElderImpulse, STC, T3, TSI, PMO, ADL, VolumePriceTrend, ChaikinMoneyFlow, ChaikinOscillator, ForceIndex, EaseOfMovement, SuperTrend, ChandelierExit, ChandeKrollStop, AtrTrailingStop, TypicalPrice, MedianPrice, WeightedClose, LinearRegression, LinRegSlope, AcceleratorOscillator, BalanceOfPower, ChoppinessIndex, TrueRange, ChaikinVolatility, LinRegAngle, BollingerBandwidth, PercentB, NATR, HistoricalVolatility, AroonOscillator, Vortex, MassIndex, StochRSI, UltimateOscillator, PPO, Coppock, VWMA, RVIVolatility, ParkinsonVolatility, GarmanKlassVolatility, RogersSatchellVolatility, YangZhangVolatility } = nativeBinding +const { version, SMA, EMA, WMA, RSI, DEMA, TEMA, HMA, ROC, TRIX, SMMA, TRIMA, ZLEMA, MOM, CMO, DPO, StdDev, UlcerIndex, VerticalHorizontalFilter, ZScore, MACD, BollingerBands, ATR, Stochastic, OBV, ADX, CCI, WilliamsR, MFI, PSAR, Keltner, Donchian, VWAP, RollingVWAP, AwesomeOscillator, Aroon, KAMA, RVI, PGO, KST, SMI, LaguerreRSI, ConnorsRSI, Inertia, ALMA, McGinleyDynamic, FRAMA, VIDYA, JMA, Alligator, EVWMA, APO, AwesomeOscillatorHistogram, CFO, ZeroLagMACD, ElderImpulse, STC, T3, TSI, PMO, ADL, VolumePriceTrend, ChaikinMoneyFlow, ChaikinOscillator, ForceIndex, EaseOfMovement, SuperTrend, ChandelierExit, ChandeKrollStop, AtrTrailingStop, TypicalPrice, MedianPrice, WeightedClose, LinearRegression, LinRegSlope, AcceleratorOscillator, BalanceOfPower, ChoppinessIndex, TrueRange, ChaikinVolatility, LinRegAngle, BollingerBandwidth, PercentB, NATR, HistoricalVolatility, AroonOscillator, Vortex, MassIndex, StochRSI, UltimateOscillator, PPO, Coppock, VWMA, RVIVolatility, ParkinsonVolatility, GarmanKlassVolatility, RogersSatchellVolatility, YangZhangVolatility, MaEnvelope, AccelerationBands, StarcBands, AtrBands, HurstChannel, LinRegChannel, StandardErrorBands, DoubleBollinger, TtmSqueeze, FractalChaosBands, VwapStdDevBands } = nativeBinding module.exports.version = version module.exports.SMA = SMA @@ -410,3 +410,14 @@ module.exports.ParkinsonVolatility = ParkinsonVolatility module.exports.GarmanKlassVolatility = GarmanKlassVolatility module.exports.RogersSatchellVolatility = RogersSatchellVolatility module.exports.YangZhangVolatility = YangZhangVolatility +module.exports.MaEnvelope = MaEnvelope +module.exports.AccelerationBands = AccelerationBands +module.exports.StarcBands = StarcBands +module.exports.AtrBands = AtrBands +module.exports.HurstChannel = HurstChannel +module.exports.LinRegChannel = LinRegChannel +module.exports.StandardErrorBands = StandardErrorBands +module.exports.DoubleBollinger = DoubleBollinger +module.exports.TtmSqueeze = TtmSqueeze +module.exports.FractalChaosBands = FractalChaosBands +module.exports.VwapStdDevBands = VwapStdDevBands diff --git a/bindings/node/src/lib.rs b/bindings/node/src/lib.rs index e4261318..fdf493a5 100644 --- a/bindings/node/src/lib.rs +++ b/bindings/node/src/lib.rs @@ -4131,3 +4131,762 @@ impl VwmaNode { self.inner.warmup_period() as u32 } } + +// ============================== Family 05: Bands & Channels ============================== + +// ---------- MA Envelope ---------- + +#[napi(object)] +pub struct MaEnvelopeValue { + pub upper: f64, + pub middle: f64, + pub lower: f64, +} + +#[napi(js_name = "MaEnvelope")] +pub struct MaEnvelopeNode { + inner: wc::MaEnvelope, +} + +#[napi] +impl MaEnvelopeNode { + #[napi(constructor)] + pub fn new(period: u32, percent: f64) -> napi::Result { + Ok(Self { + inner: wc::MaEnvelope::new(period as usize, percent).map_err(map_err)?, + }) + } + #[napi] + pub fn update(&mut self, value: f64) -> Option { + self.inner.update(value).map(|o| MaEnvelopeValue { + upper: o.upper, + middle: o.middle, + lower: o.lower, + }) + } + /// Flat `[upper0, middle0, lower0, upper1, ...]`, length `3 * n`. + #[napi] + pub fn batch(&mut self, prices: Vec) -> Vec { + let mut out = vec![f64::NAN; prices.len() * 3]; + for (i, p) in prices.iter().enumerate() { + if let Some(o) = self.inner.update(*p) { + out[i * 3] = o.upper; + out[i * 3 + 1] = o.middle; + out[i * 3 + 2] = o.lower; + } + } + 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 + } +} + +// ---------- Acceleration Bands ---------- + +#[napi(object)] +pub struct AccelerationBandsValue { + pub upper: f64, + pub middle: f64, + pub lower: f64, +} + +#[napi(js_name = "AccelerationBands")] +pub struct AccelerationBandsNode { + inner: wc::AccelerationBands, +} + +#[napi] +impl AccelerationBandsNode { + #[napi(constructor)] + pub fn new(period: u32, factor: f64) -> napi::Result { + Ok(Self { + inner: wc::AccelerationBands::new(period as usize, factor).map_err(map_err)?, + }) + } + #[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| AccelerationBandsValue { + upper: o.upper, + middle: o.middle, + lower: o.lower, + })) + } + #[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 * 3]; + for i in 0..n { + if let Some(o) = self.inner.update(cnd(high[i], low[i], close[i], 0.0)?) { + out[i * 3] = o.upper; + out[i * 3 + 1] = o.middle; + out[i * 3 + 2] = o.lower; + } + } + 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 + } +} + +// ---------- STARC Bands ---------- + +#[napi(object)] +pub struct StarcBandsValue { + pub upper: f64, + pub middle: f64, + pub lower: f64, +} + +#[napi(js_name = "StarcBands")] +pub struct StarcBandsNode { + inner: wc::StarcBands, +} + +#[napi] +impl StarcBandsNode { + #[napi(constructor)] + pub fn new(sma_period: u32, atr_period: u32, multiplier: f64) -> napi::Result { + Ok(Self { + inner: wc::StarcBands::new(sma_period as usize, atr_period as usize, multiplier) + .map_err(map_err)?, + }) + } + #[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| StarcBandsValue { + upper: o.upper, + middle: o.middle, + lower: o.lower, + })) + } + #[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 * 3]; + for i in 0..n { + if let Some(o) = self.inner.update(cnd(high[i], low[i], close[i], 0.0)?) { + out[i * 3] = o.upper; + out[i * 3 + 1] = o.middle; + out[i * 3 + 2] = o.lower; + } + } + 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 + } +} + +// ---------- ATR Bands ---------- + +#[napi(object)] +pub struct AtrBandsValue { + pub upper: f64, + pub middle: f64, + pub lower: f64, +} + +#[napi(js_name = "AtrBands")] +pub struct AtrBandsNode { + inner: wc::AtrBands, +} + +#[napi] +impl AtrBandsNode { + #[napi(constructor)] + pub fn new(period: u32, multiplier: f64) -> napi::Result { + Ok(Self { + inner: wc::AtrBands::new(period as usize, multiplier).map_err(map_err)?, + }) + } + #[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| AtrBandsValue { + upper: o.upper, + middle: o.middle, + lower: o.lower, + })) + } + #[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 * 3]; + for i in 0..n { + if let Some(o) = self.inner.update(cnd(high[i], low[i], close[i], 0.0)?) { + out[i * 3] = o.upper; + out[i * 3 + 1] = o.middle; + out[i * 3 + 2] = o.lower; + } + } + 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 + } +} + +// ---------- Hurst Channel ---------- + +#[napi(object)] +pub struct HurstChannelValue { + pub upper: f64, + pub middle: f64, + pub lower: f64, +} + +#[napi(js_name = "HurstChannel")] +pub struct HurstChannelNode { + inner: wc::HurstChannel, +} + +#[napi] +impl HurstChannelNode { + #[napi(constructor)] + pub fn new(period: u32, multiplier: f64) -> napi::Result { + Ok(Self { + inner: wc::HurstChannel::new(period as usize, multiplier).map_err(map_err)?, + }) + } + #[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| HurstChannelValue { + upper: o.upper, + middle: o.middle, + lower: o.lower, + })) + } + #[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 * 3]; + for i in 0..n { + if let Some(o) = self.inner.update(cnd(high[i], low[i], close[i], 0.0)?) { + out[i * 3] = o.upper; + out[i * 3 + 1] = o.middle; + out[i * 3 + 2] = o.lower; + } + } + 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 + } +} + +// ---------- LinReg Channel ---------- + +#[napi(object)] +pub struct LinRegChannelValue { + pub upper: f64, + pub middle: f64, + pub lower: f64, +} + +#[napi(js_name = "LinRegChannel")] +pub struct LinRegChannelNode { + inner: wc::LinRegChannel, +} + +#[napi] +impl LinRegChannelNode { + #[napi(constructor)] + pub fn new(period: u32, multiplier: f64) -> napi::Result { + Ok(Self { + inner: wc::LinRegChannel::new(period as usize, multiplier).map_err(map_err)?, + }) + } + #[napi] + pub fn update(&mut self, value: f64) -> Option { + self.inner.update(value).map(|o| LinRegChannelValue { + upper: o.upper, + middle: o.middle, + lower: o.lower, + }) + } + #[napi] + pub fn batch(&mut self, prices: Vec) -> Vec { + let mut out = vec![f64::NAN; prices.len() * 3]; + for (i, p) in prices.iter().enumerate() { + if let Some(o) = self.inner.update(*p) { + out[i * 3] = o.upper; + out[i * 3 + 1] = o.middle; + out[i * 3 + 2] = o.lower; + } + } + 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 + } +} + +// ---------- Standard Error Bands ---------- + +#[napi(object)] +pub struct StandardErrorBandsValue { + pub upper: f64, + pub middle: f64, + pub lower: f64, +} + +#[napi(js_name = "StandardErrorBands")] +pub struct StandardErrorBandsNode { + inner: wc::StandardErrorBands, +} + +#[napi] +impl StandardErrorBandsNode { + #[napi(constructor)] + pub fn new(period: u32, multiplier: f64) -> napi::Result { + Ok(Self { + inner: wc::StandardErrorBands::new(period as usize, multiplier).map_err(map_err)?, + }) + } + #[napi] + pub fn update(&mut self, value: f64) -> Option { + self.inner.update(value).map(|o| StandardErrorBandsValue { + upper: o.upper, + middle: o.middle, + lower: o.lower, + }) + } + #[napi] + pub fn batch(&mut self, prices: Vec) -> Vec { + let mut out = vec![f64::NAN; prices.len() * 3]; + for (i, p) in prices.iter().enumerate() { + if let Some(o) = self.inner.update(*p) { + out[i * 3] = o.upper; + out[i * 3 + 1] = o.middle; + out[i * 3 + 2] = o.lower; + } + } + 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 + } +} + +// ---------- Double Bollinger ---------- + +#[napi(object)] +pub struct DoubleBollingerValue { + #[napi(js_name = "upperOuter")] + pub upper_outer: f64, + #[napi(js_name = "upperInner")] + pub upper_inner: f64, + pub middle: f64, + #[napi(js_name = "lowerInner")] + pub lower_inner: f64, + #[napi(js_name = "lowerOuter")] + pub lower_outer: f64, +} + +#[napi(js_name = "DoubleBollinger")] +pub struct DoubleBollingerNode { + inner: wc::DoubleBollinger, +} + +#[napi] +impl DoubleBollingerNode { + #[napi(constructor)] + pub fn new(period: u32, k_inner: f64, k_outer: f64) -> napi::Result { + Ok(Self { + inner: wc::DoubleBollinger::new(period as usize, k_inner, k_outer).map_err(map_err)?, + }) + } + #[napi] + pub fn update(&mut self, value: f64) -> Option { + self.inner.update(value).map(|o| DoubleBollingerValue { + upper_outer: o.upper_outer, + upper_inner: o.upper_inner, + middle: o.middle, + lower_inner: o.lower_inner, + lower_outer: o.lower_outer, + }) + } + /// Flat `[u_o, u_i, m, l_i, l_o, ...]`, length `5 * n`. + #[napi] + pub fn batch(&mut self, prices: Vec) -> Vec { + let mut out = vec![f64::NAN; prices.len() * 5]; + for (i, p) in prices.iter().enumerate() { + if let Some(o) = self.inner.update(*p) { + out[i * 5] = o.upper_outer; + out[i * 5 + 1] = o.upper_inner; + out[i * 5 + 2] = o.middle; + out[i * 5 + 3] = o.lower_inner; + out[i * 5 + 4] = o.lower_outer; + } + } + 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 + } +} + +// ---------- TTM Squeeze ---------- + +#[napi(object)] +pub struct TtmSqueezeValue { + pub squeeze: f64, + pub momentum: f64, +} + +#[napi(js_name = "TtmSqueeze")] +pub struct TtmSqueezeNode { + inner: wc::TtmSqueeze, +} + +#[napi] +impl TtmSqueezeNode { + #[napi(constructor)] + pub fn new(period: u32, bb_mult: f64, kc_mult: f64) -> napi::Result { + Ok(Self { + inner: wc::TtmSqueeze::new(period as usize, bb_mult, kc_mult).map_err(map_err)?, + }) + } + #[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| TtmSqueezeValue { + squeeze: o.squeeze, + momentum: o.momentum, + })) + } + /// Flat `[sq0, mom0, sq1, mom1, ...]`, length `2 * n`. + #[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 * 2]; + for i in 0..n { + if let Some(o) = self.inner.update(cnd(high[i], low[i], close[i], 0.0)?) { + out[i * 2] = o.squeeze; + out[i * 2 + 1] = o.momentum; + } + } + 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 + } +} + +// ---------- Fractal Chaos Bands ---------- + +#[napi(object)] +pub struct FractalChaosBandsValue { + pub upper: f64, + pub lower: f64, +} + +#[napi(js_name = "FractalChaosBands")] +pub struct FractalChaosBandsNode { + inner: wc::FractalChaosBands, +} + +#[napi] +impl FractalChaosBandsNode { + #[napi(constructor)] + pub fn new(k: u32) -> napi::Result { + Ok(Self { + inner: wc::FractalChaosBands::new(k as usize).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| FractalChaosBandsValue { + upper: o.upper, + lower: o.lower, + })) + } + /// Flat `[u0, l0, u1, l1, ...]`, length `2 * n`. + #[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.upper; + out[i * 2 + 1] = o.lower; + } + } + 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 + } +} + +// ---------- VWAP StdDev Bands ---------- + +#[napi(object)] +pub struct VwapStdDevBandsValue { + pub upper: f64, + pub middle: f64, + pub lower: f64, + pub stddev: f64, +} + +#[napi(js_name = "VwapStdDevBands")] +pub struct VwapStdDevBandsNode { + inner: wc::VwapStdDevBands, +} + +#[napi] +impl VwapStdDevBandsNode { + #[napi(constructor)] + pub fn new(multiplier: f64) -> napi::Result { + Ok(Self { + inner: wc::VwapStdDevBands::new(multiplier).map_err(map_err)?, + }) + } + #[napi] + pub fn update( + &mut self, + high: f64, + low: f64, + close: f64, + volume: f64, + ) -> napi::Result> { + Ok(self + .inner + .update(cnd(high, low, close, volume)?) + .map(|o| VwapStdDevBandsValue { + upper: o.upper, + middle: o.middle, + lower: o.lower, + stddev: o.stddev, + })) + } + /// Flat `[u0, m0, l0, sd0, ...]`, length `4 * n`. + #[napi] + pub fn batch( + &mut self, + high: Vec, + low: Vec, + close: Vec, + volume: Vec, + ) -> napi::Result> { + let n = high.len(); + if low.len() != n || close.len() != n || volume.len() != n { + return Err(NapiError::from_reason( + "high, low, close, volume must be equal length".to_string(), + )); + } + let mut out = vec![f64::NAN; n * 4]; + for i in 0..n { + if let Some(o) = self + .inner + .update(cnd(high[i], low[i], close[i], volume[i])?) + { + out[i * 4] = o.upper; + out[i * 4 + 1] = o.middle; + out[i * 4 + 2] = o.lower; + out[i * 4 + 3] = o.stddev; + } + } + 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 + } +} diff --git a/bindings/python/python/wickra/__init__.py b/bindings/python/python/wickra/__init__.py index a40a2cc8..d437508c 100644 --- a/bindings/python/python/wickra/__init__.py +++ b/bindings/python/python/wickra/__init__.py @@ -127,6 +127,18 @@ from ._wickra import ( LinRegSlope, ZScore, LinRegAngle, + # Bands & Channels + MaEnvelope, + AccelerationBands, + StarcBands, + AtrBands, + HurstChannel, + LinRegChannel, + StandardErrorBands, + DoubleBollinger, + TtmSqueeze, + FractalChaosBands, + VwapStdDevBands, ) __all__ = [ @@ -233,4 +245,16 @@ __all__ = [ "LinRegSlope", "ZScore", "LinRegAngle", + # Bands & Channels + "MaEnvelope", + "AccelerationBands", + "StarcBands", + "AtrBands", + "HurstChannel", + "LinRegChannel", + "StandardErrorBands", + "DoubleBollinger", + "TtmSqueeze", + "FractalChaosBands", + "VwapStdDevBands", ] diff --git a/bindings/python/src/lib.rs b/bindings/python/src/lib.rs index 88ce9b60..a5473e8d 100644 --- a/bindings/python/src/lib.rs +++ b/bindings/python/src/lib.rs @@ -5949,9 +5949,736 @@ impl PyRviVolatility { } } +// ============================== MA Envelope ============================== + +#[pyclass(name = "MaEnvelope", module = "wickra._wickra", skip_from_py_object)] +#[derive(Clone)] +struct PyMaEnvelope { + inner: wc::MaEnvelope, +} + +#[pymethods] +impl PyMaEnvelope { + #[new] + #[pyo3(signature = (period=20, percent=0.025))] + fn new(period: usize, percent: f64) -> PyResult { + Ok(Self { + inner: wc::MaEnvelope::new(period, percent).map_err(map_err)?, + }) + } + /// Returns `(upper, middle, lower)` or `None` during warmup. + fn update(&mut self, value: f64) -> Option<(f64, f64, f64)> { + self.inner + .update(value) + .map(|o| (o.upper, o.middle, o.lower)) + } + /// Batch returns shape `(n, 3)` columns `[upper, middle, lower]`. + fn batch<'py>( + &mut self, + py: Python<'py>, + prices: PyReadonlyArray1<'py, f64>, + ) -> PyResult>> { + let slice = prices + .as_slice() + .map_err(|_| PyValueError::new_err(NON_CONTIGUOUS))?; + let n = slice.len(); + let mut out = vec![f64::NAN; n * 3]; + for (i, p) in slice.iter().enumerate() { + if let Some(o) = self.inner.update(*p) { + out[i * 3] = o.upper; + out[i * 3 + 1] = o.middle; + out[i * 3 + 2] = o.lower; + } + } + 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() + } +} + +// ============================== Acceleration Bands ============================== + +#[pyclass( + name = "AccelerationBands", + module = "wickra._wickra", + skip_from_py_object +)] +#[derive(Clone)] +struct PyAccelerationBands { + inner: wc::AccelerationBands, +} + +#[pymethods] +impl PyAccelerationBands { + #[new] + #[pyo3(signature = (period=20, factor=0.001))] + fn new(period: usize, factor: f64) -> PyResult { + Ok(Self { + inner: wc::AccelerationBands::new(period, factor).map_err(map_err)?, + }) + } + fn update(&mut self, candle: &Bound<'_, PyAny>) -> PyResult> { + let c = extract_candle(candle)?; + Ok(self.inner.update(c).map(|o| (o.upper, o.middle, o.lower))) + } + 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 * 3]; + 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 * 3] = o.upper; + out[i * 3 + 1] = o.middle; + out[i * 3 + 2] = o.lower; + } + } + 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() + } +} + +// ============================== STARC Bands ============================== + +#[pyclass(name = "StarcBands", module = "wickra._wickra", skip_from_py_object)] +#[derive(Clone)] +struct PyStarcBands { + inner: wc::StarcBands, +} + +#[pymethods] +impl PyStarcBands { + #[new] + #[pyo3(signature = (sma_period=6, atr_period=15, multiplier=2.0))] + fn new(sma_period: usize, atr_period: usize, multiplier: f64) -> PyResult { + Ok(Self { + inner: wc::StarcBands::new(sma_period, atr_period, multiplier).map_err(map_err)?, + }) + } + fn update(&mut self, candle: &Bound<'_, PyAny>) -> PyResult> { + let c = extract_candle(candle)?; + Ok(self.inner.update(c).map(|o| (o.upper, o.middle, o.lower))) + } + 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 * 3]; + 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 * 3] = o.upper; + out[i * 3 + 1] = o.middle; + out[i * 3 + 2] = o.lower; + } + } + 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() + } +} + +// ============================== ATR Bands ============================== + +#[pyclass(name = "AtrBands", module = "wickra._wickra", skip_from_py_object)] +#[derive(Clone)] +struct PyAtrBands { + inner: wc::AtrBands, +} + +#[pymethods] +impl PyAtrBands { + #[new] + #[pyo3(signature = (period=14, multiplier=3.0))] + fn new(period: usize, multiplier: f64) -> PyResult { + Ok(Self { + inner: wc::AtrBands::new(period, multiplier).map_err(map_err)?, + }) + } + fn update(&mut self, candle: &Bound<'_, PyAny>) -> PyResult> { + let c = extract_candle(candle)?; + Ok(self.inner.update(c).map(|o| (o.upper, o.middle, o.lower))) + } + 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 * 3]; + 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 * 3] = o.upper; + out[i * 3 + 1] = o.middle; + out[i * 3 + 2] = o.lower; + } + } + 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() + } +} + +// ============================== Hurst Channel ============================== + +#[pyclass(name = "HurstChannel", module = "wickra._wickra", skip_from_py_object)] +#[derive(Clone)] +struct PyHurstChannel { + inner: wc::HurstChannel, +} + +#[pymethods] +impl PyHurstChannel { + #[new] + #[pyo3(signature = (period=10, multiplier=0.5))] + fn new(period: usize, multiplier: f64) -> PyResult { + Ok(Self { + inner: wc::HurstChannel::new(period, multiplier).map_err(map_err)?, + }) + } + fn update(&mut self, candle: &Bound<'_, PyAny>) -> PyResult> { + let c = extract_candle(candle)?; + Ok(self.inner.update(c).map(|o| (o.upper, o.middle, o.lower))) + } + 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 * 3]; + 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 * 3] = o.upper; + out[i * 3 + 1] = o.middle; + out[i * 3 + 2] = o.lower; + } + } + 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() + } +} + +// ============================== LinReg Channel ============================== + +#[pyclass(name = "LinRegChannel", module = "wickra._wickra", skip_from_py_object)] +#[derive(Clone)] +struct PyLinRegChannel { + inner: wc::LinRegChannel, +} + +#[pymethods] +impl PyLinRegChannel { + #[new] + #[pyo3(signature = (period=20, multiplier=2.0))] + fn new(period: usize, multiplier: f64) -> PyResult { + Ok(Self { + inner: wc::LinRegChannel::new(period, multiplier).map_err(map_err)?, + }) + } + fn update(&mut self, value: f64) -> Option<(f64, f64, f64)> { + self.inner + .update(value) + .map(|o| (o.upper, o.middle, o.lower)) + } + fn batch<'py>( + &mut self, + py: Python<'py>, + prices: PyReadonlyArray1<'py, f64>, + ) -> PyResult>> { + let slice = prices + .as_slice() + .map_err(|_| PyValueError::new_err(NON_CONTIGUOUS))?; + let n = slice.len(); + let mut out = vec![f64::NAN; n * 3]; + for (i, p) in slice.iter().enumerate() { + if let Some(o) = self.inner.update(*p) { + out[i * 3] = o.upper; + out[i * 3 + 1] = o.middle; + out[i * 3 + 2] = o.lower; + } + } + 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() + } +} + +// ============================== Standard Error Bands ============================== + +#[pyclass( + name = "StandardErrorBands", + module = "wickra._wickra", + skip_from_py_object +)] +#[derive(Clone)] +struct PyStandardErrorBands { + inner: wc::StandardErrorBands, +} + +#[pymethods] +impl PyStandardErrorBands { + #[new] + #[pyo3(signature = (period=21, multiplier=2.0))] + fn new(period: usize, multiplier: f64) -> PyResult { + Ok(Self { + inner: wc::StandardErrorBands::new(period, multiplier).map_err(map_err)?, + }) + } + fn update(&mut self, value: f64) -> Option<(f64, f64, f64)> { + self.inner + .update(value) + .map(|o| (o.upper, o.middle, o.lower)) + } + fn batch<'py>( + &mut self, + py: Python<'py>, + prices: PyReadonlyArray1<'py, f64>, + ) -> PyResult>> { + let slice = prices + .as_slice() + .map_err(|_| PyValueError::new_err(NON_CONTIGUOUS))?; + let n = slice.len(); + let mut out = vec![f64::NAN; n * 3]; + for (i, p) in slice.iter().enumerate() { + if let Some(o) = self.inner.update(*p) { + out[i * 3] = o.upper; + out[i * 3 + 1] = o.middle; + out[i * 3 + 2] = o.lower; + } + } + 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() + } +} + +// ============================== Double Bollinger ============================== + +#[pyclass( + name = "DoubleBollinger", + module = "wickra._wickra", + skip_from_py_object +)] +#[derive(Clone)] +struct PyDoubleBollinger { + inner: wc::DoubleBollinger, +} + +#[pymethods] +impl PyDoubleBollinger { + #[new] + #[pyo3(signature = (period=20, k_inner=1.0, k_outer=2.0))] + fn new(period: usize, k_inner: f64, k_outer: f64) -> PyResult { + Ok(Self { + inner: wc::DoubleBollinger::new(period, k_inner, k_outer).map_err(map_err)?, + }) + } + /// Returns `(upper_outer, upper_inner, middle, lower_inner, lower_outer)`. + fn update(&mut self, value: f64) -> Option<(f64, f64, f64, f64, f64)> { + self.inner.update(value).map(|o| { + ( + o.upper_outer, + o.upper_inner, + o.middle, + o.lower_inner, + o.lower_outer, + ) + }) + } + /// Returns shape `(n, 5)` columns + /// `[upper_outer, upper_inner, middle, lower_inner, lower_outer]`. + fn batch<'py>( + &mut self, + py: Python<'py>, + prices: PyReadonlyArray1<'py, f64>, + ) -> PyResult>> { + let slice = prices + .as_slice() + .map_err(|_| PyValueError::new_err(NON_CONTIGUOUS))?; + let n = slice.len(); + let mut out = vec![f64::NAN; n * 5]; + for (i, p) in slice.iter().enumerate() { + if let Some(o) = self.inner.update(*p) { + out[i * 5] = o.upper_outer; + out[i * 5 + 1] = o.upper_inner; + out[i * 5 + 2] = o.middle; + out[i * 5 + 3] = o.lower_inner; + out[i * 5 + 4] = o.lower_outer; + } + } + 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() + } +} + +// ============================== TTM Squeeze ============================== + +#[pyclass(name = "TtmSqueeze", module = "wickra._wickra", skip_from_py_object)] +#[derive(Clone)] +struct PyTtmSqueeze { + inner: wc::TtmSqueeze, +} + +#[pymethods] +impl PyTtmSqueeze { + #[new] + #[pyo3(signature = (period=20, bb_mult=2.0, kc_mult=1.5))] + fn new(period: usize, bb_mult: f64, kc_mult: f64) -> PyResult { + Ok(Self { + inner: wc::TtmSqueeze::new(period, bb_mult, kc_mult).map_err(map_err)?, + }) + } + /// Returns `(squeeze, momentum)` or `None` during warmup. `squeeze` is + /// `1.0` while BB ⊂ KC, `0.0` otherwise. + fn update(&mut self, candle: &Bound<'_, PyAny>) -> PyResult> { + let c = extract_candle(candle)?; + Ok(self.inner.update(c).map(|o| (o.squeeze, o.momentum))) + } + /// Returns shape `(n, 2)` columns `[squeeze, momentum]`. + 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 * 2]; + 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 * 2] = o.squeeze; + out[i * 2 + 1] = o.momentum; + } + } + 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() + } +} + +// ============================== Fractal Chaos Bands ============================== + +#[pyclass( + name = "FractalChaosBands", + module = "wickra._wickra", + skip_from_py_object +)] +#[derive(Clone)] +struct PyFractalChaosBands { + inner: wc::FractalChaosBands, +} + +#[pymethods] +impl PyFractalChaosBands { + #[new] + #[pyo3(signature = (k=2))] + fn new(k: usize) -> PyResult { + Ok(Self { + inner: wc::FractalChaosBands::new(k).map_err(map_err)?, + }) + } + /// Returns `(upper, lower)` or `None` until both fractals have confirmed. + fn update(&mut self, candle: &Bound<'_, PyAny>) -> PyResult> { + let c = extract_candle(candle)?; + Ok(self.inner.update(c).map(|o| (o.upper, o.lower))) + } + /// Returns shape `(n, 2)` columns `[upper, lower]`. + 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.upper; + out[i * 2 + 1] = o.lower; + } + } + 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() + } +} + +// ============================== VWAP StdDev Bands ============================== + +#[pyclass( + name = "VwapStdDevBands", + module = "wickra._wickra", + skip_from_py_object +)] +#[derive(Clone)] +struct PyVwapStdDevBands { + inner: wc::VwapStdDevBands, +} + +#[pymethods] +impl PyVwapStdDevBands { + #[new] + #[pyo3(signature = (multiplier=2.0))] + fn new(multiplier: f64) -> PyResult { + Ok(Self { + inner: wc::VwapStdDevBands::new(multiplier).map_err(map_err)?, + }) + } + /// Returns `(upper, middle, lower, stddev)` or `None` until volume is non-zero. + fn update(&mut self, candle: &Bound<'_, PyAny>) -> PyResult> { + let c = extract_candle(candle)?; + Ok(self + .inner + .update(c) + .map(|o| (o.upper, o.middle, o.lower, o.stddev))) + } + /// Returns shape `(n, 4)` columns `[upper, middle, lower, stddev]`. + #[allow(clippy::many_single_char_names)] + fn batch<'py>( + &mut self, + py: Python<'py>, + high: PyReadonlyArray1<'py, f64>, + low: PyReadonlyArray1<'py, f64>, + close: PyReadonlyArray1<'py, f64>, + volume: 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))?; + let v = volume + .as_slice() + .map_err(|_| PyValueError::new_err(NON_CONTIGUOUS))?; + if h.len() != l.len() || l.len() != c.len() || c.len() != v.len() { + return Err(PyValueError::new_err( + "high, low, close, volume must be equal length", + )); + } + let n = h.len(); + let mut out = vec![f64::NAN; n * 4]; + for i in 0..n { + let candle = wc::Candle::new(c[i], h[i], l[i], c[i], v[i], 0).map_err(map_err)?; + if let Some(o) = self.inner.update(candle) { + out[i * 4] = o.upper; + out[i * 4 + 1] = o.middle; + out[i * 4 + 2] = o.lower; + out[i * 4 + 3] = o.stddev; + } + } + Ok(numpy::ndarray::Array2::from_shape_vec((n, 4), 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() + } +} + // ============================== Module ============================== #[pymodule] +#[allow(clippy::too_many_lines)] fn _wickra(_py: Python<'_>, m: &Bound<'_, PyModule>) -> PyResult<()> { m.add("__version__", env!("CARGO_PKG_VERSION"))?; m.add_class::()?; @@ -6051,5 +6778,16 @@ 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::()?; + 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 360cd154..afb5a90a 100644 --- a/bindings/python/tests/test_new_indicators.py +++ b/bindings/python/tests/test_new_indicators.py @@ -78,6 +78,16 @@ SCALAR = [ ] +# Family 05 band/channel indicators with scalar input and multi-output. +# `cols` is the expected number of band columns from `batch`. +SCALAR_MULTI = { + "MaEnvelope": (lambda: ta.MaEnvelope(20, 0.025), 3), + "LinRegChannel": (lambda: ta.LinRegChannel(20, 2.0), 3), + "StandardErrorBands": (lambda: ta.StandardErrorBands(21, 2.0), 3), + "DoubleBollinger": (lambda: ta.DoubleBollinger(20, 1.0, 2.0), 5), +} + + @pytest.mark.parametrize("cls, args", SCALAR, ids=[c.__name__ for c, _ in SCALAR]) def test_scalar_streaming_matches_batch(cls, args, sine_prices): batch = cls(*args).batch(sine_prices) @@ -247,6 +257,26 @@ MULTI = { lambda: ta.ChandeKrollStop(10, 1.0, 9), lambda ind, h, l, c, v: ind.batch(h, l, c), ), + # 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(...)`. + "TtmSqueeze": ( + lambda: ta.TtmSqueeze(20, 2.0, 1.5), + lambda ind, h, l, c, v: ind.batch(h, l, c), + ), + "FractalChaosBands": ( + lambda: ta.FractalChaosBands(2), + lambda ind, h, l, c, v: ind.batch(h, l), + ), +} + + +# Bands with 3 outputs upper/middle/lower from a candle (h, l, c). +HLC_BAND3 = { + "AccelerationBands": lambda: ta.AccelerationBands(20, 0.001), + "StarcBands": lambda: ta.StarcBands(6, 15, 2.0), + "AtrBands": lambda: ta.AtrBands(14, 3.0), + "HurstChannel": lambda: ta.HurstChannel(10, 0.5), } # --- Scalar-input, multi-output indicators -------------------------------- @@ -286,6 +316,73 @@ def test_multi_streaming_matches_batch(name, ohlcv): assert _eq_nan(batch, np.array(rows, dtype=np.float64)), f"{name} mismatch" +# --- Family 05: scalar-input multi-output band/channel indicators ---------- + + +@pytest.mark.parametrize("name", list(SCALAR_MULTI)) +def test_scalar_multi_streaming_matches_batch(name, sine_prices): + make, cols = SCALAR_MULTI[name] + batch = make().batch(sine_prices) + assert batch.shape == (sine_prices.size, cols) + + streamer = make() + rows = [] + for p in sine_prices: + v = streamer.update(float(p)) + rows.append([math.nan] * cols if v is None else list(v)) + assert _eq_nan(batch, np.array(rows, dtype=np.float64)), f"{name} mismatch" + + +# --- Family 05: 3-band candle-input indicators ------------------------------ + + +@pytest.mark.parametrize("name", list(HLC_BAND3)) +def test_hlc_band3_streaming_matches_batch(name, ohlcv): + high, low, close, _ = ohlcv + make = HLC_BAND3[name] + batch = make().batch(high, low, close) + assert batch.shape == (close.size, 3) + + streamer = make() + rows = [] + for i in range(close.size): + candle = ( + float(close[i]), + float(high[i]), + float(low[i]), + float(close[i]), + 1.0, + i, + ) + v = streamer.update(candle) + rows.append([math.nan] * 3 if v is None else list(v)) + assert _eq_nan(batch, np.array(rows, dtype=np.float64)), f"{name} mismatch" + + +# --- VWAP StdDev Bands (4 outputs, needs volume) ---------------------------- + + +def test_vwap_stddev_bands_streaming_matches_batch(ohlcv): + high, low, close, volume = ohlcv + batch = ta.VwapStdDevBands(2.0).batch(high, low, close, volume) + assert batch.shape == (close.size, 4) + + streamer = ta.VwapStdDevBands(2.0) + rows = [] + for i in range(close.size): + candle = ( + float(close[i]), + float(high[i]), + float(low[i]), + float(close[i]), + float(volume[i]), + i, + ) + v = streamer.update(candle) + rows.append([math.nan] * 4 if v is None else list(v)) + assert _eq_nan(batch, np.array(rows, dtype=np.float64)) + + @pytest.mark.parametrize("name", list(MULTI_SCALAR_INPUT)) def test_multi_scalar_streaming_matches_batch(name, ohlcv): _, _, close, _ = ohlcv @@ -399,6 +496,108 @@ def test_z_score_reference(): assert out[1] == pytest.approx(1.0) +# --- Family 05 reference values --------------------------------------------- + + +def test_ma_envelope_reference(): + # SMA([10, 20, 30]) = 20; with percent = 0.10: upper = 22, lower = 18. + out = ta.MaEnvelope(3, 0.10).batch(np.array([10.0, 20.0, 30.0])) + assert math.isnan(out[0, 0]) and math.isnan(out[1, 0]) + assert out[2, 0] == pytest.approx(22.0) # upper + assert out[2, 1] == pytest.approx(20.0) # middle + assert out[2, 2] == pytest.approx(18.0) # lower + + +def test_acceleration_bands_reference(): + # Single bar: high=12, low=8, close=10, factor=0.5, period=1. + # ratio = 4/20 = 0.2; raw_up = 12·1.1 = 13.2; raw_lo = 8·0.9 = 7.2. + v = ta.AccelerationBands(1, 0.5).update((10.0, 12.0, 8.0, 10.0, 1.0, 0)) + assert v == pytest.approx((13.2, 10.0, 7.2)) + + +def test_atr_bands_reference(): + # Five identical bars (h=11, l=9, c=10) → ATR=2, close=10, mult=3: + # upper=16, middle=10, lower=4. + out = ta.AtrBands(5, 3.0).batch( + np.array([11.0] * 5), np.array([9.0] * 5), np.array([10.0] * 5) + ) + assert math.isnan(out[3, 0]) + assert out[4, 0] == pytest.approx(16.0) + assert out[4, 1] == pytest.approx(10.0) + assert out[4, 2] == pytest.approx(4.0) + + +def test_hurst_channel_reference(): + # Five identical (h=12, l=8, c=10): SMA(close)=10, range=4, mult=0.5. + out = ta.HurstChannel(5, 0.5).batch( + np.array([12.0] * 5), np.array([8.0] * 5), np.array([10.0] * 5) + ) + assert out[4, 0] == pytest.approx(12.0) + assert out[4, 1] == pytest.approx(10.0) + assert out[4, 2] == pytest.approx(8.0) + + +def test_linreg_channel_reference(): + # period 3 over [1, 2, 9]: line y=4x, endpoint=8, residuals=[1, -2, 1], + # population sigma=sqrt(2); mult=2 → upper=8+2√2, lower=8-2√2. + out = ta.LinRegChannel(3, 2.0).batch(np.array([1.0, 2.0, 9.0])) + s = math.sqrt(2.0) + assert out[2, 0] == pytest.approx(8.0 + 2.0 * s) + assert out[2, 1] == pytest.approx(8.0) + assert out[2, 2] == pytest.approx(8.0 - 2.0 * s) + + +def test_standard_error_bands_reference(): + # Same [1, 2, 9] with n=3: SSE=6, n-2=1, stderr=sqrt(6); mult=2 → + # upper=8+2√6, lower=8-2√6. + out = ta.StandardErrorBands(3, 2.0).batch(np.array([1.0, 2.0, 9.0])) + s = math.sqrt(6.0) + assert out[2, 0] == pytest.approx(8.0 + 2.0 * s) + assert out[2, 1] == pytest.approx(8.0) + assert out[2, 2] == pytest.approx(8.0 - 2.0 * s) + + +def test_double_bollinger_orders_bands(): + # On a non-trivial dispersion, outer >= inner >= middle >= -inner >= -outer. + out = ta.DoubleBollinger(5, 1.0, 2.0).batch( + np.array([1.0, 5.0, 2.0, 4.0, 3.0, 6.0]) + ) + v = out[5] + assert v[0] >= v[1] >= v[2] >= v[3] >= v[4] + + +def test_vwap_stddev_bands_reference(): + # Two equal-volume bars with tp=8, tp=12: vwap=10, σ=2, mult=1.5 → + # upper=13, lower=7. + v = ta.VwapStdDevBands(1.5) + v.update((8.0, 8.0, 8.0, 8.0, 1.0, 0)) + out = v.update((12.0, 12.0, 12.0, 12.0, 1.0, 1)) + assert out[0] == pytest.approx(13.0) + assert out[1] == pytest.approx(10.0) + assert out[2] == pytest.approx(7.0) + assert out[3] == pytest.approx(2.0) + + +def test_ttm_squeeze_flat_market(): + # Zero volatility: BB and KC both collapse to a point → squeeze=1.0, + # momentum=0.0. + candles_h = np.array([10.0] * 25) + out = ta.TtmSqueeze(20, 2.0, 1.5).batch(candles_h, candles_h, candles_h) + assert out[24, 0] == pytest.approx(1.0) + assert out[24, 1] == pytest.approx(0.0) + + +def test_fractal_chaos_bands_detects_peak_and_trough(): + # Sequence that creates one fractal high (i=2) and one low (i=3). + h = np.array([1.0, 2.0, 5.0, 3.0, 2.0, 1.0, 2.0]) + l = np.array([1.0, 2.0, 3.0, 0.5, 2.0, 1.0, 2.0]) + out = ta.FractalChaosBands(2).batch(h, l) + # First bar with both bands set is index 5. + assert math.isnan(out[4, 0]) + assert out[5, 0] == pytest.approx(5.0) + assert out[5, 1] == pytest.approx(0.5) + + # --- Lifecycle ------------------------------------------------------------ @@ -407,6 +606,9 @@ def test_new_indicators_expose_lifecycle(): instances += [make() for make, _ 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()] + instances += [make() for make in HLC_BAND3.values()] + instances += [ta.VwapStdDevBands(2.0)] instances.append(ta.Alligator(13, 8, 5)) instances.append(ta.ZeroLagMACD(12, 26, 9)) for ind in instances: diff --git a/bindings/wasm/src/lib.rs b/bindings/wasm/src/lib.rs index 5fb61746..42ea3349 100644 --- a/bindings/wasm/src/lib.rs +++ b/bindings/wasm/src/lib.rs @@ -2686,6 +2686,672 @@ impl WasmAroon { } } +// ============================== Family 05: Bands & Channels ============================== + +// Every indicator below is multi-output (2-5 bands), so they bypass the +// `wasm_scalar_indicator!` macro and follow the hand-rolled pattern used by +// Bollinger Bands, Keltner Channels, etc. above: `update` returns a JS object +// via `Object::new` + `Reflect::set`; `batch` returns a flat interleaved +// `Float64Array`. + +// ---------- MA Envelope (scalar input, 3 outputs) ---------- + +#[wasm_bindgen(js_name = MaEnvelope)] +pub struct WasmMaEnvelope { + inner: wc::MaEnvelope, +} + +#[wasm_bindgen(js_class = MaEnvelope)] +impl WasmMaEnvelope { + #[wasm_bindgen(constructor)] + pub fn new(period: usize, percent: f64) -> Result { + Ok(Self { + inner: wc::MaEnvelope::new(period, percent).map_err(map_err)?, + }) + } + pub fn update(&mut self, value: f64) -> JsValue { + match self.inner.update(value) { + Some(o) => { + let obj = Object::new(); + Reflect::set(&obj, &"upper".into(), &o.upper.into()).ok(); + Reflect::set(&obj, &"middle".into(), &o.middle.into()).ok(); + Reflect::set(&obj, &"lower".into(), &o.lower.into()).ok(); + obj.into() + } + None => JsValue::NULL, + } + } + /// Flat `[upper0, middle0, lower0, upper1, ...]`, length `3 * n`. + pub fn batch(&mut self, prices: &[f64]) -> Float64Array { + let n = prices.len(); + let mut out = vec![f64::NAN; n * 3]; + for (i, p) in prices.iter().enumerate() { + if let Some(o) = self.inner.update(*p) { + out[i * 3] = o.upper; + out[i * 3 + 1] = o.middle; + out[i * 3 + 2] = o.lower; + } + } + 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() + } +} + +// ---------- Acceleration Bands ---------- + +#[wasm_bindgen(js_name = AccelerationBands)] +pub struct WasmAccelerationBands { + inner: wc::AccelerationBands, +} + +#[wasm_bindgen(js_class = AccelerationBands)] +impl WasmAccelerationBands { + #[wasm_bindgen(constructor)] + pub fn new(period: usize, factor: f64) -> Result { + Ok(Self { + inner: wc::AccelerationBands::new(period, factor).map_err(map_err)?, + }) + } + 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, &"upper".into(), &o.upper.into()).ok(); + Reflect::set(&obj, &"middle".into(), &o.middle.into()).ok(); + Reflect::set(&obj, &"lower".into(), &o.lower.into()).ok(); + obj.into() + } + None => JsValue::NULL, + }) + } + /// Returns `[u0, m0, l0, u1, m1, l1, ...]`, length `3 * n`. + pub fn batch( + &mut self, + high: &[f64], + low: &[f64], + close: &[f64], + ) -> Result { + let n = high.len(); + if low.len() != n || close.len() != n { + return Err(JsError::new("high, low, close must be equal length")); + } + let mut out = vec![f64::NAN; n * 3]; + 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 * 3] = o.upper; + out[i * 3 + 1] = o.middle; + out[i * 3 + 2] = o.lower; + } + } + 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() + } +} + +// ---------- STARC Bands ---------- + +#[wasm_bindgen(js_name = StarcBands)] +pub struct WasmStarcBands { + inner: wc::StarcBands, +} + +#[wasm_bindgen(js_class = StarcBands)] +impl WasmStarcBands { + #[wasm_bindgen(constructor)] + pub fn new( + sma_period: usize, + atr_period: usize, + multiplier: f64, + ) -> Result { + Ok(Self { + inner: wc::StarcBands::new(sma_period, atr_period, multiplier).map_err(map_err)?, + }) + } + 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, &"upper".into(), &o.upper.into()).ok(); + Reflect::set(&obj, &"middle".into(), &o.middle.into()).ok(); + Reflect::set(&obj, &"lower".into(), &o.lower.into()).ok(); + obj.into() + } + None => JsValue::NULL, + }) + } + pub fn batch( + &mut self, + high: &[f64], + low: &[f64], + close: &[f64], + ) -> Result { + let n = high.len(); + if low.len() != n || close.len() != n { + return Err(JsError::new("high, low, close must be equal length")); + } + let mut out = vec![f64::NAN; n * 3]; + 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 * 3] = o.upper; + out[i * 3 + 1] = o.middle; + out[i * 3 + 2] = o.lower; + } + } + 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() + } +} + +// ---------- ATR Bands ---------- + +#[wasm_bindgen(js_name = AtrBands)] +pub struct WasmAtrBands { + inner: wc::AtrBands, +} + +#[wasm_bindgen(js_class = AtrBands)] +impl WasmAtrBands { + #[wasm_bindgen(constructor)] + pub fn new(period: usize, multiplier: f64) -> Result { + Ok(Self { + inner: wc::AtrBands::new(period, multiplier).map_err(map_err)?, + }) + } + 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, &"upper".into(), &o.upper.into()).ok(); + Reflect::set(&obj, &"middle".into(), &o.middle.into()).ok(); + Reflect::set(&obj, &"lower".into(), &o.lower.into()).ok(); + obj.into() + } + None => JsValue::NULL, + }) + } + pub fn batch( + &mut self, + high: &[f64], + low: &[f64], + close: &[f64], + ) -> Result { + let n = high.len(); + if low.len() != n || close.len() != n { + return Err(JsError::new("high, low, close must be equal length")); + } + let mut out = vec![f64::NAN; n * 3]; + 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 * 3] = o.upper; + out[i * 3 + 1] = o.middle; + out[i * 3 + 2] = o.lower; + } + } + 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() + } +} + +// ---------- Hurst Channel ---------- + +#[wasm_bindgen(js_name = HurstChannel)] +pub struct WasmHurstChannel { + inner: wc::HurstChannel, +} + +#[wasm_bindgen(js_class = HurstChannel)] +impl WasmHurstChannel { + #[wasm_bindgen(constructor)] + pub fn new(period: usize, multiplier: f64) -> Result { + Ok(Self { + inner: wc::HurstChannel::new(period, multiplier).map_err(map_err)?, + }) + } + 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, &"upper".into(), &o.upper.into()).ok(); + Reflect::set(&obj, &"middle".into(), &o.middle.into()).ok(); + Reflect::set(&obj, &"lower".into(), &o.lower.into()).ok(); + obj.into() + } + None => JsValue::NULL, + }) + } + pub fn batch( + &mut self, + high: &[f64], + low: &[f64], + close: &[f64], + ) -> Result { + let n = high.len(); + if low.len() != n || close.len() != n { + return Err(JsError::new("high, low, close must be equal length")); + } + let mut out = vec![f64::NAN; n * 3]; + 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 * 3] = o.upper; + out[i * 3 + 1] = o.middle; + out[i * 3 + 2] = o.lower; + } + } + 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() + } +} + +// ---------- LinReg Channel (scalar input) ---------- + +#[wasm_bindgen(js_name = LinRegChannel)] +pub struct WasmLinRegChannel { + inner: wc::LinRegChannel, +} + +#[wasm_bindgen(js_class = LinRegChannel)] +impl WasmLinRegChannel { + #[wasm_bindgen(constructor)] + pub fn new(period: usize, multiplier: f64) -> Result { + Ok(Self { + inner: wc::LinRegChannel::new(period, multiplier).map_err(map_err)?, + }) + } + pub fn update(&mut self, value: f64) -> JsValue { + match self.inner.update(value) { + Some(o) => { + let obj = Object::new(); + Reflect::set(&obj, &"upper".into(), &o.upper.into()).ok(); + Reflect::set(&obj, &"middle".into(), &o.middle.into()).ok(); + Reflect::set(&obj, &"lower".into(), &o.lower.into()).ok(); + obj.into() + } + None => JsValue::NULL, + } + } + pub fn batch(&mut self, prices: &[f64]) -> Float64Array { + let n = prices.len(); + let mut out = vec![f64::NAN; n * 3]; + for (i, p) in prices.iter().enumerate() { + if let Some(o) = self.inner.update(*p) { + out[i * 3] = o.upper; + out[i * 3 + 1] = o.middle; + out[i * 3 + 2] = o.lower; + } + } + 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() + } +} + +// ---------- Standard Error Bands (scalar input) ---------- + +#[wasm_bindgen(js_name = StandardErrorBands)] +pub struct WasmStandardErrorBands { + inner: wc::StandardErrorBands, +} + +#[wasm_bindgen(js_class = StandardErrorBands)] +impl WasmStandardErrorBands { + #[wasm_bindgen(constructor)] + pub fn new(period: usize, multiplier: f64) -> Result { + Ok(Self { + inner: wc::StandardErrorBands::new(period, multiplier).map_err(map_err)?, + }) + } + pub fn update(&mut self, value: f64) -> JsValue { + match self.inner.update(value) { + Some(o) => { + let obj = Object::new(); + Reflect::set(&obj, &"upper".into(), &o.upper.into()).ok(); + Reflect::set(&obj, &"middle".into(), &o.middle.into()).ok(); + Reflect::set(&obj, &"lower".into(), &o.lower.into()).ok(); + obj.into() + } + None => JsValue::NULL, + } + } + pub fn batch(&mut self, prices: &[f64]) -> Float64Array { + let n = prices.len(); + let mut out = vec![f64::NAN; n * 3]; + for (i, p) in prices.iter().enumerate() { + if let Some(o) = self.inner.update(*p) { + out[i * 3] = o.upper; + out[i * 3 + 1] = o.middle; + out[i * 3 + 2] = o.lower; + } + } + 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() + } +} + +// ---------- Double Bollinger (scalar input, 5 outputs) ---------- + +#[wasm_bindgen(js_name = DoubleBollinger)] +pub struct WasmDoubleBollinger { + inner: wc::DoubleBollinger, +} + +#[wasm_bindgen(js_class = DoubleBollinger)] +impl WasmDoubleBollinger { + #[wasm_bindgen(constructor)] + pub fn new(period: usize, k_inner: f64, k_outer: f64) -> Result { + Ok(Self { + inner: wc::DoubleBollinger::new(period, k_inner, k_outer).map_err(map_err)?, + }) + } + pub fn update(&mut self, value: f64) -> JsValue { + match self.inner.update(value) { + Some(o) => { + let obj = Object::new(); + Reflect::set(&obj, &"upperOuter".into(), &o.upper_outer.into()).ok(); + Reflect::set(&obj, &"upperInner".into(), &o.upper_inner.into()).ok(); + Reflect::set(&obj, &"middle".into(), &o.middle.into()).ok(); + Reflect::set(&obj, &"lowerInner".into(), &o.lower_inner.into()).ok(); + Reflect::set(&obj, &"lowerOuter".into(), &o.lower_outer.into()).ok(); + obj.into() + } + None => JsValue::NULL, + } + } + /// Flat `[u_o, u_i, m, l_i, l_o, ...]`, length `5 * n`. + pub fn batch(&mut self, prices: &[f64]) -> Float64Array { + let n = prices.len(); + let mut out = vec![f64::NAN; n * 5]; + for (i, p) in prices.iter().enumerate() { + if let Some(o) = self.inner.update(*p) { + out[i * 5] = o.upper_outer; + out[i * 5 + 1] = o.upper_inner; + out[i * 5 + 2] = o.middle; + out[i * 5 + 3] = o.lower_inner; + out[i * 5 + 4] = o.lower_outer; + } + } + 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() + } +} + +// ---------- TTM Squeeze ---------- + +#[wasm_bindgen(js_name = TtmSqueeze)] +pub struct WasmTtmSqueeze { + inner: wc::TtmSqueeze, +} + +#[wasm_bindgen(js_class = TtmSqueeze)] +impl WasmTtmSqueeze { + #[wasm_bindgen(constructor)] + pub fn new(period: usize, bb_mult: f64, kc_mult: f64) -> Result { + Ok(Self { + inner: wc::TtmSqueeze::new(period, bb_mult, kc_mult).map_err(map_err)?, + }) + } + 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, &"squeeze".into(), &o.squeeze.into()).ok(); + Reflect::set(&obj, &"momentum".into(), &o.momentum.into()).ok(); + obj.into() + } + None => JsValue::NULL, + }) + } + /// Flat `[sq0, mom0, sq1, mom1, ...]`, length `2 * n`. + pub fn batch( + &mut self, + high: &[f64], + low: &[f64], + close: &[f64], + ) -> Result { + let n = high.len(); + if low.len() != n || close.len() != n { + return Err(JsError::new("high, low, close must be equal length")); + } + let mut out = vec![f64::NAN; n * 2]; + 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 * 2] = o.squeeze; + out[i * 2 + 1] = o.momentum; + } + } + 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() + } +} + +// ---------- Fractal Chaos Bands ---------- + +#[wasm_bindgen(js_name = FractalChaosBands)] +pub struct WasmFractalChaosBands { + inner: wc::FractalChaosBands, +} + +#[wasm_bindgen(js_class = FractalChaosBands)] +impl WasmFractalChaosBands { + #[wasm_bindgen(constructor)] + pub fn new(k: usize) -> Result { + Ok(Self { + inner: wc::FractalChaosBands::new(k).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, &"upper".into(), &o.upper.into()).ok(); + Reflect::set(&obj, &"lower".into(), &o.lower.into()).ok(); + obj.into() + } + None => JsValue::NULL, + }) + } + /// Flat `[u0, l0, u1, l1, ...]`, length `2 * n`. + pub fn batch(&mut self, high: &[f64], low: &[f64]) -> Result { + let n = high.len(); + if low.len() != n { + return Err(JsError::new("high and low must be equal length")); + } + 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.upper; + out[i * 2 + 1] = o.lower; + } + } + 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() + } +} + +// ---------- VWAP StdDev Bands ---------- + +#[wasm_bindgen(js_name = VwapStdDevBands)] +pub struct WasmVwapStdDevBands { + inner: wc::VwapStdDevBands, +} + +#[wasm_bindgen(js_class = VwapStdDevBands)] +impl WasmVwapStdDevBands { + #[wasm_bindgen(constructor)] + pub fn new(multiplier: f64) -> Result { + Ok(Self { + inner: wc::VwapStdDevBands::new(multiplier).map_err(map_err)?, + }) + } + pub fn update( + &mut self, + high: f64, + low: f64, + close: f64, + volume: f64, + ) -> Result { + let c = make_candle(high, low, close, volume)?; + Ok(match self.inner.update(c) { + Some(o) => { + let obj = Object::new(); + Reflect::set(&obj, &"upper".into(), &o.upper.into()).ok(); + Reflect::set(&obj, &"middle".into(), &o.middle.into()).ok(); + Reflect::set(&obj, &"lower".into(), &o.lower.into()).ok(); + Reflect::set(&obj, &"stddev".into(), &o.stddev.into()).ok(); + obj.into() + } + None => JsValue::NULL, + }) + } + /// Flat `[u0, m0, l0, sd0, ...]`, length `4 * n`. + pub fn batch( + &mut self, + high: &[f64], + low: &[f64], + close: &[f64], + volume: &[f64], + ) -> Result { + let n = high.len(); + if low.len() != n || close.len() != n || volume.len() != n { + return Err(JsError::new( + "high, low, close, volume must be equal length", + )); + } + let mut out = vec![f64::NAN; n * 4]; + for i in 0..n { + let c = make_candle(high[i], low[i], close[i], volume[i])?; + if let Some(o) = self.inner.update(c) { + out[i * 4] = o.upper; + out[i * 4 + 1] = o.middle; + out[i * 4 + 2] = o.lower; + out[i * 4 + 3] = o.stddev; + } + } + 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() + } +} + #[cfg(test)] mod tests { use super::*; diff --git a/crates/wickra-core/src/indicators/acceleration_bands.rs b/crates/wickra-core/src/indicators/acceleration_bands.rs new file mode 100644 index 00000000..db61a60e --- /dev/null +++ b/crates/wickra-core/src/indicators/acceleration_bands.rs @@ -0,0 +1,277 @@ +//! Acceleration Bands (Price Headley). + +use crate::error::{Error, Result}; +use crate::indicators::sma::Sma; +use crate::ohlcv::Candle; +use crate::traits::Indicator; + +/// Acceleration Bands output: SMA of close with momentum-biased envelopes +/// driven by the bar's high/low geometry. +#[derive(Debug, Clone, Copy, PartialEq)] +pub struct AccelerationBandsOutput { + /// Upper band: SMA of `high · (1 + factor · (high − low) / (high + low))`. + pub upper: f64, + /// Middle band: SMA of close. + pub middle: f64, + /// Lower band: SMA of `low · (1 − factor · (high − low) / (high + low))`. + pub lower: f64, +} + +/// Acceleration Bands (Price Headley): SMA-smoothed bands that widen with each +/// bar's relative range `(high − low) / (high + low)`. +/// +/// ```text +/// ratio = (high − low) / (high + low) +/// raw_up = high · (1 + factor · ratio) +/// raw_lo = low · (1 − factor · ratio) +/// upper = SMA(raw_up, period) +/// middle = SMA(close, period) +/// lower = SMA(raw_lo, period) +/// ``` +/// +/// Headley's reference parameters are `period = 20`, `factor = 0.001` for +/// intraday equity markets — the geometric `ratio` term tends to scale on +/// fractional moves, so the literal `factor` is small. The bands compress in +/// quiet markets and flare on impulsive bars, making them a momentum-biased +/// alternative to the volatility-driven Bollinger or Keltner envelopes. +/// +/// # Example +/// +/// ``` +/// use wickra_core::{AccelerationBands, Candle, Indicator}; +/// +/// let mut indicator = AccelerationBands::new(20, 0.001).unwrap(); +/// let mut last = None; +/// for i in 0..40 { +/// let base = 100.0 + f64::from(i); +/// let candle = +/// Candle::new(base, base + 2.0, base - 2.0, base + 1.0, 10.0, i64::from(i)).unwrap(); +/// last = indicator.update(candle); +/// } +/// assert!(last.is_some()); +/// ``` +#[derive(Debug, Clone)] +pub struct AccelerationBands { + upper_sma: Sma, + middle_sma: Sma, + lower_sma: Sma, + factor: f64, + period: usize, +} + +impl AccelerationBands { + /// Construct a new Acceleration Bands indicator. + /// + /// # Errors + /// Returns [`Error::PeriodZero`] if `period == 0` and + /// [`Error::NonPositiveMultiplier`] if `factor` is not strictly positive + /// and finite. + pub fn new(period: usize, factor: f64) -> Result { + if !factor.is_finite() || factor <= 0.0 { + return Err(Error::NonPositiveMultiplier); + } + Ok(Self { + upper_sma: Sma::new(period)?, + middle_sma: Sma::new(period)?, + lower_sma: Sma::new(period)?, + factor, + period, + }) + } + + /// Headley's classic configuration: `period = 20`, `factor = 0.001`. + pub fn classic() -> Self { + Self::new(20, 0.001).expect("classic Acceleration Bands parameters are valid") + } + + /// Configured `(period, factor)`. + pub const fn parameters(&self) -> (usize, f64) { + (self.period, self.factor) + } +} + +impl Indicator for AccelerationBands { + type Input = Candle; + type Output = AccelerationBandsOutput; + + fn update(&mut self, candle: Candle) -> Option { + // (high + low) == 0 is geometrically impossible for valid OHLC + // (high >= low and a zero-sum requires both equal to 0, which would + // make the bar degenerate). Guard anyway so a hypothetical zero-price + // bar collapses the ratio to zero rather than emitting NaN. + let sum_hl = candle.high + candle.low; + let ratio = if sum_hl == 0.0 { + 0.0 + } else { + (candle.high - candle.low) / sum_hl + }; + let raw_up = candle.high * self.factor.mul_add(ratio, 1.0); + let raw_lo = candle.low * (-self.factor).mul_add(ratio, 1.0); + + // Feed all three SMAs unconditionally so they warm up in lock-step. + let upper = self.upper_sma.update(raw_up); + let middle = self.middle_sma.update(candle.close); + let lower = self.lower_sma.update(raw_lo); + let (upper, middle, lower) = (upper?, middle?, lower?); + Some(AccelerationBandsOutput { + upper, + middle, + lower, + }) + } + + fn reset(&mut self) { + self.upper_sma.reset(); + self.middle_sma.reset(); + self.lower_sma.reset(); + } + + fn warmup_period(&self) -> usize { + self.period + } + + fn is_ready(&self) -> bool { + self.middle_sma.is_ready() + } + + fn name(&self) -> &'static str { + "AccelerationBands" + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::traits::BatchExt; + use approx::assert_relative_eq; + + fn c(h: f64, l: f64, cl: f64) -> Candle { + Candle::new(cl, h, l, cl, 1.0, 0).unwrap() + } + + #[test] + fn rejects_zero_period() { + assert!(matches!( + AccelerationBands::new(0, 0.001), + Err(Error::PeriodZero) + )); + } + + #[test] + fn rejects_non_positive_factor() { + assert!(matches!( + AccelerationBands::new(20, 0.0), + Err(Error::NonPositiveMultiplier) + )); + assert!(matches!( + AccelerationBands::new(20, -1.0), + Err(Error::NonPositiveMultiplier) + )); + assert!(matches!( + AccelerationBands::new(20, f64::NAN), + Err(Error::NonPositiveMultiplier) + )); + } + + #[test] + fn accessors_and_metadata() { + let ab = AccelerationBands::classic(); + let (p, f) = ab.parameters(); + assert_eq!(p, 20); + assert_relative_eq!(f, 0.001, epsilon = 1e-12); + assert_eq!(ab.warmup_period(), 20); + assert_eq!(ab.name(), "AccelerationBands"); + } + + #[test] + fn flat_market_collapses_to_constant() { + // high == low so the ratio term is zero; all three SMAs converge to + // the same constant. + let candles: Vec = (0..30).map(|_| c(10.0, 10.0, 10.0)).collect(); + let mut ab = AccelerationBands::new(5, 0.5).unwrap(); + let last = ab.batch(&candles).into_iter().flatten().last().unwrap(); + assert_relative_eq!(last.middle, 10.0, epsilon = 1e-9); + assert_relative_eq!(last.upper, 10.0, epsilon = 1e-9); + assert_relative_eq!(last.lower, 10.0, epsilon = 1e-9); + } + + #[test] + fn warmup_returns_none() { + let mut ab = AccelerationBands::new(5, 0.001).unwrap(); + for i in 0..4 { + let base = 100.0 + f64::from(i); + assert!(ab.update(c(base + 1.0, base - 1.0, base)).is_none()); + } + assert!(ab.update(c(105.0, 103.0, 104.0)).is_some()); + } + + #[test] + fn upper_above_middle_above_lower() { + let candles: Vec = (0..50) + .map(|i| { + let m = 100.0 + (f64::from(i) * 0.2).sin() * 5.0; + c(m + 1.0, m - 1.0, m) + }) + .collect(); + let mut ab = AccelerationBands::new(20, 0.5).unwrap(); + for o in ab.batch(&candles).into_iter().flatten() { + assert!(o.upper >= o.middle, "{} < {}", o.upper, o.middle); + assert!(o.middle >= o.lower, "{} < {}", o.middle, o.lower); + } + } + + #[test] + fn batch_equals_streaming() { + let candles: Vec = (0..40) + .map(|i| c(f64::from(i) + 2.0, f64::from(i), f64::from(i) + 1.0)) + .collect(); + let mut a = AccelerationBands::new(10, 0.5).unwrap(); + let mut b = AccelerationBands::new(10, 0.5).unwrap(); + assert_eq!( + a.batch(&candles), + candles.iter().map(|x| b.update(*x)).collect::>() + ); + } + + #[test] + fn reset_clears_state() { + let candles: Vec = (0..10) + .map(|i| c(f64::from(i) + 2.0, f64::from(i), f64::from(i) + 1.0)) + .collect(); + let mut ab = AccelerationBands::new(5, 0.5).unwrap(); + ab.batch(&candles); + assert!(ab.is_ready()); + ab.reset(); + assert!(!ab.is_ready()); + assert_eq!(ab.update(candles[0]), None); + } + + #[test] + fn zero_price_candle_collapses_ratio_to_zero() { + // `high + low == 0` is geometrically only reachable with a fully-zero + // bar (high >= low and both non-negative for a real market, but + // `Candle::new` accepts the degenerate `(0, 0, 0, 0)` case). The + // ratio guard must fire and the bands all collapse to zero. + let zero = Candle::new(0.0, 0.0, 0.0, 0.0, 1.0, 0).unwrap(); + let mut ab = AccelerationBands::new(1, 0.5).unwrap(); + let v = ab.update(zero).unwrap(); + assert_relative_eq!(v.upper, 0.0, epsilon = 1e-12); + assert_relative_eq!(v.middle, 0.0, epsilon = 1e-12); + assert_relative_eq!(v.lower, 0.0, epsilon = 1e-12); + } + + /// Hand-computed reference. Single bar with `high = 12`, `low = 8`, + /// `close = 10`, `factor = 0.5`, `period = 1`. + /// `ratio = (12 − 8) / (12 + 8) = 0.2` + /// `raw_up = 12 · (1 + 0.5 · 0.2) = 12 · 1.1 = 13.2` + /// `raw_lo = 8 · (1 − 0.5 · 0.2) = 8 · 0.9 = 7.2` + /// `middle = SMA(close, 1) = 10` + #[test] + fn reference_value_single_bar() { + let mut ab = AccelerationBands::new(1, 0.5).unwrap(); + let v = ab.update(c(12.0, 8.0, 10.0)).unwrap(); + assert_relative_eq!(v.upper, 13.2, epsilon = 1e-12); + assert_relative_eq!(v.middle, 10.0, epsilon = 1e-12); + assert_relative_eq!(v.lower, 7.2, epsilon = 1e-12); + } +} diff --git a/crates/wickra-core/src/indicators/atr_bands.rs b/crates/wickra-core/src/indicators/atr_bands.rs new file mode 100644 index 00000000..601ad697 --- /dev/null +++ b/crates/wickra-core/src/indicators/atr_bands.rs @@ -0,0 +1,214 @@ +//! ATR Bands. + +use crate::error::{Error, Result}; +use crate::indicators::atr::Atr; +use crate::ohlcv::Candle; +use crate::traits::Indicator; + +/// ATR Bands output. +#[derive(Debug, Clone, Copy, PartialEq)] +pub struct AtrBandsOutput { + /// Upper band: `close + multiplier · ATR`. + pub upper: f64, + /// Middle band: the current close. + pub middle: f64, + /// Lower band: `close − multiplier · ATR`. + pub lower: f64, +} + +/// ATR Bands: a close-anchored envelope of width `multiplier · ATR`. +/// +/// ```text +/// upper = close + multiplier · ATR(period) +/// lower = close − multiplier · ATR(period) +/// ``` +/// +/// Unlike [`Keltner`](crate::Keltner) or [`StarcBands`](crate::StarcBands), the +/// centerline is the *raw close* rather than a smoothed average — the band +/// rides the price tick-for-tick. This is the standard volatility-targeting +/// envelope traders use to set initial stop-loss and profit targets: an entry +/// at the close sets a `multiplier · ATR` stop and the symmetric target +/// without ever needing to wait for a moving average to warm up. +/// +/// # Example +/// +/// ``` +/// use wickra_core::{AtrBands, Candle, Indicator}; +/// +/// let mut indicator = AtrBands::new(14, 3.0).unwrap(); +/// let mut last = None; +/// for i in 0..30 { +/// let base = 100.0 + f64::from(i); +/// let candle = +/// Candle::new(base, base + 2.0, base - 2.0, base + 1.0, 10.0, i64::from(i)).unwrap(); +/// last = indicator.update(candle); +/// } +/// assert!(last.is_some()); +/// ``` +#[derive(Debug, Clone)] +pub struct AtrBands { + atr: Atr, + multiplier: f64, +} + +impl AtrBands { + /// # Errors + /// Returns [`Error::PeriodZero`] / [`Error::NonPositiveMultiplier`] on + /// invalid inputs. + pub fn new(period: usize, multiplier: f64) -> Result { + if !multiplier.is_finite() || multiplier <= 0.0 { + return Err(Error::NonPositiveMultiplier); + } + Ok(Self { + atr: Atr::new(period)?, + multiplier, + }) + } + + /// Configured ATR period. + pub const fn period(&self) -> usize { + self.atr.period() + } + + /// Configured ATR multiplier. + pub const fn multiplier(&self) -> f64 { + self.multiplier + } +} + +impl Indicator for AtrBands { + type Input = Candle; + type Output = AtrBandsOutput; + + fn update(&mut self, candle: Candle) -> Option { + let atr = self.atr.update(candle)?; + Some(AtrBandsOutput { + upper: candle.close + self.multiplier * atr, + middle: candle.close, + lower: candle.close - self.multiplier * atr, + }) + } + + fn reset(&mut self) { + self.atr.reset(); + } + + fn warmup_period(&self) -> usize { + self.atr.warmup_period() + } + + fn is_ready(&self) -> bool { + self.atr.is_ready() + } + + fn name(&self) -> &'static str { + "AtrBands" + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::traits::BatchExt; + use approx::assert_relative_eq; + + fn c(h: f64, l: f64, cl: f64) -> Candle { + Candle::new(cl, h, l, cl, 1.0, 0).unwrap() + } + + #[test] + fn rejects_zero_period() { + assert!(matches!(AtrBands::new(0, 3.0), Err(Error::PeriodZero))); + } + + #[test] + fn rejects_non_positive_multiplier() { + assert!(matches!( + AtrBands::new(14, 0.0), + Err(Error::NonPositiveMultiplier) + )); + assert!(matches!( + AtrBands::new(14, -1.0), + Err(Error::NonPositiveMultiplier) + )); + assert!(matches!( + AtrBands::new(14, f64::INFINITY), + Err(Error::NonPositiveMultiplier) + )); + } + + #[test] + fn accessors_and_metadata() { + let ab = AtrBands::new(14, 3.0).unwrap(); + assert_eq!(ab.period(), 14); + assert_relative_eq!(ab.multiplier(), 3.0, epsilon = 1e-12); + assert_eq!(ab.warmup_period(), 14); + assert_eq!(ab.name(), "AtrBands"); + } + + #[test] + fn flat_market_collapses_bands() { + let candles: Vec = (0..30).map(|_| c(10.0, 10.0, 10.0)).collect(); + let mut ab = AtrBands::new(5, 3.0).unwrap(); + let last = ab.batch(&candles).into_iter().flatten().last().unwrap(); + assert_relative_eq!(last.upper, 10.0, epsilon = 1e-9); + assert_relative_eq!(last.middle, 10.0, epsilon = 1e-9); + assert_relative_eq!(last.lower, 10.0, epsilon = 1e-9); + } + + #[test] + fn upper_above_middle_above_lower() { + let candles: Vec = (0..50) + .map(|i| { + let m = 100.0 + (f64::from(i) * 0.2).sin() * 5.0; + c(m + 1.0, m - 1.0, m) + }) + .collect(); + let mut ab = AtrBands::new(14, 3.0).unwrap(); + for o in ab.batch(&candles).into_iter().flatten() { + assert!(o.upper >= o.middle); + assert!(o.middle >= o.lower); + } + } + + #[test] + fn batch_equals_streaming() { + let candles: Vec = (0..40) + .map(|i| c(f64::from(i) + 2.0, f64::from(i), f64::from(i) + 1.0)) + .collect(); + let mut a = AtrBands::new(10, 2.5).unwrap(); + let mut b = AtrBands::new(10, 2.5).unwrap(); + assert_eq!( + a.batch(&candles), + candles.iter().map(|x| b.update(*x)).collect::>() + ); + } + + #[test] + fn reset_clears_state() { + let candles: Vec = (0..20) + .map(|i| c(f64::from(i) + 1.0, f64::from(i) - 1.0, f64::from(i))) + .collect(); + let mut ab = AtrBands::new(5, 3.0).unwrap(); + ab.batch(&candles); + assert!(ab.is_ready()); + ab.reset(); + assert!(!ab.is_ready()); + assert_eq!(ab.update(candles[0]), None); + } + + /// Reference: with constant high-low spread of 2, ATR(period) converges to + /// 2 immediately; for multiplier 3 the bands are at `close ± 6`. + #[test] + fn reference_values_constant_spread() { + // Five identical candles with TR = 2 each: ATR seeds to 2 on bar 5. + let candles: Vec = (0..5).map(|_| c(11.0, 9.0, 10.0)).collect(); + let mut ab = AtrBands::new(5, 3.0).unwrap(); + let out = ab.batch(&candles); + assert!(out[0].is_none() && out[3].is_none()); + let v = out[4].unwrap(); + assert_relative_eq!(v.middle, 10.0, epsilon = 1e-9); + assert_relative_eq!(v.upper, 16.0, epsilon = 1e-9); + assert_relative_eq!(v.lower, 4.0, epsilon = 1e-9); + } +} diff --git a/crates/wickra-core/src/indicators/double_bollinger.rs b/crates/wickra-core/src/indicators/double_bollinger.rs new file mode 100644 index 00000000..9e8c3c9a --- /dev/null +++ b/crates/wickra-core/src/indicators/double_bollinger.rs @@ -0,0 +1,261 @@ +//! Double Bollinger Bands (Kathy Lien). + +use crate::error::{Error, Result}; +use crate::indicators::bollinger::BollingerBands; +use crate::traits::Indicator; + +/// Double Bollinger Bands output: two concentric bands at `k_inner` and +/// `k_outer` standard deviations around a shared SMA middle. +#[derive(Debug, Clone, Copy, PartialEq)] +pub struct DoubleBollingerOutput { + /// Outer upper band: `middle + k_outer · stddev`. + pub upper_outer: f64, + /// Inner upper band: `middle + k_inner · stddev`. + pub upper_inner: f64, + /// Middle band: SMA over the window. + pub middle: f64, + /// Inner lower band: `middle − k_inner · stddev`. + pub lower_inner: f64, + /// Outer lower band: `middle − k_outer · stddev`. + pub lower_outer: f64, +} + +/// Double Bollinger Bands: two concentric Bollinger envelopes (Kathy Lien). +/// +/// ```text +/// middle = SMA(period) +/// sigma = population stddev over the window +/// upper_outer = middle + k_outer · sigma // wide channel (often 2σ) +/// upper_inner = middle + k_inner · sigma // narrow channel (often 1σ) +/// lower_inner = middle − k_inner · sigma +/// lower_outer = middle − k_outer · sigma +/// ``` +/// +/// Lien's trading framework partitions price into three zones: +/// +/// - **Sell zone:** close below `lower_inner`. +/// - **Neutral zone:** close between `lower_inner` and `upper_inner`. +/// - **Buy zone:** close above `upper_inner`. +/// +/// A close beyond the outer band marks an extended move that traders typically +/// fade or trail. The constructor enforces `k_outer > k_inner` so the outputs +/// remain monotonically ordered. +/// +/// # Example +/// +/// ``` +/// use wickra_core::{DoubleBollinger, Indicator}; +/// +/// let mut indicator = DoubleBollinger::new(20, 1.0, 2.0).unwrap(); +/// let mut last = None; +/// for i in 0..40 { +/// last = indicator.update(100.0 + (f64::from(i) * 0.3).sin() * 6.0); +/// } +/// assert!(last.is_some()); +/// ``` +#[derive(Debug, Clone)] +pub struct DoubleBollinger { + inner: BollingerBands, + k_inner: f64, + k_outer: f64, +} + +impl DoubleBollinger { + /// Construct a new Double Bollinger Bands indicator. + /// + /// # Errors + /// Returns [`Error::PeriodZero`] if `period == 0`, + /// [`Error::NonPositiveMultiplier`] if either `k_inner` or `k_outer` is + /// non-positive or non-finite, and [`Error::InvalidPeriod`] if + /// `k_outer <= k_inner` (the outer band must strictly enclose the inner + /// band so the zone-partitioning interpretation holds). + pub fn new(period: usize, k_inner: f64, k_outer: f64) -> Result { + if !k_inner.is_finite() || k_inner <= 0.0 || !k_outer.is_finite() || k_outer <= 0.0 { + return Err(Error::NonPositiveMultiplier); + } + if k_outer <= k_inner { + return Err(Error::InvalidPeriod { + message: "double bollinger requires k_outer > k_inner", + }); + } + // Build the inner state on the outer multiplier so the upper/lower + // outputs of `BollingerBands::update` already give us the outer band; + // the inner band is reconstructed from the same `stddev`. + Ok(Self { + inner: BollingerBands::new(period, k_outer)?, + k_inner, + k_outer, + }) + } + + /// Kathy Lien's classic configuration: SMA(20) with `±1σ` and `±2σ` bands. + pub fn classic() -> Self { + Self::new(20, 1.0, 2.0).expect("classic Double Bollinger parameters are valid") + } + + /// Configured `(period, k_inner, k_outer)`. + pub const fn parameters(&self) -> (usize, f64, f64) { + (self.inner.period(), self.k_inner, self.k_outer) + } +} + +impl Indicator for DoubleBollinger { + type Input = f64; + type Output = DoubleBollingerOutput; + + fn update(&mut self, value: f64) -> Option { + let o = self.inner.update(value)?; + Some(DoubleBollingerOutput { + upper_outer: o.upper, + upper_inner: o.middle + self.k_inner * o.stddev, + middle: o.middle, + lower_inner: o.middle - self.k_inner * o.stddev, + lower_outer: o.lower, + }) + } + + fn reset(&mut self) { + self.inner.reset(); + } + + fn warmup_period(&self) -> usize { + self.inner.warmup_period() + } + + fn is_ready(&self) -> bool { + self.inner.is_ready() + } + + fn name(&self) -> &'static str { + "DoubleBollinger" + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::traits::BatchExt; + use approx::assert_relative_eq; + + #[test] + fn rejects_zero_period() { + assert!(matches!( + DoubleBollinger::new(0, 1.0, 2.0), + Err(Error::PeriodZero) + )); + } + + #[test] + fn rejects_non_positive_multiplier() { + assert!(matches!( + DoubleBollinger::new(20, 0.0, 2.0), + Err(Error::NonPositiveMultiplier) + )); + assert!(matches!( + DoubleBollinger::new(20, 1.0, -2.0), + Err(Error::NonPositiveMultiplier) + )); + assert!(matches!( + DoubleBollinger::new(20, f64::NAN, 2.0), + Err(Error::NonPositiveMultiplier) + )); + } + + #[test] + fn rejects_outer_not_greater_than_inner() { + assert!(matches!( + DoubleBollinger::new(20, 2.0, 1.0), + Err(Error::InvalidPeriod { .. }) + )); + assert!(matches!( + DoubleBollinger::new(20, 2.0, 2.0), + Err(Error::InvalidPeriod { .. }) + )); + } + + #[test] + fn accessors_and_metadata() { + let db = DoubleBollinger::classic(); + let (p, ki, ko) = db.parameters(); + assert_eq!(p, 20); + assert_relative_eq!(ki, 1.0, epsilon = 1e-12); + assert_relative_eq!(ko, 2.0, epsilon = 1e-12); + assert_eq!(db.warmup_period(), 20); + assert_eq!(db.name(), "DoubleBollinger"); + } + + #[test] + fn constant_series_collapses_all_bands() { + let mut db = DoubleBollinger::new(10, 1.0, 2.0).unwrap(); + let last = db + .batch(&[5.0_f64; 20]) + .into_iter() + .flatten() + .last() + .unwrap(); + assert_relative_eq!(last.middle, 5.0, epsilon = 1e-12); + assert_relative_eq!(last.upper_outer, 5.0, epsilon = 1e-12); + assert_relative_eq!(last.upper_inner, 5.0, epsilon = 1e-12); + assert_relative_eq!(last.lower_inner, 5.0, epsilon = 1e-12); + assert_relative_eq!(last.lower_outer, 5.0, epsilon = 1e-12); + } + + #[test] + fn bands_strictly_ordered_with_dispersion() { + let prices: Vec = (0..80) + .map(|i| 100.0 + (f64::from(i) * 0.3).sin() * 6.0) + .collect(); + let mut db = DoubleBollinger::classic(); + for o in db.batch(&prices).into_iter().flatten() { + assert!(o.upper_outer >= o.upper_inner); + assert!(o.upper_inner >= o.middle); + assert!(o.middle >= o.lower_inner); + assert!(o.lower_inner >= o.lower_outer); + } + } + + #[test] + fn batch_equals_streaming() { + let prices: Vec = (0..50).map(|i| f64::from(i) * 0.7).collect(); + let mut a = DoubleBollinger::new(10, 1.0, 2.0).unwrap(); + let mut b = DoubleBollinger::new(10, 1.0, 2.0).unwrap(); + assert_eq!( + a.batch(&prices), + prices.iter().map(|p| b.update(*p)).collect::>() + ); + } + + #[test] + fn reset_clears_state() { + let mut db = DoubleBollinger::new(5, 1.0, 2.0).unwrap(); + db.batch(&[1.0, 2.0, 3.0, 4.0, 5.0]); + assert!(db.is_ready()); + db.reset(); + assert!(!db.is_ready()); + assert_eq!(db.update(1.0), None); + } + + /// The inner band must agree with running a separate `BollingerBands` at + /// the inner multiplier. + #[test] + fn inner_band_matches_separate_bollinger() { + let prices: Vec = (0..80) + .map(|i| 100.0 + (f64::from(i) * 0.3).sin() * 6.0) + .collect(); + let mut db = DoubleBollinger::new(20, 1.0, 2.0).unwrap(); + let mut bb_inner = BollingerBands::new(20, 1.0).unwrap(); + let mut bb_outer = BollingerBands::new(20, 2.0).unwrap(); + for p in &prices { + let d = db.update(*p); + let i = bb_inner.update(*p); + let o = bb_outer.update(*p); + if let (Some(d), Some(i), Some(o)) = (d, i, o) { + assert_relative_eq!(d.middle, i.middle, epsilon = 1e-9); + assert_relative_eq!(d.upper_inner, i.upper, epsilon = 1e-9); + assert_relative_eq!(d.lower_inner, i.lower, epsilon = 1e-9); + assert_relative_eq!(d.upper_outer, o.upper, epsilon = 1e-9); + assert_relative_eq!(d.lower_outer, o.lower, epsilon = 1e-9); + } + } + } +} diff --git a/crates/wickra-core/src/indicators/fractal_chaos_bands.rs b/crates/wickra-core/src/indicators/fractal_chaos_bands.rs new file mode 100644 index 00000000..c7df3ad0 --- /dev/null +++ b/crates/wickra-core/src/indicators/fractal_chaos_bands.rs @@ -0,0 +1,271 @@ +//! Fractal Chaos Bands (Bill Williams Fractals). + +use std::collections::VecDeque; + +use crate::error::{Error, Result}; +use crate::ohlcv::Candle; +use crate::traits::Indicator; + +/// Fractal Chaos Bands output. +#[derive(Debug, Clone, Copy, PartialEq)] +pub struct FractalChaosBandsOutput { + /// Upper band: high of the most recent confirmed fractal high. + pub upper: f64, + /// Lower band: low of the most recent confirmed fractal low. + pub lower: f64, +} + +/// Fractal Chaos Bands: a step-function envelope of the most recent Bill +/// Williams fractal highs and lows. +/// +/// A bar is a **fractal high** when its high is the maximum of the window +/// `[i − k, …, i + k]`. A **fractal low** is defined symmetrically on lows. +/// The bands hold the high (low) of the latest confirmed fractal high (low), +/// stepping outwards whenever a new fractal forms and otherwise staying flat: +/// +/// ```text +/// confirmation_lag = k // the centre bar is known only k bars later +/// upper = high of the most recent confirmed fractal high +/// lower = low of the most recent confirmed fractal low +/// ``` +/// +/// `k = 2` (5-bar fractals) is the canonical Williams setting and matches the +/// "Fractal Chaos Bands" oscillator shipped with several chart vendors. With +/// `k` bars of look-ahead, every band update reflects price `k` bars ago — +/// strict streaming preserves this lag rather than peeking into the future. +/// +/// # Example +/// +/// ``` +/// use wickra_core::{Candle, FractalChaosBands, Indicator}; +/// +/// let mut indicator = FractalChaosBands::new(2).unwrap(); +/// let mut last = None; +/// for i in 0..30 { +/// let base = 100.0 + (f64::from(i) * 0.5).sin() * 5.0; +/// let candle = +/// Candle::new(base, base + 1.0, base - 1.0, base, 10.0, i64::from(i)).unwrap(); +/// last = indicator.update(candle); +/// } +/// // Confirmation requires `2k + 1` bars plus at least one fractal of each +/// // kind, so `last` may legitimately be `None` on a single sweep without +/// // both a peak and a trough in the window. +/// let _ = last; +/// ``` +#[derive(Debug, Clone)] +pub struct FractalChaosBands { + k: usize, + window: VecDeque, + last_upper: Option, + last_lower: Option, +} + +impl FractalChaosBands { + /// Construct a new Fractal Chaos Bands indicator with the given fractal + /// half-width `k` (a bar is a fractal high if its high exceeds the highs + /// of the `k` bars on either side; canonical `k = 2`). + /// + /// # Errors + /// Returns [`Error::PeriodZero`] if `k == 0` (a single bar is always its + /// own trivial fractal). + pub fn new(k: usize) -> Result { + if k == 0 { + return Err(Error::PeriodZero); + } + Ok(Self { + k, + window: VecDeque::with_capacity(2 * k + 1), + last_upper: None, + last_lower: None, + }) + } + + /// Canonical Bill Williams configuration: `k = 2` (5-bar fractals). + pub fn classic() -> Self { + Self::new(2).expect("classic Fractal Chaos Bands parameters are valid") + } + + /// Configured half-width `k`. + pub const fn k(&self) -> usize { + self.k + } +} + +impl Indicator for FractalChaosBands { + type Input = Candle; + type Output = FractalChaosBandsOutput; + + fn update(&mut self, candle: Candle) -> Option { + let window_len = 2 * self.k + 1; + if self.window.len() == window_len { + self.window.pop_front(); + } + self.window.push_back(candle); + if self.window.len() < window_len { + return None; + } + // The centre bar is at index `k`. Strictly compare against the `k` + // bars on either side: `>` for the high and `<` for the low (a ties- + // included pattern would fire on flat tops/bottoms, against Williams' + // intent). + let center = &self.window[self.k]; + let mut is_high = true; + let mut is_low = true; + for (i, c) in self.window.iter().enumerate() { + if i == self.k { + continue; + } + if c.high >= center.high { + is_high = false; + } + if c.low <= center.low { + is_low = false; + } + } + if is_high { + self.last_upper = Some(center.high); + } + if is_low { + self.last_lower = Some(center.low); + } + // Both bands must have been seen at least once before we can emit. + match (self.last_upper, self.last_lower) { + (Some(u), Some(l)) => Some(FractalChaosBandsOutput { upper: u, lower: l }), + _ => None, + } + } + + fn reset(&mut self) { + self.window.clear(); + self.last_upper = None; + self.last_lower = None; + } + + fn warmup_period(&self) -> usize { + 2 * self.k + 1 + } + + fn is_ready(&self) -> bool { + self.last_upper.is_some() && self.last_lower.is_some() + } + + fn name(&self) -> &'static str { + "FractalChaosBands" + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::traits::BatchExt; + use approx::assert_relative_eq; + + fn c(h: f64, l: f64, cl: f64) -> Candle { + Candle::new(cl, h, l, cl, 1.0, 0).unwrap() + } + + #[test] + fn rejects_zero_k() { + assert!(matches!(FractalChaosBands::new(0), Err(Error::PeriodZero))); + } + + #[test] + fn accessors_and_metadata() { + let f = FractalChaosBands::classic(); + assert_eq!(f.k(), 2); + assert_eq!(f.warmup_period(), 5); + assert_eq!(f.name(), "FractalChaosBands"); + } + + /// Detect a single peak and a single trough with `k = 2`. + /// Bars (high, low, close): (1,1,1), (2,2,2), (5,3,4), (3,1,2), + /// (2,2,2), (1,1,1), (2,2,2), (5,3,4). + /// Indices: 0..7. The peak at i=2 is `>` its 2 neighbours on each side + /// (after index 4 lands). The trough at i=3 is `<` its 2 neighbours on + /// each side (after index 5 lands). Both bands first emit on index 5. + #[test] + fn detects_simple_peak_and_trough() { + let candles = vec![ + c(1.0, 1.0, 1.0), + c(2.0, 2.0, 2.0), + c(5.0, 3.0, 4.0), // peak: high 5 is the max of neighbouring 4 + c(3.0, 0.5, 1.0), // trough: low 0.5 is the min + c(2.0, 2.0, 2.0), + c(1.0, 1.0, 1.0), + c(2.0, 2.0, 2.0), + ]; + let mut f = FractalChaosBands::new(2).unwrap(); + let out = f.batch(&candles); + // Bars 0..4 are warmup or single-band only — both bands haven't been + // confirmed yet. + for v in out.iter().take(5) { + assert!(v.is_none()); + } + // Bar 5 confirms the trough at i=3 (low 0.5); the peak at i=2 was + // confirmed by bar 4 (centre 2, look-ahead 2 → index 4). So index 5 + // is the first bar with *both* upper and lower set. + let v = out[5].unwrap(); + assert_relative_eq!(v.upper, 5.0, epsilon = 1e-12); + assert_relative_eq!(v.lower, 0.5, epsilon = 1e-12); + } + + /// In a flat market no bar is strictly higher (or lower) than its + /// neighbours, so no fractal ever confirms and the indicator never emits. + #[test] + fn flat_market_never_emits() { + let candles: Vec = (0..30).map(|_| c(10.0, 10.0, 10.0)).collect(); + let mut f = FractalChaosBands::new(2).unwrap(); + for v in f.batch(&candles) { + assert!(v.is_none()); + } + } + + #[test] + fn batch_equals_streaming() { + let candles: Vec = (0..40) + .map(|i| { + let m = 100.0 + (f64::from(i) * 0.5).sin() * 3.0; + c(m + 1.0, m - 1.0, m) + }) + .collect(); + let mut a = FractalChaosBands::new(2).unwrap(); + let mut b = FractalChaosBands::new(2).unwrap(); + assert_eq!( + a.batch(&candles), + candles.iter().map(|x| b.update(*x)).collect::>() + ); + } + + #[test] + fn reset_clears_state() { + let candles = vec![ + c(1.0, 1.0, 1.0), + c(2.0, 2.0, 2.0), + c(5.0, 3.0, 4.0), + c(3.0, 0.5, 1.0), + c(2.0, 2.0, 2.0), + c(1.0, 1.0, 1.0), + c(2.0, 2.0, 2.0), + ]; + let mut f = FractalChaosBands::new(2).unwrap(); + f.batch(&candles); + assert!(f.is_ready()); + f.reset(); + assert!(!f.is_ready()); + assert_eq!(f.update(candles[0]), None); + } + + #[test] + fn upper_above_lower_when_both_set() { + let candles: Vec = (0..60) + .map(|i| { + let m = 100.0 + (f64::from(i) * 0.4).sin() * 5.0; + c(m + 1.0, m - 1.0, m) + }) + .collect(); + let mut f = FractalChaosBands::new(2).unwrap(); + for o in f.batch(&candles).into_iter().flatten() { + assert!(o.upper >= o.lower); + } + } +} diff --git a/crates/wickra-core/src/indicators/hurst_channel.rs b/crates/wickra-core/src/indicators/hurst_channel.rs new file mode 100644 index 00000000..06f6cabd --- /dev/null +++ b/crates/wickra-core/src/indicators/hurst_channel.rs @@ -0,0 +1,236 @@ +//! Hurst Channel (Brian Millard / Hurst-cycle channel). + +use std::collections::VecDeque; + +use crate::error::{Error, Result}; +use crate::indicators::sma::Sma; +use crate::ohlcv::Candle; +use crate::traits::Indicator; + +/// Hurst Channel output. +#[derive(Debug, Clone, Copy, PartialEq)] +pub struct HurstChannelOutput { + /// Upper channel: `middle + multiplier · (highest_high − lowest_low)`. + pub upper: f64, + /// Middle line: SMA of close over the period. + pub middle: f64, + /// Lower channel: `middle − multiplier · (highest_high − lowest_low)`. + pub lower: f64, +} + +/// Hurst Channel: an SMA centerline wrapped by a rolling high-low range. +/// +/// ```text +/// middle = SMA(close, period) +/// range = max(high, period) − min(low, period) +/// upper = middle + multiplier · range +/// lower = middle − multiplier · range +/// ``` +/// +/// The Hurst Channel sizes its envelope by the *realised* high-low range of +/// the window — a simpler, range-based volatility proxy than Bollinger's +/// rolling stddev or Keltner's ATR. With a `multiplier` of `0.5` the channel +/// reduces to a centerline that hugs the midpoint of the Donchian envelope; +/// chart vendors that follow Hurst's cycle work commonly use `period = 10` and +/// `multiplier = 0.5` for the "inner" channel. +/// +/// # Example +/// +/// ``` +/// use wickra_core::{Candle, HurstChannel, Indicator}; +/// +/// let mut indicator = HurstChannel::new(10, 0.5).unwrap(); +/// let mut last = None; +/// for i in 0..30 { +/// let base = 100.0 + f64::from(i); +/// let candle = +/// Candle::new(base, base + 2.0, base - 2.0, base + 1.0, 10.0, i64::from(i)).unwrap(); +/// last = indicator.update(candle); +/// } +/// assert!(last.is_some()); +/// ``` +#[derive(Debug, Clone)] +pub struct HurstChannel { + period: usize, + multiplier: f64, + sma: Sma, + highs: VecDeque, + lows: VecDeque, +} + +impl HurstChannel { + /// # Errors + /// Returns [`Error::PeriodZero`] / [`Error::NonPositiveMultiplier`] on + /// invalid inputs. + pub fn new(period: usize, multiplier: f64) -> Result { + if !multiplier.is_finite() || multiplier <= 0.0 { + return Err(Error::NonPositiveMultiplier); + } + Ok(Self { + period, + multiplier, + sma: Sma::new(period)?, + highs: VecDeque::with_capacity(period), + lows: VecDeque::with_capacity(period), + }) + } + + /// Configured period. + pub const fn period(&self) -> usize { + self.period + } + + /// Configured range multiplier. + pub const fn multiplier(&self) -> f64 { + self.multiplier + } +} + +impl Indicator for HurstChannel { + type Input = Candle; + type Output = HurstChannelOutput; + + fn update(&mut self, candle: Candle) -> Option { + if self.highs.len() == self.period { + self.highs.pop_front(); + self.lows.pop_front(); + } + self.highs.push_back(candle.high); + self.lows.push_back(candle.low); + + let middle = self.sma.update(candle.close)?; + let hi = self.highs.iter().copied().fold(f64::NEG_INFINITY, f64::max); + let lo = self.lows.iter().copied().fold(f64::INFINITY, f64::min); + let range = hi - lo; + Some(HurstChannelOutput { + upper: middle + self.multiplier * range, + middle, + lower: middle - self.multiplier * range, + }) + } + + fn reset(&mut self) { + self.sma.reset(); + self.highs.clear(); + self.lows.clear(); + } + + fn warmup_period(&self) -> usize { + self.period + } + + fn is_ready(&self) -> bool { + self.sma.is_ready() + } + + fn name(&self) -> &'static str { + "HurstChannel" + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::traits::BatchExt; + use approx::assert_relative_eq; + + fn c(h: f64, l: f64, cl: f64) -> Candle { + Candle::new(cl, h, l, cl, 1.0, 0).unwrap() + } + + #[test] + fn rejects_zero_period() { + assert!(matches!(HurstChannel::new(0, 0.5), Err(Error::PeriodZero))); + } + + #[test] + fn rejects_non_positive_multiplier() { + assert!(matches!( + HurstChannel::new(10, 0.0), + Err(Error::NonPositiveMultiplier) + )); + assert!(matches!( + HurstChannel::new(10, -0.5), + Err(Error::NonPositiveMultiplier) + )); + assert!(matches!( + HurstChannel::new(10, f64::NAN), + Err(Error::NonPositiveMultiplier) + )); + } + + #[test] + fn accessors_and_metadata() { + let h = HurstChannel::new(10, 0.5).unwrap(); + assert_eq!(h.period(), 10); + assert_relative_eq!(h.multiplier(), 0.5, epsilon = 1e-12); + assert_eq!(h.warmup_period(), 10); + assert_eq!(h.name(), "HurstChannel"); + } + + #[test] + fn flat_market_collapses_bands() { + let candles: Vec = (0..20).map(|_| c(10.0, 10.0, 10.0)).collect(); + let mut h = HurstChannel::new(5, 0.5).unwrap(); + let last = h.batch(&candles).into_iter().flatten().last().unwrap(); + assert_relative_eq!(last.upper, 10.0, epsilon = 1e-9); + assert_relative_eq!(last.middle, 10.0, epsilon = 1e-9); + assert_relative_eq!(last.lower, 10.0, epsilon = 1e-9); + } + + #[test] + fn upper_above_middle_above_lower() { + let candles: Vec = (0..50) + .map(|i| { + let m = 100.0 + (f64::from(i) * 0.2).sin() * 5.0; + c(m + 1.0, m - 1.0, m) + }) + .collect(); + let mut h = HurstChannel::new(10, 0.5).unwrap(); + for o in h.batch(&candles).into_iter().flatten() { + assert!(o.upper >= o.middle); + assert!(o.middle >= o.lower); + } + } + + #[test] + fn batch_equals_streaming() { + let candles: Vec = (0..40) + .map(|i| c(f64::from(i) + 2.0, f64::from(i), f64::from(i) + 1.0)) + .collect(); + let mut a = HurstChannel::new(10, 0.5).unwrap(); + let mut b = HurstChannel::new(10, 0.5).unwrap(); + assert_eq!( + a.batch(&candles), + candles.iter().map(|x| b.update(*x)).collect::>() + ); + } + + #[test] + fn reset_clears_state() { + let candles: Vec = (0..10) + .map(|i| c(f64::from(i) + 1.0, f64::from(i) - 1.0, f64::from(i))) + .collect(); + let mut h = HurstChannel::new(5, 0.5).unwrap(); + h.batch(&candles); + assert!(h.is_ready()); + h.reset(); + assert!(!h.is_ready()); + assert_eq!(h.update(candles[0]), None); + } + + /// Reference: five identical candles `(high=12, low=8, close=10)`: + /// SMA(close, 5) = 10, range = 12 − 8 = 4, multiplier = 0.5 + /// upper = 10 + 0.5·4 = 12, lower = 10 − 0.5·4 = 8. + #[test] + fn reference_values() { + let candles: Vec = (0..5).map(|_| c(12.0, 8.0, 10.0)).collect(); + let mut h = HurstChannel::new(5, 0.5).unwrap(); + let out = h.batch(&candles); + assert!(out[0].is_none() && out[3].is_none()); + let v = out[4].unwrap(); + assert_relative_eq!(v.middle, 10.0, epsilon = 1e-9); + assert_relative_eq!(v.upper, 12.0, epsilon = 1e-9); + assert_relative_eq!(v.lower, 8.0, epsilon = 1e-9); + } +} diff --git a/crates/wickra-core/src/indicators/linreg_channel.rs b/crates/wickra-core/src/indicators/linreg_channel.rs new file mode 100644 index 00000000..1a1b769a --- /dev/null +++ b/crates/wickra-core/src/indicators/linreg_channel.rs @@ -0,0 +1,266 @@ +//! Linear Regression Channel — OLS endpoint ± k · stddev of residuals. + +use std::collections::VecDeque; + +use crate::error::{Error, Result}; +use crate::traits::Indicator; + +/// Linear Regression Channel output. +#[derive(Debug, Clone, Copy, PartialEq)] +pub struct LinRegChannelOutput { + /// Upper channel: regression endpoint plus `multiplier · stddev` of the + /// residuals. + pub upper: f64, + /// Middle line: OLS endpoint over the window. + pub middle: f64, + /// Lower channel: regression endpoint minus `multiplier · stddev` of the + /// residuals. + pub lower: f64, +} + +/// Linear Regression Channel: rolling least-squares line with `±k·σ` bands +/// sized by the residuals about the fitted line. +/// +/// ```text +/// fit y = a + b·x by OLS over the last `period` closes +/// residual_i = y_i − (a + b · x_i) +/// sigma = sqrt( Σ residual_i² / period ) // population stddev +/// middle = a + b · (period − 1) // endpoint of the line +/// upper = middle + multiplier · sigma +/// lower = middle − multiplier · sigma +/// ``` +/// +/// Where [`BollingerBands`](crate::BollingerBands) measures dispersion about +/// the *mean*, the `LinReg` Channel measures it about the *trend*: detrended +/// residuals, so a steady drift up or down does not bias the band width. The +/// resulting envelope tracks the trend without flaring on momentum bursts — +/// breakouts are statistically meaningful in the direction of trend, not just +/// in absolute price. +/// +/// # Example +/// +/// ``` +/// use wickra_core::{Indicator, LinRegChannel}; +/// +/// let mut indicator = LinRegChannel::new(20, 2.0).unwrap(); +/// let mut last = None; +/// for i in 0..40 { +/// last = indicator.update(100.0 + f64::from(i)); +/// } +/// assert!(last.is_some()); +/// ``` +#[derive(Debug, Clone)] +pub struct LinRegChannel { + period: usize, + multiplier: f64, + window: VecDeque, + sum_x: f64, + sum_xx: f64, +} + +impl LinRegChannel { + /// # Errors + /// Returns [`Error::InvalidPeriod`] if `period < 2` and + /// [`Error::NonPositiveMultiplier`] if `multiplier` is not strictly + /// positive and finite. + pub fn new(period: usize, multiplier: f64) -> Result { + if period < 2 { + return Err(Error::InvalidPeriod { + message: "linear regression channel needs period >= 2", + }); + } + if !multiplier.is_finite() || multiplier <= 0.0 { + return Err(Error::NonPositiveMultiplier); + } + let n = period as f64; + Ok(Self { + period, + multiplier, + window: VecDeque::with_capacity(period), + sum_x: n * (n - 1.0) / 2.0, + sum_xx: (n - 1.0) * n * (2.0 * n - 1.0) / 6.0, + }) + } + + /// Configured period. + pub const fn period(&self) -> usize { + self.period + } + + /// Configured multiplier. + pub const fn multiplier(&self) -> f64 { + self.multiplier + } +} + +impl Indicator for LinRegChannel { + type Input = f64; + type Output = LinRegChannelOutput; + + fn update(&mut self, value: f64) -> Option { + if self.window.len() == self.period { + self.window.pop_front(); + } + self.window.push_back(value); + if self.window.len() < self.period { + return None; + } + // Recompute over the live window every bar. The OLS endpoint *could* + // be maintained incrementally (see `LinearRegression`) but the + // residual-stddev cannot be slid in closed form without storing each + // residual; recomputing both keeps the code simple and is O(period) + // per update — entirely acceptable for the periods used in practice. + let n = self.period as f64; + let mut sum_y = 0.0; + let mut sum_xy = 0.0; + for (i, &y) in self.window.iter().enumerate() { + let x = i as f64; + sum_y += y; + sum_xy += x * y; + } + let denom = n * self.sum_xx - self.sum_x * self.sum_x; + let slope = (n * sum_xy - self.sum_x * sum_y) / denom; + let intercept = (sum_y - slope * self.sum_x) / n; + + // Residuals about the fitted line. + let mut sum_sq = 0.0; + for (i, &y) in self.window.iter().enumerate() { + let fitted = intercept + slope * (i as f64); + let r = y - fitted; + sum_sq += r * r; + } + let sigma = (sum_sq / n).sqrt(); + let middle = intercept + slope * (n - 1.0); + Some(LinRegChannelOutput { + upper: middle + self.multiplier * sigma, + middle, + lower: middle - self.multiplier * sigma, + }) + } + + fn reset(&mut self) { + self.window.clear(); + } + + fn warmup_period(&self) -> usize { + self.period + } + + fn is_ready(&self) -> bool { + self.window.len() == self.period + } + + fn name(&self) -> &'static str { + "LinRegChannel" + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::traits::BatchExt; + use approx::assert_relative_eq; + + #[test] + fn rejects_period_below_two() { + assert!(LinRegChannel::new(0, 2.0).is_err()); + assert!(LinRegChannel::new(1, 2.0).is_err()); + assert!(LinRegChannel::new(2, 2.0).is_ok()); + } + + #[test] + fn rejects_non_positive_multiplier() { + assert!(matches!( + LinRegChannel::new(20, 0.0), + Err(Error::NonPositiveMultiplier) + )); + assert!(matches!( + LinRegChannel::new(20, -1.0), + Err(Error::NonPositiveMultiplier) + )); + assert!(matches!( + LinRegChannel::new(20, f64::NAN), + Err(Error::NonPositiveMultiplier) + )); + } + + #[test] + fn accessors_and_metadata() { + let lc = LinRegChannel::new(20, 2.0).unwrap(); + assert_eq!(lc.period(), 20); + assert_relative_eq!(lc.multiplier(), 2.0, epsilon = 1e-12); + assert_eq!(lc.warmup_period(), 20); + assert_eq!(lc.name(), "LinRegChannel"); + } + + #[test] + fn perfect_line_collapses_channel() { + // A perfectly linear series has zero residuals, so upper == middle == lower. + let prices: Vec = (0..40).map(|i| 2.0 * f64::from(i) + 5.0).collect(); + let mut lc = LinRegChannel::new(10, 2.0).unwrap(); + for o in lc.batch(&prices).into_iter().flatten() { + assert_relative_eq!(o.upper, o.middle, epsilon = 1e-9); + assert_relative_eq!(o.middle, o.lower, epsilon = 1e-9); + } + } + + #[test] + fn constant_series_collapses_channel() { + let mut lc = LinRegChannel::new(8, 2.0).unwrap(); + let out = lc.batch(&[42.0; 20]); + let v = out.iter().rev().flatten().next().unwrap(); + assert_relative_eq!(v.middle, 42.0, epsilon = 1e-9); + assert_relative_eq!(v.upper, 42.0, epsilon = 1e-9); + assert_relative_eq!(v.lower, 42.0, epsilon = 1e-9); + } + + #[test] + fn upper_above_middle_above_lower() { + let prices: Vec = (0..80) + .map(|i| 100.0 + (f64::from(i) * 0.3).sin() * 10.0) + .collect(); + let mut lc = LinRegChannel::new(20, 2.0).unwrap(); + for o in lc.batch(&prices).into_iter().flatten() { + assert!(o.upper >= o.middle); + assert!(o.middle >= o.lower); + } + } + + #[test] + fn batch_equals_streaming() { + let prices: Vec = (0..60) + .map(|i| 50.0 + (f64::from(i) * 0.3).sin() * 10.0) + .collect(); + let mut a = LinRegChannel::new(14, 2.0).unwrap(); + let mut b = LinRegChannel::new(14, 2.0).unwrap(); + assert_eq!( + a.batch(&prices), + prices.iter().map(|p| b.update(*p)).collect::>() + ); + } + + #[test] + fn reset_clears_state() { + let mut lc = LinRegChannel::new(5, 2.0).unwrap(); + lc.batch(&[1.0, 2.0, 3.0, 4.0, 5.0]); + assert!(lc.is_ready()); + lc.reset(); + assert!(!lc.is_ready()); + assert_eq!(lc.update(1.0), None); + } + + /// Reference: period 3 over `[1, 2, 9]`. Fitted line `y = 0 + 4·x`, + /// endpoint at `x = 2` is `8`. Residuals: `1 − 0 = 1`, `2 − 4 = −2`, + /// `9 − 8 = 1`. Population variance = (1 + 4 + 1) / 3 = 2, sigma = sqrt(2). + /// With multiplier 2.0, upper = 8 + 2·sqrt(2), lower = 8 − 2·sqrt(2). + #[test] + fn reference_values() { + let mut lc = LinRegChannel::new(3, 2.0).unwrap(); + let out = lc.batch(&[1.0, 2.0, 9.0]); + let v = out[2].unwrap(); + let s2 = f64::sqrt(2.0); + assert_relative_eq!(v.middle, 8.0, epsilon = 1e-9); + assert_relative_eq!(v.upper, 8.0 + 2.0 * s2, epsilon = 1e-9); + assert_relative_eq!(v.lower, 8.0 - 2.0 * s2, epsilon = 1e-9); + } +} diff --git a/crates/wickra-core/src/indicators/ma_envelope.rs b/crates/wickra-core/src/indicators/ma_envelope.rs new file mode 100644 index 00000000..01cf5b32 --- /dev/null +++ b/crates/wickra-core/src/indicators/ma_envelope.rs @@ -0,0 +1,216 @@ +//! Moving Average Envelope. + +use crate::error::{Error, Result}; +use crate::indicators::sma::Sma; +use crate::traits::Indicator; + +/// Moving Average Envelope output: SMA middle line wrapped by a fixed-percent +/// envelope on either side. +#[derive(Debug, Clone, Copy, PartialEq)] +pub struct MaEnvelopeOutput { + /// Upper envelope: `middle · (1 + percent)`. + pub upper: f64, + /// Middle band: SMA over the window. + pub middle: f64, + /// Lower envelope: `middle · (1 − percent)`. + pub lower: f64, +} + +/// Moving Average Envelope: an SMA centerline with constant-percent bands on +/// each side. +/// +/// ```text +/// middle = SMA(period) +/// upper = middle · (1 + percent) +/// lower = middle · (1 − percent) +/// ``` +/// +/// The envelope is a fixed multiplicative offset around the moving average, +/// so the band width scales with price rather than with realised volatility +/// (contrast Bollinger Bands, whose width is `2·k·σ`, or Keltner Channels, +/// whose width is `2·k·ATR`). It is the oldest band-style overlay still in +/// regular use; chart vendors typically default to `period = 20`, +/// `percent = 0.025` (2.5 %). +/// +/// # Example +/// +/// ``` +/// use wickra_core::{Indicator, MaEnvelope}; +/// +/// let mut indicator = MaEnvelope::new(20, 0.025).unwrap(); +/// let mut last = None; +/// for i in 0..40 { +/// last = indicator.update(100.0 + f64::from(i)); +/// } +/// assert!(last.is_some()); +/// ``` +#[derive(Debug, Clone)] +pub struct MaEnvelope { + sma: Sma, + percent: f64, +} + +impl MaEnvelope { + /// Construct a new Moving Average Envelope. + /// + /// # Errors + /// Returns [`Error::PeriodZero`] if `period == 0` and + /// [`Error::NonPositiveMultiplier`] if `percent` is not strictly positive + /// and finite. + pub fn new(period: usize, percent: f64) -> Result { + if !percent.is_finite() || percent <= 0.0 { + return Err(Error::NonPositiveMultiplier); + } + Ok(Self { + sma: Sma::new(period)?, + percent, + }) + } + + /// Configured period. + pub const fn period(&self) -> usize { + self.sma.period() + } + + /// Configured envelope percent (e.g. `0.025` for ±2.5 %). + pub const fn percent(&self) -> f64 { + self.percent + } +} + +impl Indicator for MaEnvelope { + type Input = f64; + type Output = MaEnvelopeOutput; + + fn update(&mut self, input: f64) -> Option { + let middle = self.sma.update(input)?; + Some(MaEnvelopeOutput { + upper: middle * (1.0 + self.percent), + middle, + lower: middle * (1.0 - self.percent), + }) + } + + fn reset(&mut self) { + self.sma.reset(); + } + + fn warmup_period(&self) -> usize { + self.sma.warmup_period() + } + + fn is_ready(&self) -> bool { + self.sma.is_ready() + } + + fn name(&self) -> &'static str { + "MaEnvelope" + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::traits::BatchExt; + use approx::assert_relative_eq; + + #[test] + fn rejects_zero_period() { + assert!(matches!(MaEnvelope::new(0, 0.025), Err(Error::PeriodZero))); + } + + #[test] + fn rejects_non_positive_percent() { + assert!(matches!( + MaEnvelope::new(20, 0.0), + Err(Error::NonPositiveMultiplier) + )); + assert!(matches!( + MaEnvelope::new(20, -0.1), + Err(Error::NonPositiveMultiplier) + )); + assert!(matches!( + MaEnvelope::new(20, f64::NAN), + Err(Error::NonPositiveMultiplier) + )); + } + + #[test] + fn accessors_and_metadata() { + let env = MaEnvelope::new(20, 0.025).unwrap(); + assert_eq!(env.period(), 20); + assert_relative_eq!(env.percent(), 0.025, epsilon = 1e-12); + assert_eq!(env.warmup_period(), 20); + assert_eq!(env.name(), "MaEnvelope"); + assert!(!env.is_ready()); + } + + #[test] + fn constant_series_yields_flat_envelope() { + let mut env = MaEnvelope::new(5, 0.01).unwrap(); + let last = env + .batch(&[100.0_f64; 20]) + .into_iter() + .flatten() + .last() + .unwrap(); + assert_relative_eq!(last.middle, 100.0, epsilon = 1e-12); + assert_relative_eq!(last.upper, 101.0, epsilon = 1e-12); + assert_relative_eq!(last.lower, 99.0, epsilon = 1e-12); + } + + #[test] + fn warmup_returns_none() { + let mut env = MaEnvelope::new(5, 0.05).unwrap(); + for v in [1.0, 2.0, 3.0, 4.0] { + assert!(env.update(v).is_none()); + } + assert!(env.update(5.0).is_some()); + } + + #[test] + fn upper_above_middle_above_lower() { + let prices: Vec = (1..=80) + .map(|i| 100.0 + (f64::from(i) * 0.2).sin() * 5.0) + .collect(); + let mut env = MaEnvelope::new(20, 0.025).unwrap(); + for o in env.batch(&prices).into_iter().flatten() { + assert!(o.upper >= o.middle); + assert!(o.middle >= o.lower); + } + } + + #[test] + fn batch_equals_streaming() { + let prices: Vec = (1..=50).map(|i| f64::from(i) * 0.7 + 100.0).collect(); + let mut a = MaEnvelope::new(10, 0.03).unwrap(); + let mut b = MaEnvelope::new(10, 0.03).unwrap(); + assert_eq!( + a.batch(&prices), + prices.iter().map(|p| b.update(*p)).collect::>() + ); + } + + #[test] + fn reset_clears_state() { + let mut env = MaEnvelope::new(5, 0.02).unwrap(); + env.batch(&[1.0, 2.0, 3.0, 4.0, 5.0]); + assert!(env.is_ready()); + env.reset(); + assert!(!env.is_ready()); + assert_eq!(env.update(1.0), None); + } + + /// Reference value: SMA over [10, 20, 30] is 20; with percent = 0.10 the + /// upper band is 22 and the lower band is 18. + #[test] + fn reference_values() { + let mut env = MaEnvelope::new(3, 0.10).unwrap(); + let out = env.batch(&[10.0, 20.0, 30.0]); + assert!(out[0].is_none() && out[1].is_none()); + let v = out[2].unwrap(); + assert_relative_eq!(v.middle, 20.0, epsilon = 1e-12); + assert_relative_eq!(v.upper, 22.0, epsilon = 1e-12); + assert_relative_eq!(v.lower, 18.0, epsilon = 1e-12); + } +} diff --git a/crates/wickra-core/src/indicators/mod.rs b/crates/wickra-core/src/indicators/mod.rs index 857b37bd..87a4c06b 100644 --- a/crates/wickra-core/src/indicators/mod.rs +++ b/crates/wickra-core/src/indicators/mod.rs @@ -4,6 +4,7 @@ //! volume) but every public name is also re-exported flat from this module and //! from the crate root for convenience. +mod acceleration_bands; mod accelerator_oscillator; mod adl; mod adx; @@ -13,6 +14,7 @@ mod apo; mod aroon; mod aroon_oscillator; mod atr; +mod atr_bands; mod atr_trailing_stop; mod awesome_oscillator; mod awesome_oscillator_histogram; @@ -32,16 +34,19 @@ mod connors_rsi; mod coppock; mod dema; mod donchian; +mod double_bollinger; mod dpo; mod ease_of_movement; mod elder_impulse; mod ema; mod evwma; mod force_index; +mod fractal_chaos_bands; mod frama; mod garman_klass; mod historical_volatility; mod hma; +mod hurst_channel; mod inertia; mod jma; mod kama; @@ -50,7 +55,9 @@ mod kst; mod laguerre_rsi; mod linreg; mod linreg_angle; +mod linreg_channel; mod linreg_slope; +mod ma_envelope; mod macd; mod mass_index; mod mcginley_dynamic; @@ -73,6 +80,8 @@ mod rvi_volatility; mod sma; mod smi; mod smma; +mod standard_error_bands; +mod starc_bands; mod stc; mod std_dev; mod stoch_rsi; @@ -84,6 +93,7 @@ mod trima; mod trix; mod true_range; mod tsi; +mod ttm_squeeze; mod typical_price; mod ulcer_index; mod ultimate_oscillator; @@ -92,6 +102,7 @@ mod vidya; mod vortex; mod vpt; mod vwap; +mod vwap_stddev_bands; mod vwma; mod weighted_close; mod williams_r; @@ -101,6 +112,7 @@ mod z_score; mod zero_lag_macd; mod zlema; +pub use acceleration_bands::{AccelerationBands, AccelerationBandsOutput}; pub use accelerator_oscillator::AcceleratorOscillator; pub use adl::Adl; pub use adx::{Adx, AdxOutput}; @@ -110,6 +122,7 @@ pub use apo::Apo; pub use aroon::{Aroon, AroonOutput}; pub use aroon_oscillator::AroonOscillator; pub use atr::Atr; +pub use atr_bands::{AtrBands, AtrBandsOutput}; pub use atr_trailing_stop::AtrTrailingStop; pub use awesome_oscillator::AwesomeOscillator; pub use awesome_oscillator_histogram::AwesomeOscillatorHistogram; @@ -129,16 +142,19 @@ pub use connors_rsi::ConnorsRsi; pub use coppock::Coppock; pub use dema::Dema; pub use donchian::{Donchian, DonchianOutput}; +pub use double_bollinger::{DoubleBollinger, DoubleBollingerOutput}; pub use dpo::Dpo; pub use ease_of_movement::EaseOfMovement; pub use elder_impulse::ElderImpulse; pub use ema::Ema; pub use evwma::Evwma; pub use force_index::ForceIndex; +pub use fractal_chaos_bands::{FractalChaosBands, FractalChaosBandsOutput}; pub use frama::Frama; pub use garman_klass::GarmanKlassVolatility; pub use historical_volatility::HistoricalVolatility; pub use hma::Hma; +pub use hurst_channel::{HurstChannel, HurstChannelOutput}; pub use inertia::Inertia; pub use jma::Jma; pub use kama::Kama; @@ -147,7 +163,9 @@ pub use kst::{Kst, KstOutput}; pub use laguerre_rsi::LaguerreRsi; pub use linreg::LinearRegression; pub use linreg_angle::LinRegAngle; +pub use linreg_channel::{LinRegChannel, LinRegChannelOutput}; pub use linreg_slope::LinRegSlope; +pub use ma_envelope::{MaEnvelope, MaEnvelopeOutput}; pub use macd::{MacdIndicator, MacdOutput}; pub use mass_index::MassIndex; pub use mcginley_dynamic::McGinleyDynamic; @@ -170,6 +188,8 @@ pub use rvi_volatility::RviVolatility; pub use sma::Sma; pub use smi::Smi; pub use smma::Smma; +pub use standard_error_bands::{StandardErrorBands, StandardErrorBandsOutput}; +pub use starc_bands::{StarcBands, StarcBandsOutput}; pub use stc::Stc; pub use std_dev::StdDev; pub use stoch_rsi::StochRsi; @@ -181,6 +201,7 @@ pub use trima::Trima; pub use trix::Trix; pub use true_range::TrueRange; pub use tsi::Tsi; +pub use ttm_squeeze::{TtmSqueeze, TtmSqueezeOutput}; pub use typical_price::TypicalPrice; pub use ulcer_index::UlcerIndex; pub use ultimate_oscillator::UltimateOscillator; @@ -189,6 +210,7 @@ pub use vidya::Vidya; pub use vortex::{Vortex, VortexOutput}; pub use vpt::VolumePriceTrend; pub use vwap::{RollingVwap, Vwap}; +pub use vwap_stddev_bands::{VwapStdDevBands, VwapStdDevBandsOutput}; pub use vwma::Vwma; pub use weighted_close::WeightedClose; pub use williams_r::WilliamsR; diff --git a/crates/wickra-core/src/indicators/standard_error_bands.rs b/crates/wickra-core/src/indicators/standard_error_bands.rs new file mode 100644 index 00000000..a1787b2c --- /dev/null +++ b/crates/wickra-core/src/indicators/standard_error_bands.rs @@ -0,0 +1,278 @@ +//! Standard Error Bands. + +use std::collections::VecDeque; + +use crate::error::{Error, Result}; +use crate::traits::Indicator; + +/// Standard Error Bands output. +#[derive(Debug, Clone, Copy, PartialEq)] +pub struct StandardErrorBandsOutput { + /// Upper band: regression endpoint plus `multiplier · standard_error`. + pub upper: f64, + /// Middle line: OLS endpoint over the window. + pub middle: f64, + /// Lower band: regression endpoint minus `multiplier · standard_error`. + pub lower: f64, +} + +/// Standard Error Bands: linear-regression line wrapped by the standard error +/// of the fit. +/// +/// ```text +/// fit y = a + b·x by OLS over the last `period` closes +/// residual_i = y_i − (a + b · x_i) +/// stderr = sqrt( Σ residual_i² / (period − 2) ) // OLS standard error +/// middle = a + b · (period − 1) +/// upper = middle + multiplier · stderr +/// lower = middle − multiplier · stderr +/// ``` +/// +/// Standard Error Bands and [`LinRegChannel`](crate::LinRegChannel) both wrap +/// an OLS endpoint, but use *different denominators* for the dispersion +/// statistic: +/// +/// - The `LinReg` Channel uses the population standard deviation of the +/// residuals (denominator `n`). +/// - Standard Error Bands use the OLS standard error (denominator `n − 2`, +/// one degree of freedom for the slope and one for the intercept). +/// +/// The `n − 2` divisor produces a slightly wider channel and is the +/// statistically-correct band-width when the regression is interpreted as a +/// prediction interval. Jon Andersen's original publication pairs the bands +/// with a default `multiplier = 2.0` and a 3-bar SMA smoothing of all three +/// outputs; this implementation reports the *raw* bands so callers can pipe +/// them through their own smoother (e.g. [`Sma::new(3)`](crate::Sma)). +/// +/// # Example +/// +/// ``` +/// use wickra_core::{Indicator, StandardErrorBands}; +/// +/// let mut indicator = StandardErrorBands::new(21, 2.0).unwrap(); +/// let mut last = None; +/// for i in 0..40 { +/// last = indicator.update(100.0 + f64::from(i)); +/// } +/// assert!(last.is_some()); +/// ``` +#[derive(Debug, Clone)] +pub struct StandardErrorBands { + period: usize, + multiplier: f64, + window: VecDeque, + sum_x: f64, + sum_xx: f64, +} + +impl StandardErrorBands { + /// # Errors + /// Returns [`Error::InvalidPeriod`] if `period < 3` (the `n − 2` + /// denominator requires at least 3 points) and + /// [`Error::NonPositiveMultiplier`] if `multiplier` is not strictly + /// positive and finite. + pub fn new(period: usize, multiplier: f64) -> Result { + if period < 3 { + return Err(Error::InvalidPeriod { + message: "standard error bands need period >= 3", + }); + } + if !multiplier.is_finite() || multiplier <= 0.0 { + return Err(Error::NonPositiveMultiplier); + } + let n = period as f64; + Ok(Self { + period, + multiplier, + window: VecDeque::with_capacity(period), + sum_x: n * (n - 1.0) / 2.0, + sum_xx: (n - 1.0) * n * (2.0 * n - 1.0) / 6.0, + }) + } + + /// Configured period. + pub const fn period(&self) -> usize { + self.period + } + + /// Configured multiplier. + pub const fn multiplier(&self) -> f64 { + self.multiplier + } +} + +impl Indicator for StandardErrorBands { + type Input = f64; + type Output = StandardErrorBandsOutput; + + fn update(&mut self, value: f64) -> Option { + if self.window.len() == self.period { + self.window.pop_front(); + } + self.window.push_back(value); + if self.window.len() < self.period { + return None; + } + let n = self.period as f64; + let mut sum_y = 0.0; + let mut sum_xy = 0.0; + for (i, &y) in self.window.iter().enumerate() { + let x = i as f64; + sum_y += y; + sum_xy += x * y; + } + let denom = n * self.sum_xx - self.sum_x * self.sum_x; + let slope = (n * sum_xy - self.sum_x * sum_y) / denom; + let intercept = (sum_y - slope * self.sum_x) / n; + + let mut sse = 0.0; + for (i, &y) in self.window.iter().enumerate() { + let fitted = intercept + slope * (i as f64); + let r = y - fitted; + sse += r * r; + } + // OLS standard error with `n − 2` degrees of freedom. `n − 2` is at + // least 1 because the constructor enforces `period >= 3`. + let stderr = (sse / (n - 2.0)).sqrt(); + let middle = intercept + slope * (n - 1.0); + Some(StandardErrorBandsOutput { + upper: middle + self.multiplier * stderr, + middle, + lower: middle - self.multiplier * stderr, + }) + } + + fn reset(&mut self) { + self.window.clear(); + } + + fn warmup_period(&self) -> usize { + self.period + } + + fn is_ready(&self) -> bool { + self.window.len() == self.period + } + + fn name(&self) -> &'static str { + "StandardErrorBands" + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::traits::BatchExt; + use approx::assert_relative_eq; + + #[test] + fn rejects_period_below_three() { + assert!(StandardErrorBands::new(0, 2.0).is_err()); + assert!(StandardErrorBands::new(1, 2.0).is_err()); + assert!(StandardErrorBands::new(2, 2.0).is_err()); + assert!(StandardErrorBands::new(3, 2.0).is_ok()); + } + + #[test] + fn rejects_non_positive_multiplier() { + assert!(matches!( + StandardErrorBands::new(20, 0.0), + Err(Error::NonPositiveMultiplier) + )); + assert!(matches!( + StandardErrorBands::new(20, -1.0), + Err(Error::NonPositiveMultiplier) + )); + assert!(matches!( + StandardErrorBands::new(20, f64::NAN), + Err(Error::NonPositiveMultiplier) + )); + } + + #[test] + fn accessors_and_metadata() { + let seb = StandardErrorBands::new(21, 2.0).unwrap(); + assert_eq!(seb.period(), 21); + assert_relative_eq!(seb.multiplier(), 2.0, epsilon = 1e-12); + assert_eq!(seb.warmup_period(), 21); + assert_eq!(seb.name(), "StandardErrorBands"); + } + + #[test] + fn perfect_line_collapses_bands() { + let prices: Vec = (0..40).map(|i| 2.0 * f64::from(i) + 5.0).collect(); + let mut seb = StandardErrorBands::new(10, 2.0).unwrap(); + for o in seb.batch(&prices).into_iter().flatten() { + assert_relative_eq!(o.upper, o.middle, epsilon = 1e-9); + assert_relative_eq!(o.middle, o.lower, epsilon = 1e-9); + } + } + + #[test] + fn upper_above_middle_above_lower() { + let prices: Vec = (0..80) + .map(|i| 100.0 + (f64::from(i) * 0.3).sin() * 10.0) + .collect(); + let mut seb = StandardErrorBands::new(21, 2.0).unwrap(); + for o in seb.batch(&prices).into_iter().flatten() { + assert!(o.upper >= o.middle); + assert!(o.middle >= o.lower); + } + } + + #[test] + fn batch_equals_streaming() { + let prices: Vec = (0..60) + .map(|i| 50.0 + (f64::from(i) * 0.3).sin() * 10.0) + .collect(); + let mut a = StandardErrorBands::new(21, 2.0).unwrap(); + let mut b = StandardErrorBands::new(21, 2.0).unwrap(); + assert_eq!( + a.batch(&prices), + prices.iter().map(|p| b.update(*p)).collect::>() + ); + } + + #[test] + fn reset_clears_state() { + let mut seb = StandardErrorBands::new(5, 2.0).unwrap(); + seb.batch(&[1.0, 2.0, 3.0, 4.0, 5.0]); + assert!(seb.is_ready()); + seb.reset(); + assert!(!seb.is_ready()); + assert_eq!(seb.update(1.0), None); + } + + /// Reference: period 3 over `[1, 2, 9]`. Fitted line `y = 0 + 4·x`, + /// endpoint at `x = 2` is `8`. Residuals: 1, −2, 1. SSE = 6. + /// `n − 2 = 1`, so stderr = sqrt(6 / 1) = sqrt(6). With multiplier 2.0: + /// upper = 8 + 2·sqrt(6), lower = 8 − 2·sqrt(6). + #[test] + fn reference_values() { + let mut seb = StandardErrorBands::new(3, 2.0).unwrap(); + let v = seb.batch(&[1.0, 2.0, 9.0])[2].unwrap(); + let s = f64::sqrt(6.0); + assert_relative_eq!(v.middle, 8.0, epsilon = 1e-9); + assert_relative_eq!(v.upper, 8.0 + 2.0 * s, epsilon = 1e-9); + assert_relative_eq!(v.lower, 8.0 - 2.0 * s, epsilon = 1e-9); + } + + /// The n−2 standard error must be strictly larger than the population + /// stddev (n divisor) on the same residuals — by the factor sqrt(n / (n−2)). + #[test] + fn standard_error_exceeds_population_stddev() { + // Use n = 5 (factor = sqrt(5/3)) with non-trivial residuals. + let prices: Vec = vec![1.0, 5.0, 2.0, 8.0, 3.0]; + let mut seb = StandardErrorBands::new(5, 1.0).unwrap(); + let v = seb.batch(&prices)[4].unwrap(); + // The half-width of the band is `multiplier · stderr`, so: + let half = v.upper - v.middle; + assert!(half > 0.0); + // sigma² = SSE / 5, stderr² = SSE / 3, ratio of stderr to sigma = sqrt(5/3). + // Reproduce stderr from the half-width (multiplier = 1.0) and check + // it is sqrt(5/3) ≈ 1.291 times larger than sigma. + let factor = (5.0_f64 / 3.0).sqrt(); + // half / factor would equal the population stddev — we expect factor > 1. + assert!(half / factor < half, "n−2 stderr must exceed n stddev"); + } +} diff --git a/crates/wickra-core/src/indicators/starc_bands.rs b/crates/wickra-core/src/indicators/starc_bands.rs new file mode 100644 index 00000000..e985f019 --- /dev/null +++ b/crates/wickra-core/src/indicators/starc_bands.rs @@ -0,0 +1,229 @@ +//! STARC Bands (Stoller Average Range Channel). + +use crate::error::{Error, Result}; +use crate::indicators::atr::Atr; +use crate::indicators::sma::Sma; +use crate::ohlcv::Candle; +use crate::traits::Indicator; + +/// STARC Bands output. +#[derive(Debug, Clone, Copy, PartialEq)] +pub struct StarcBandsOutput { + /// Upper band: `middle + multiplier · ATR`. + pub upper: f64, + /// Middle band: SMA of close. + pub middle: f64, + /// Lower band: `middle − multiplier · ATR`. + pub lower: f64, +} + +/// STARC Bands (Stoller Average Range Channel): a close-SMA centerline with +/// bands sized by ATR. +/// +/// ```text +/// middle = SMA(close, sma_period) +/// upper = middle + multiplier · ATR(atr_period) +/// lower = middle − multiplier · ATR(atr_period) +/// ``` +/// +/// STARC and [`Keltner`](crate::Keltner) share the same skeleton — moving +/// average plus an ATR offset — but Keltner's centerline is an `EMA` of the +/// typical price while STARC uses an `SMA` of the close. The SMA gives a +/// flatter, less reactive midline that traders use to pick the larger swing +/// targets; Stoller's reference parameters are `SMA(6)` over the close with +/// `ATR(15)` and a multiplier of `2.0`. +/// +/// # Example +/// +/// ``` +/// use wickra_core::{Candle, Indicator, StarcBands}; +/// +/// let mut indicator = StarcBands::new(6, 15, 2.0).unwrap(); +/// let mut last = None; +/// for i in 0..40 { +/// let base = 100.0 + f64::from(i); +/// let candle = +/// Candle::new(base, base + 2.0, base - 2.0, base + 1.0, 10.0, i64::from(i)).unwrap(); +/// last = indicator.update(candle); +/// } +/// assert!(last.is_some()); +/// ``` +#[derive(Debug, Clone)] +pub struct StarcBands { + sma: Sma, + atr: Atr, + multiplier: f64, + sma_period: usize, + atr_period: usize, +} + +impl StarcBands { + /// # Errors + /// Returns [`Error::PeriodZero`] / [`Error::NonPositiveMultiplier`] on + /// invalid inputs. + pub fn new(sma_period: usize, atr_period: usize, multiplier: f64) -> Result { + if !multiplier.is_finite() || multiplier <= 0.0 { + return Err(Error::NonPositiveMultiplier); + } + Ok(Self { + sma: Sma::new(sma_period)?, + atr: Atr::new(atr_period)?, + multiplier, + sma_period, + atr_period, + }) + } + + /// Stoller's classic configuration: SMA(6), ATR(15), multiplier 2.0. + pub fn classic() -> Self { + Self::new(6, 15, 2.0).expect("classic STARC parameters are valid") + } + + /// Configured `(sma_period, atr_period, multiplier)`. + pub const fn parameters(&self) -> (usize, usize, f64) { + (self.sma_period, self.atr_period, self.multiplier) + } +} + +impl Indicator for StarcBands { + type Input = Candle; + type Output = StarcBandsOutput; + + fn update(&mut self, candle: Candle) -> Option { + // Feed both unconditionally so SMA and ATR warm up in parallel. + let mid = self.sma.update(candle.close); + let atr = self.atr.update(candle); + let (mid, atr) = (mid?, atr?); + Some(StarcBandsOutput { + upper: mid + self.multiplier * atr, + middle: mid, + lower: mid - self.multiplier * atr, + }) + } + + fn reset(&mut self) { + self.sma.reset(); + self.atr.reset(); + } + + fn warmup_period(&self) -> usize { + self.sma_period.max(self.atr_period) + } + + fn is_ready(&self) -> bool { + self.sma.is_ready() && self.atr.is_ready() + } + + fn name(&self) -> &'static str { + "StarcBands" + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::traits::BatchExt; + use approx::assert_relative_eq; + + fn c(h: f64, l: f64, cl: f64) -> Candle { + Candle::new(cl, h, l, cl, 1.0, 0).unwrap() + } + + #[test] + fn rejects_invalid_input() { + assert!(StarcBands::new(0, 14, 2.0).is_err()); + assert!(StarcBands::new(6, 0, 2.0).is_err()); + assert!(StarcBands::new(6, 14, 0.0).is_err()); + assert!(StarcBands::new(6, 14, -1.0).is_err()); + assert!(StarcBands::new(6, 14, f64::NAN).is_err()); + } + + #[test] + fn accessors_and_metadata() { + let s = StarcBands::new(6, 15, 2.0).unwrap(); + let (sp, ap, m) = s.parameters(); + assert_eq!(sp, 6); + assert_eq!(ap, 15); + assert_relative_eq!(m, 2.0, epsilon = 1e-12); + assert_eq!(s.warmup_period(), 15); + assert_eq!(s.name(), "StarcBands"); + } + + #[test] + fn flat_market_collapses_bands() { + let candles: Vec = (0..50).map(|_| c(10.0, 10.0, 10.0)).collect(); + let mut s = StarcBands::new(6, 15, 2.0).unwrap(); + let last = s.batch(&candles).into_iter().flatten().last().unwrap(); + assert_relative_eq!(last.upper, last.middle, epsilon = 1e-9); + assert_relative_eq!(last.lower, last.middle, epsilon = 1e-9); + } + + #[test] + fn upper_above_middle_above_lower() { + let candles: Vec = (0..80) + .map(|i| { + let m = 100.0 + (f64::from(i) * 0.2).sin() * 5.0; + c(m + 1.0, m - 1.0, m) + }) + .collect(); + let mut s = StarcBands::classic(); + for o in s.batch(&candles).into_iter().flatten() { + assert!(o.upper >= o.middle); + assert!(o.middle >= o.lower); + } + } + + #[test] + fn batch_equals_streaming() { + let candles: Vec = (0..40) + .map(|i| c(f64::from(i) + 1.0, f64::from(i) - 1.0, f64::from(i))) + .collect(); + let mut a = StarcBands::classic(); + let mut b = StarcBands::classic(); + assert_eq!( + a.batch(&candles), + candles.iter().map(|x| b.update(*x)).collect::>() + ); + } + + #[test] + fn reset_clears_state() { + let candles: Vec = (0..30) + .map(|i| c(f64::from(i) + 1.0, f64::from(i) - 1.0, f64::from(i))) + .collect(); + let mut s = StarcBands::classic(); + s.batch(&candles); + assert!(s.is_ready()); + s.reset(); + assert!(!s.is_ready()); + assert_eq!(s.update(candles[0]), None); + } + + /// STARC must equal feeding independent SMA(close) and ATR siblings and + /// combining them. + #[test] + fn matches_independent_sma_and_atr() { + let candles: Vec = (0..60) + .map(|i| { + let m = 100.0 + (f64::from(i) * 0.2).sin() * 5.0; + c(m + 1.5, m - 1.5, m) + }) + .collect(); + let mut s = StarcBands::new(6, 15, 2.0).unwrap(); + let mut sma = Sma::new(6).unwrap(); + let mut atr = Atr::new(15).unwrap(); + for candle in &candles { + let got = s.update(*candle); + let mid = sma.update(candle.close); + let a = atr.update(*candle); + if let (Some(m), Some(av)) = (mid, a) { + let o = got.expect("STARC emits once both ready"); + assert_relative_eq!(o.middle, m, epsilon = 1e-9); + assert_relative_eq!(o.upper, m + 2.0 * av, epsilon = 1e-9); + assert_relative_eq!(o.lower, m - 2.0 * av, epsilon = 1e-9); + } else { + assert!(got.is_none()); + } + } + } +} diff --git a/crates/wickra-core/src/indicators/ttm_squeeze.rs b/crates/wickra-core/src/indicators/ttm_squeeze.rs new file mode 100644 index 00000000..feed27fc --- /dev/null +++ b/crates/wickra-core/src/indicators/ttm_squeeze.rs @@ -0,0 +1,302 @@ +//! TTM Squeeze (John Carter). + +use std::collections::VecDeque; + +use crate::error::{Error, Result}; +use crate::indicators::atr::Atr; +use crate::indicators::bollinger::BollingerBands; +use crate::indicators::sma::Sma; +use crate::ohlcv::Candle; +use crate::traits::Indicator; + +/// TTM Squeeze output. +#[derive(Debug, Clone, Copy, PartialEq)] +pub struct TtmSqueezeOutput { + /// `1.0` while the squeeze is *on* (Bollinger Bands sit inside the Keltner + /// Channel), `0.0` otherwise. The squeeze releases — the signal flips back + /// to `0.0` — when volatility expands and BB pierce KC. + pub squeeze: f64, + /// Detrended momentum: linear-regression endpoint of + /// `close − (midpoint(highest_high, lowest_low, period) + SMA(close, period)) / 2`. + /// Histogram-like reading that swings positive in a breakout up, negative + /// in a breakout down; trade direction on the squeeze release follows the + /// sign of `momentum`. + pub momentum: f64, +} + +/// TTM Squeeze (John Carter): a Bollinger-vs-Keltner volatility squeeze paired +/// with a detrended-close momentum reading. +/// +/// Carter's setup detects coiled markets (low realised volatility relative to +/// ATR) and the *direction* of the breakout when they uncoil: +/// +/// ```text +/// squeeze = 1.0 if BollingerBands(period, bb_mult) +/// ⊂ KeltnerChannels-like(SMA(period), ATR(period), kc_mult) +/// else 0.0 +/// +/// hl_mid = (max(high, period) + min(low, period)) / 2 +/// detrend = close − (hl_mid + SMA(close, period)) / 2 +/// momentum = LinearRegression(detrend, period) // endpoint +/// ``` +/// +/// The "Keltner-like" envelope here uses an *SMA* centerline (not the EMA of +/// typical price that [`Keltner`](crate::Keltner) uses) plus an ATR offset, +/// exactly as Carter's original publication and every chart-vendor +/// implementation define it. Common parameters: `period = 20`, `bb_mult = 2.0`, +/// `kc_mult = 1.5`. +/// +/// # Example +/// +/// ``` +/// use wickra_core::{Candle, Indicator, TtmSqueeze}; +/// +/// let mut indicator = TtmSqueeze::new(20, 2.0, 1.5).unwrap(); +/// let mut last = None; +/// for i in 0..40 { +/// let base = 100.0 + f64::from(i); +/// let candle = +/// Candle::new(base, base + 2.0, base - 2.0, base + 1.0, 10.0, i64::from(i)).unwrap(); +/// last = indicator.update(candle); +/// } +/// assert!(last.is_some()); +/// ``` +#[derive(Debug, Clone)] +pub struct TtmSqueeze { + period: usize, + kc_mult: f64, + bb: BollingerBands, + sma_close: Sma, + atr: Atr, + highs: VecDeque, + lows: VecDeque, + closes: VecDeque, + // Pre-computed OLS constants over `x = 0..period − 1`. + sum_x: f64, + denom: f64, +} + +impl TtmSqueeze { + /// # Errors + /// Returns [`Error::PeriodZero`] if `period == 0` and + /// [`Error::NonPositiveMultiplier`] if either multiplier is not strictly + /// positive and finite. `period >= 2` is required for the linear-regression + /// momentum component. + pub fn new(period: usize, bb_mult: f64, kc_mult: f64) -> Result { + if period < 2 { + return Err(Error::InvalidPeriod { + message: "TTM squeeze needs period >= 2 for the momentum regression", + }); + } + if !bb_mult.is_finite() || bb_mult <= 0.0 || !kc_mult.is_finite() || kc_mult <= 0.0 { + return Err(Error::NonPositiveMultiplier); + } + let n = period as f64; + let sum_x = n * (n - 1.0) / 2.0; + let sum_xx = (n - 1.0) * n * (2.0 * n - 1.0) / 6.0; + Ok(Self { + period, + kc_mult, + bb: BollingerBands::new(period, bb_mult)?, + sma_close: Sma::new(period)?, + atr: Atr::new(period)?, + highs: VecDeque::with_capacity(period), + lows: VecDeque::with_capacity(period), + closes: VecDeque::with_capacity(period), + sum_x, + denom: n * sum_xx - sum_x * sum_x, + }) + } + + /// John Carter's classic configuration: `period = 20`, `bb_mult = 2.0`, + /// `kc_mult = 1.5`. + pub fn classic() -> Self { + Self::new(20, 2.0, 1.5).expect("classic TTM Squeeze parameters are valid") + } + + /// Configured `(period, bb_mult, kc_mult)`. + pub fn parameters(&self) -> (usize, f64, f64) { + (self.period, self.bb.multiplier(), self.kc_mult) + } +} + +impl Indicator for TtmSqueeze { + type Input = Candle; + type Output = TtmSqueezeOutput; + + fn update(&mut self, candle: Candle) -> Option { + if self.highs.len() == self.period { + self.highs.pop_front(); + self.lows.pop_front(); + self.closes.pop_front(); + } + self.highs.push_back(candle.high); + self.lows.push_back(candle.low); + self.closes.push_back(candle.close); + + // Feed all three sub-indicators unconditionally so they warm up in + // lock-step. ATR returns its first value at bar `period` (Wilder + // seeds), the SMA and BB on bar `period` as well. + let bb = self.bb.update(candle.close); + let mid = self.sma_close.update(candle.close); + let atr = self.atr.update(candle); + let (bb, mid, atr) = (bb?, mid?, atr?); + + let kc_upper = mid + self.kc_mult * atr; + let kc_lower = mid - self.kc_mult * atr; + let squeeze = f64::from(bb.upper <= kc_upper && bb.lower >= kc_lower); + + // Detrended close. The reference forms it as the deviation of close + // from the average of the rolling high-low midpoint and the SMA of + // close, then runs a linear regression of that series. + let hi = self.highs.iter().copied().fold(f64::NEG_INFINITY, f64::max); + let lo = self.lows.iter().copied().fold(f64::INFINITY, f64::min); + let hl_mid = f64::midpoint(hi, lo); + // Build the detrended window over the closes currently in `closes`. + // We need all `period` closes to fit the regression, which is + // guaranteed once `bb` / `mid` are ready. + let baseline = f64::midpoint(hl_mid, mid); + let mut sum_y = 0.0; + let mut sum_xy = 0.0; + for (i, &c) in self.closes.iter().enumerate() { + let y = c - baseline; + let x = i as f64; + sum_y += y; + sum_xy += x * y; + } + let n = self.period as f64; + let slope = (n * sum_xy - self.sum_x * sum_y) / self.denom; + let intercept = (sum_y - slope * self.sum_x) / n; + let momentum = intercept + slope * (n - 1.0); + + Some(TtmSqueezeOutput { squeeze, momentum }) + } + + fn reset(&mut self) { + self.bb.reset(); + self.sma_close.reset(); + self.atr.reset(); + self.highs.clear(); + self.lows.clear(); + self.closes.clear(); + } + + fn warmup_period(&self) -> usize { + self.period + } + + fn is_ready(&self) -> bool { + self.bb.is_ready() && self.sma_close.is_ready() && self.atr.is_ready() + } + + fn name(&self) -> &'static str { + "TtmSqueeze" + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::traits::BatchExt; + use approx::assert_relative_eq; + + fn c(h: f64, l: f64, cl: f64) -> Candle { + Candle::new(cl, h, l, cl, 1.0, 0).unwrap() + } + + #[test] + fn rejects_invalid_period() { + assert!(TtmSqueeze::new(0, 2.0, 1.5).is_err()); + assert!(TtmSqueeze::new(1, 2.0, 1.5).is_err()); + } + + #[test] + fn rejects_non_positive_multipliers() { + assert!(matches!( + TtmSqueeze::new(20, 0.0, 1.5), + Err(Error::NonPositiveMultiplier) + )); + assert!(matches!( + TtmSqueeze::new(20, 2.0, -1.0), + Err(Error::NonPositiveMultiplier) + )); + assert!(matches!( + TtmSqueeze::new(20, f64::NAN, 1.5), + Err(Error::NonPositiveMultiplier) + )); + } + + #[test] + fn accessors_and_metadata() { + let s = TtmSqueeze::classic(); + let (p, b, k) = s.parameters(); + assert_eq!(p, 20); + assert_relative_eq!(b, 2.0, epsilon = 1e-12); + assert_relative_eq!(k, 1.5, epsilon = 1e-12); + assert_eq!(s.warmup_period(), 20); + assert_eq!(s.name(), "TtmSqueeze"); + } + + #[test] + fn flat_market_has_zero_momentum() { + let candles: Vec = (0..30).map(|_| c(10.0, 10.0, 10.0)).collect(); + let mut s = TtmSqueeze::new(20, 2.0, 1.5).unwrap(); + let last = s.batch(&candles).into_iter().flatten().last().unwrap(); + assert_relative_eq!(last.momentum, 0.0, epsilon = 1e-9); + // With zero volatility both BB and KC collapse to a point, so the + // squeeze is trivially "on". + assert_relative_eq!(last.squeeze, 1.0, epsilon = 1e-12); + } + + #[test] + fn batch_equals_streaming() { + let candles: Vec = (0..40) + .map(|i| c(f64::from(i) + 2.0, f64::from(i), f64::from(i) + 1.0)) + .collect(); + let mut a = TtmSqueeze::new(20, 2.0, 1.5).unwrap(); + let mut b = TtmSqueeze::new(20, 2.0, 1.5).unwrap(); + assert_eq!( + a.batch(&candles), + candles.iter().map(|x| b.update(*x)).collect::>() + ); + } + + #[test] + fn reset_clears_state() { + let candles: Vec = (0..30) + .map(|i| c(f64::from(i) + 1.0, f64::from(i) - 1.0, f64::from(i))) + .collect(); + let mut s = TtmSqueeze::classic(); + s.batch(&candles); + assert!(s.is_ready()); + s.reset(); + assert!(!s.is_ready()); + assert_eq!(s.update(candles[0]), None); + } + + /// Squeeze fires only after `period` candles, never before. + #[test] + fn warmup_returns_none() { + let mut s = TtmSqueeze::new(20, 2.0, 1.5).unwrap(); + for i in 0..19 { + let base = 100.0 + f64::from(i); + assert!(s.update(c(base + 1.0, base - 1.0, base)).is_none()); + } + assert!(s.update(c(121.0, 119.0, 120.0)).is_some()); + } + + /// Squeeze flag is binary — `0.0` or `1.0`. + #[test] + fn squeeze_is_binary() { + let candles: Vec = (0..60) + .map(|i| { + let m = 100.0 + (f64::from(i) * 0.4).sin() * 2.0; + c(m + 1.0, m - 1.0, m) + }) + .collect(); + let mut s = TtmSqueeze::new(20, 2.0, 1.5).unwrap(); + for o in s.batch(&candles).into_iter().flatten() { + assert!(o.squeeze == 0.0 || o.squeeze == 1.0); + } + } +} diff --git a/crates/wickra-core/src/indicators/vwap_stddev_bands.rs b/crates/wickra-core/src/indicators/vwap_stddev_bands.rs new file mode 100644 index 00000000..bc45086f --- /dev/null +++ b/crates/wickra-core/src/indicators/vwap_stddev_bands.rs @@ -0,0 +1,251 @@ +//! VWAP Standard-Deviation Bands. + +use crate::error::{Error, Result}; +use crate::ohlcv::Candle; +use crate::traits::Indicator; + +/// `VWAP` `StdDev` Bands output. +#[derive(Debug, Clone, Copy, PartialEq)] +pub struct VwapStdDevBandsOutput { + /// Upper band: `vwap + multiplier · sigma`. + pub upper: f64, + /// Middle band: cumulative VWAP of typical price. + pub middle: f64, + /// Lower band: `vwap − multiplier · sigma`. + pub lower: f64, + /// Volume-weighted standard deviation of typical price about VWAP. + pub stddev: f64, +} + +/// VWAP with volume-weighted standard-deviation envelopes. +/// +/// ```text +/// tp_i = typical_price(candle_i) // (high + low + close) / 3 +/// sum_v = Σ volume_i +/// sum_pv = Σ tp_i · volume_i +/// sum_p2v = Σ tp_i² · volume_i +/// vwap = sum_pv / sum_v +/// variance = sum_p2v / sum_v − vwap² // volume-weighted population variance +/// sigma = sqrt(max(variance, 0)) +/// upper/lower = vwap ± multiplier · sigma +/// ``` +/// +/// The cumulative running sums make every update O(1) with no per-bar replay, +/// matching the streaming contract of [`Vwap`](crate::Vwap). VWAP and its +/// stddev bands are an intraday-session tool: call [`Indicator::reset`] at +/// the start of each session boundary so the accumulators do not span the gap. +/// +/// # Example +/// +/// ``` +/// use wickra_core::{Candle, Indicator, VwapStdDevBands}; +/// +/// let mut indicator = VwapStdDevBands::new(2.0).unwrap(); +/// let mut last = None; +/// for i in 0..40 { +/// let base = 100.0 + f64::from(i); +/// let candle = +/// Candle::new(base, base + 2.0, base - 2.0, base + 1.0, 10.0, i64::from(i)).unwrap(); +/// last = indicator.update(candle); +/// } +/// assert!(last.is_some()); +/// ``` +#[derive(Debug, Clone)] +pub struct VwapStdDevBands { + multiplier: f64, + sum_pv: f64, + sum_p2v: f64, + sum_v: f64, + has_emitted: bool, +} + +impl VwapStdDevBands { + /// # Errors + /// Returns [`Error::NonPositiveMultiplier`] if `multiplier` is not strictly + /// positive and finite. + pub fn new(multiplier: f64) -> Result { + if !multiplier.is_finite() || multiplier <= 0.0 { + return Err(Error::NonPositiveMultiplier); + } + Ok(Self { + multiplier, + sum_pv: 0.0, + sum_p2v: 0.0, + sum_v: 0.0, + has_emitted: false, + }) + } + + /// Configured multiplier. + pub const fn multiplier(&self) -> f64 { + self.multiplier + } +} + +impl Indicator for VwapStdDevBands { + type Input = Candle; + type Output = VwapStdDevBandsOutput; + + fn update(&mut self, candle: Candle) -> Option { + let tp = candle.typical_price(); + self.sum_pv += tp * candle.volume; + self.sum_p2v += tp * tp * candle.volume; + self.sum_v += candle.volume; + if self.sum_v == 0.0 { + return None; + } + self.has_emitted = true; + let vwap = self.sum_pv / self.sum_v; + // Volume-weighted population variance; clamp tiny negative cancellation + // noise back to zero on near-constant inputs. + let var = (self.sum_p2v / self.sum_v - vwap * vwap).max(0.0); + let sigma = var.sqrt(); + Some(VwapStdDevBandsOutput { + upper: vwap + self.multiplier * sigma, + middle: vwap, + lower: vwap - self.multiplier * sigma, + stddev: sigma, + }) + } + + fn reset(&mut self) { + self.sum_pv = 0.0; + self.sum_p2v = 0.0; + self.sum_v = 0.0; + self.has_emitted = false; + } + + fn warmup_period(&self) -> usize { + 1 + } + + fn is_ready(&self) -> bool { + self.has_emitted + } + + fn name(&self) -> &'static str { + "VwapStdDevBands" + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::traits::BatchExt; + use approx::assert_relative_eq; + + fn c(h: f64, l: f64, cl: f64, v: f64) -> Candle { + Candle::new(cl, h, l, cl, v, 0).unwrap() + } + + #[test] + fn rejects_non_positive_multiplier() { + assert!(matches!( + VwapStdDevBands::new(0.0), + Err(Error::NonPositiveMultiplier) + )); + assert!(matches!( + VwapStdDevBands::new(-1.0), + Err(Error::NonPositiveMultiplier) + )); + assert!(matches!( + VwapStdDevBands::new(f64::NAN), + Err(Error::NonPositiveMultiplier) + )); + } + + #[test] + fn accessors_and_metadata() { + let v = VwapStdDevBands::new(2.0).unwrap(); + assert_relative_eq!(v.multiplier(), 2.0, epsilon = 1e-12); + assert_eq!(v.warmup_period(), 1); + assert_eq!(v.name(), "VwapStdDevBands"); + } + + #[test] + fn zero_volume_returns_none() { + let mut v = VwapStdDevBands::new(2.0).unwrap(); + assert!(v.update(c(10.0, 10.0, 10.0, 0.0)).is_none()); + } + + #[test] + fn constant_price_collapses_bands() { + let candles: Vec = (0..10).map(|_| c(10.0, 10.0, 10.0, 5.0)).collect(); + let mut v = VwapStdDevBands::new(2.0).unwrap(); + let last = v.batch(&candles).into_iter().flatten().last().unwrap(); + assert_relative_eq!(last.middle, 10.0, epsilon = 1e-9); + assert_relative_eq!(last.stddev, 0.0, epsilon = 1e-9); + assert_relative_eq!(last.upper, 10.0, epsilon = 1e-9); + assert_relative_eq!(last.lower, 10.0, epsilon = 1e-9); + } + + #[test] + fn upper_above_middle_above_lower() { + let candles: Vec = (0..50) + .map(|i| { + let m = 100.0 + (f64::from(i) * 0.2).sin() * 5.0; + c(m + 1.0, m - 1.0, m, 1.0 + f64::from(i % 5)) + }) + .collect(); + let mut v = VwapStdDevBands::new(2.0).unwrap(); + for o in v.batch(&candles).into_iter().flatten() { + assert!(o.upper >= o.middle); + assert!(o.middle >= o.lower); + assert!(o.stddev >= 0.0); + } + } + + #[test] + fn batch_equals_streaming() { + let candles: Vec = (0..40) + .map(|i| { + c( + f64::from(i) + 2.0, + f64::from(i), + f64::from(i) + 1.0, + 1.0 + f64::from(i % 4), + ) + }) + .collect(); + let mut a = VwapStdDevBands::new(2.0).unwrap(); + let mut b = VwapStdDevBands::new(2.0).unwrap(); + assert_eq!( + a.batch(&candles), + candles.iter().map(|x| b.update(*x)).collect::>() + ); + } + + #[test] + fn reset_clears_state() { + let candles: Vec = (0..10) + .map(|i| c(f64::from(i) + 1.0, f64::from(i) - 1.0, f64::from(i), 1.0)) + .collect(); + let mut v = VwapStdDevBands::new(2.0).unwrap(); + v.batch(&candles); + assert!(v.is_ready()); + v.reset(); + assert!(!v.is_ready()); + // After reset a zero-volume bar still returns `None` (volume is + // required to define the volume-weighted average). + assert_eq!(v.update(c(10.0, 10.0, 10.0, 0.0)), None); + } + + /// Reference: two equal-volume bars at typical prices `tp = 8` and `tp = 12`. + /// VWAP = (8 + 12) / 2 = 10. Volume-weighted population variance = + /// (64 + 144) / 2 − 100 = 4. Sigma = 2. With multiplier 1.5: upper = 13, + /// lower = 7. + #[test] + fn reference_values() { + // typical_price = (high + low + close) / 3. Choose bars where this is + // exactly 8 and 12. Bar A: high=8, low=8, close=8 → tp=8. + // Bar B: high=12, low=12, close=12 → tp=12. + let candles = [c(8.0, 8.0, 8.0, 1.0), c(12.0, 12.0, 12.0, 1.0)]; + let mut v = VwapStdDevBands::new(1.5).unwrap(); + let _ = v.update(candles[0]); + let out = v.update(candles[1]).unwrap(); + assert_relative_eq!(out.middle, 10.0, epsilon = 1e-9); + assert_relative_eq!(out.stddev, 2.0, epsilon = 1e-9); + assert_relative_eq!(out.upper, 13.0, epsilon = 1e-9); + assert_relative_eq!(out.lower, 7.0, epsilon = 1e-9); + } +} diff --git a/crates/wickra-core/src/lib.rs b/crates/wickra-core/src/lib.rs index 20203d9e..8fe46236 100644 --- a/crates/wickra-core/src/lib.rs +++ b/crates/wickra-core/src/lib.rs @@ -44,20 +44,24 @@ pub mod indicators; pub use error::{Error, Result}; pub use indicators::{ - AcceleratorOscillator, Adl, Adx, AdxOutput, Alligator, AlligatorOutput, Alma, Apo, Aroon, - AroonOscillator, AroonOutput, Atr, AtrTrailingStop, AwesomeOscillator, - AwesomeOscillatorHistogram, BalanceOfPower, BollingerBands, BollingerBandwidth, - BollingerOutput, Cci, Cfo, ChaikinMoneyFlow, ChaikinOscillator, ChaikinVolatility, - ChandeKrollStop, ChandeKrollStopOutput, ChandelierExit, ChandelierExitOutput, ChoppinessIndex, - Cmo, ConnorsRsi, Coppock, Dema, Donchian, DonchianOutput, Dpo, EaseOfMovement, ElderImpulse, - Ema, Evwma, ForceIndex, Frama, GarmanKlassVolatility, HistoricalVolatility, Hma, Inertia, Jma, - Kama, Keltner, KeltnerOutput, Kst, KstOutput, LaguerreRsi, LinRegAngle, LinRegSlope, - LinearRegression, MacdIndicator, MacdOutput, MassIndex, McGinleyDynamic, MedianPrice, Mfi, Mom, - Natr, Obv, ParkinsonVolatility, PercentB, Pgo, Pmo, Ppo, Psar, Roc, RogersSatchellVolatility, - RollingVwap, Rsi, Rvi, RviVolatility, Sma, Smi, Smma, Stc, StdDev, StochRsi, Stochastic, - StochasticOutput, SuperTrend, SuperTrendOutput, Tema, Trima, Trix, TrueRange, Tsi, - TypicalPrice, UlcerIndex, UltimateOscillator, VerticalHorizontalFilter, Vidya, - VolumePriceTrend, Vortex, VortexOutput, Vwap, Vwma, WeightedClose, WilliamsR, Wma, + AccelerationBands, AccelerationBandsOutput, AcceleratorOscillator, Adl, Adx, AdxOutput, + Alligator, AlligatorOutput, Alma, 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, Donchian, + DonchianOutput, DoubleBollinger, DoubleBollingerOutput, Dpo, EaseOfMovement, ElderImpulse, Ema, + Evwma, ForceIndex, FractalChaosBands, FractalChaosBandsOutput, Frama, GarmanKlassVolatility, + HistoricalVolatility, Hma, HurstChannel, HurstChannelOutput, Inertia, Jma, Kama, Keltner, + KeltnerOutput, Kst, KstOutput, LaguerreRsi, LinRegAngle, LinRegChannel, LinRegChannelOutput, + LinRegSlope, LinearRegression, MaEnvelope, MaEnvelopeOutput, MacdIndicator, MacdOutput, + MassIndex, McGinleyDynamic, MedianPrice, Mfi, Mom, Natr, Obv, ParkinsonVolatility, PercentB, + Pgo, Pmo, Ppo, Psar, Roc, RogersSatchellVolatility, RollingVwap, Rsi, Rvi, RviVolatility, Sma, + Smi, Smma, StandardErrorBands, StandardErrorBandsOutput, StarcBands, StarcBandsOutput, Stc, + StdDev, StochRsi, Stochastic, StochasticOutput, SuperTrend, SuperTrendOutput, Tema, Trima, + Trix, TrueRange, Tsi, TtmSqueeze, TtmSqueezeOutput, TypicalPrice, UlcerIndex, + UltimateOscillator, VerticalHorizontalFilter, Vidya, VolumePriceTrend, Vortex, VortexOutput, + Vwap, VwapStdDevBands, VwapStdDevBandsOutput, Vwma, WeightedClose, WilliamsR, Wma, YangZhangVolatility, ZScore, ZeroLagMacd, ZeroLagMacdOutput, Zlema, T3, }; pub use ohlcv::{Candle, Tick}; diff --git a/crates/wickra/benches/indicators.rs b/crates/wickra/benches/indicators.rs index 810b2a9b..2d4e5c89 100644 --- a/crates/wickra/benches/indicators.rs +++ b/crates/wickra/benches/indicators.rs @@ -19,9 +19,11 @@ use criterion::{criterion_group, criterion_main, BenchmarkId, Criterion, Throughput}; use std::hint::black_box; use wickra::{ - Alma, Atr, BatchExt, BollingerBands, Candle, Ema, Frama, GarmanKlassVolatility, Indicator, Jma, - MacdIndicator, McGinleyDynamic, Obv, ParkinsonVolatility, Pgo, RogersSatchellVolatility, Rsi, - Rvi, RviVolatility, Sma, Stochastic, Vidya, Wma, YangZhangVolatility, + AccelerationBands, Alma, Atr, AtrBands, BatchExt, BollingerBands, Candle, DoubleBollinger, Ema, + FractalChaosBands, Frama, GarmanKlassVolatility, HurstChannel, Indicator, Jma, LinRegChannel, + MaEnvelope, MacdIndicator, McGinleyDynamic, Obv, ParkinsonVolatility, Pgo, + RogersSatchellVolatility, Rsi, Rvi, RviVolatility, Sma, StandardErrorBands, StarcBands, + Stochastic, TtmSqueeze, Vidya, VwapStdDevBands, Wma, YangZhangVolatility, }; use wickra_data::csv::CandleReader; @@ -152,6 +154,8 @@ fn benches(c: &mut Criterion) { bench_candle_input(c, "atr", &candles, || Atr::new(14).unwrap()); bench_candle_input(c, "stochastic", &candles, Stochastic::classic); bench_candle_input(c, "obv", &candles, Obv::new); + + // --- Family 04: Volatility --- bench_scalar(c, "rvi_volatility", &closes, || { RviVolatility::new(10).unwrap() }); @@ -169,6 +173,64 @@ fn benches(c: &mut Criterion) { }); bench_candle_input(c, "rvi", &candles, || Rvi::new(10).unwrap()); bench_candle_input(c, "pgo", &candles, || Pgo::new(14).unwrap()); + + // --- Family 05: Bands & Channels --- + bench_candle_input(c, "acceleration_bands", &candles, || { + AccelerationBands::new(20, 0.001).unwrap() + }); + bench_candle_input(c, "starc_bands", &candles, || { + StarcBands::new(6, 15, 2.0).unwrap() + }); + bench_candle_input(c, "atr_bands", &candles, || AtrBands::new(14, 3.0).unwrap()); + bench_candle_input(c, "hurst_channel", &candles, || { + HurstChannel::new(10, 0.5).unwrap() + }); + bench_candle_input(c, "ttm_squeeze", &candles, || { + TtmSqueeze::new(20, 2.0, 1.5).unwrap() + }); + bench_candle_input(c, "fractal_chaos_bands", &candles, || { + FractalChaosBands::new(2).unwrap() + }); + bench_candle_input(c, "vwap_stddev_bands", &candles, || { + VwapStdDevBands::new(2.0).unwrap() + }); + bench_scalar_multi(c, "ma_envelope", &closes, || { + MaEnvelope::new(20, 0.025).unwrap() + }); + bench_scalar_multi(c, "linreg_channel", &closes, || { + LinRegChannel::new(20, 2.0).unwrap() + }); + bench_scalar_multi(c, "standard_error_bands", &closes, || { + StandardErrorBands::new(21, 2.0).unwrap() + }); + bench_scalar_multi(c, "double_bollinger", &closes, || { + DoubleBollinger::new(20, 1.0, 2.0).unwrap() + }); +} + +/// Variant of `bench_scalar` for scalar-input indicators whose output is *not* +/// `f64` (band/channel structs). Streaming-only path keeps the benchmark +/// expression flat across all multi-output indicators. +fn bench_scalar_multi(c: &mut Criterion, name: &str, prices: &[f64], make: F) +where + F: Fn() -> I, + I: Indicator, +{ + let mut group = c.benchmark_group(name); + for &n in SIZES { + let n = n.min(prices.len()); + let series = &prices[..n]; + group.throughput(Throughput::Elements(n as u64)); + group.bench_with_input(BenchmarkId::new("streaming", n), series, |b, prices| { + b.iter(|| { + let mut ind = make(); + for p in prices { + black_box(ind.update(*p)); + } + }); + }); + } + group.finish(); } criterion_group!(name = wickra_benches; config = Criterion::default(); targets = benches); diff --git a/fuzz/fuzz_targets/indicator_update.rs b/fuzz/fuzz_targets/indicator_update.rs index 2147330d..68ce881b 100644 --- a/fuzz/fuzz_targets/indicator_update.rs +++ b/fuzz/fuzz_targets/indicator_update.rs @@ -15,11 +15,12 @@ use libfuzzer_sys::fuzz_target; use wickra_core::{ - Alma, Apo, BatchExt, BollingerBands, Cfo, Cmo, ConnorsRsi, Coppock, Dema, Dpo, ElderImpulse, - Ema, Frama, HistoricalVolatility, Hma, Indicator, Jma, Kama, Kst, LaguerreRsi, LinRegAngle, - LinRegSlope, LinearRegression, MacdIndicator, McGinleyDynamic, Mom, Pmo, Ppo, Roc, Rsi, - RviVolatility, Sma, Smma, Stc, StdDev, StochRsi, T3, Tema, Trima, Trix, Tsi, UlcerIndex, - VerticalHorizontalFilter, Vidya, Wma, ZScore, ZeroLagMacd, Zlema, + Alma, Apo, BatchExt, BollingerBands, Cfo, Cmo, ConnorsRsi, Coppock, Dema, DoubleBollinger, Dpo, + ElderImpulse, Ema, Frama, HistoricalVolatility, Hma, Indicator, Jma, Kama, Kst, LaguerreRsi, + LinRegAngle, LinRegChannel, LinRegSlope, LinearRegression, MaEnvelope, MacdIndicator, + McGinleyDynamic, Mom, Pmo, Ppo, Roc, Rsi, RviVolatility, Sma, Smma, StandardErrorBands, Stc, + StdDev, StochRsi, T3, Tema, Trima, Trix, Tsi, UlcerIndex, VerticalHorizontalFilter, Vidya, Wma, + ZScore, ZeroLagMacd, Zlema, }; /// Drive a single streaming + batch run through one scalar indicator. Marked @@ -120,4 +121,34 @@ fuzz_target!(|data: Vec| { } let _ = BollingerBands::new(20, 2.0).unwrap().batch(&data); } + + // --- Family 05: scalar-input band/channel indicators (multi-output) --- + { + let mut env = MaEnvelope::new(20, 0.025).unwrap(); + for &x in &data { + let _ = env.update(x); + } + let _ = MaEnvelope::new(20, 0.025).unwrap().batch(&data); + } + { + let mut ch = LinRegChannel::new(20, 2.0).unwrap(); + for &x in &data { + let _ = ch.update(x); + } + let _ = LinRegChannel::new(20, 2.0).unwrap().batch(&data); + } + { + let mut seb = StandardErrorBands::new(21, 2.0).unwrap(); + for &x in &data { + let _ = seb.update(x); + } + let _ = StandardErrorBands::new(21, 2.0).unwrap().batch(&data); + } + { + let mut db = DoubleBollinger::new(20, 1.0, 2.0).unwrap(); + for &x in &data { + let _ = db.update(x); + } + let _ = DoubleBollinger::new(20, 1.0, 2.0).unwrap().batch(&data); + } }); diff --git a/fuzz/fuzz_targets/indicator_update_candle.rs b/fuzz/fuzz_targets/indicator_update_candle.rs index 9d7ad8e5..0ae8f119 100644 --- a/fuzz/fuzz_targets/indicator_update_candle.rs +++ b/fuzz/fuzz_targets/indicator_update_candle.rs @@ -23,14 +23,15 @@ use libfuzzer_sys::fuzz_target; use wickra_core::{ - AcceleratorOscillator, Adl, Adx, Alligator, Aroon, AroonOscillator, Atr, AtrTrailingStop, - AwesomeOscillator, AwesomeOscillatorHistogram, BalanceOfPower, BatchExt, Candle, Cci, - ChaikinMoneyFlow, ChaikinOscillator, - ChaikinVolatility, ChandeKrollStop, ChandelierExit, ChoppinessIndex, Donchian, EaseOfMovement, - Evwma, ForceIndex, GarmanKlassVolatility, Indicator, Inertia, Keltner, MassIndex, MedianPrice, - Mfi, Natr, Obv, ParkinsonVolatility, Pgo, Psar, RogersSatchellVolatility, RollingVwap, Rvi, Smi, - Stochastic, SuperTrend, TrueRange, TypicalPrice, UltimateOscillator, VolumePriceTrend, Vortex, - Vwap, Vwma, WeightedClose, WilliamsR, YangZhangVolatility, + AccelerationBands, AcceleratorOscillator, Adl, Adx, Alligator, Aroon, AroonOscillator, Atr, + AtrBands, AtrTrailingStop, AwesomeOscillator, AwesomeOscillatorHistogram, BalanceOfPower, + BatchExt, Candle, Cci, ChaikinMoneyFlow, ChaikinOscillator, ChaikinVolatility, ChandeKrollStop, + ChandelierExit, ChoppinessIndex, Donchian, EaseOfMovement, Evwma, ForceIndex, FractalChaosBands, + GarmanKlassVolatility, HurstChannel, Indicator, Inertia, Keltner, MassIndex, MedianPrice, Mfi, + Natr, Obv, ParkinsonVolatility, Pgo, Psar, RogersSatchellVolatility, RollingVwap, Rvi, Smi, + StarcBands, Stochastic, SuperTrend, TrueRange, TtmSqueeze, TypicalPrice, UltimateOscillator, + VolumePriceTrend, Vortex, Vwap, VwapStdDevBands, Vwma, WeightedClose, WilliamsR, + YangZhangVolatility, }; /// Convert a flat `f64` stream into a `Vec` by chunking it into @@ -143,4 +144,55 @@ fuzz_target!(|data: Vec| { } let _ = Stochastic::new(14, 3).unwrap().batch(&candles); } + + // --- Family 05: candle-input band/channel indicators (multi-output) --- + { + let mut ab = AccelerationBands::new(20, 0.001).unwrap(); + for c in &candles { + let _ = ab.update(*c); + } + let _ = AccelerationBands::new(20, 0.001).unwrap().batch(&candles); + } + { + let mut sb = StarcBands::new(6, 15, 2.0).unwrap(); + for c in &candles { + let _ = sb.update(*c); + } + let _ = StarcBands::new(6, 15, 2.0).unwrap().batch(&candles); + } + { + let mut atrb = AtrBands::new(14, 3.0).unwrap(); + for c in &candles { + let _ = atrb.update(*c); + } + let _ = AtrBands::new(14, 3.0).unwrap().batch(&candles); + } + { + let mut hc = HurstChannel::new(10, 0.5).unwrap(); + for c in &candles { + let _ = hc.update(*c); + } + let _ = HurstChannel::new(10, 0.5).unwrap().batch(&candles); + } + { + let mut ts = TtmSqueeze::new(20, 2.0, 1.5).unwrap(); + for c in &candles { + let _ = ts.update(*c); + } + let _ = TtmSqueeze::new(20, 2.0, 1.5).unwrap().batch(&candles); + } + { + let mut fc = FractalChaosBands::new(2).unwrap(); + for c in &candles { + let _ = fc.update(*c); + } + let _ = FractalChaosBands::new(2).unwrap().batch(&candles); + } + { + let mut vb = VwapStdDevBands::new(2.0).unwrap(); + for c in &candles { + let _ = vb.update(*c); + } + let _ = VwapStdDevBands::new(2.0).unwrap().batch(&candles); + } });