feat: Family 05 Bands & Channels - 11 new price-envelope indicators (#43)

* 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.
This commit is contained in:
kingchenc
2026-05-25 18:37:12 +02:00
committed by GitHub
parent 3ea0f12b7a
commit 54194a4ff8
25 changed files with 5482 additions and 33 deletions
+26
View File
@@ -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
+3 -2
View File
@@ -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/
@@ -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);
+12 -1
View File
@@ -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
+759
View File
@@ -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<Self> {
Ok(Self {
inner: wc::MaEnvelope::new(period as usize, percent).map_err(map_err)?,
})
}
#[napi]
pub fn update(&mut self, value: f64) -> Option<MaEnvelopeValue> {
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<f64>) -> Vec<f64> {
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<Self> {
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<Option<AccelerationBandsValue>> {
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<f64>,
low: Vec<f64>,
close: Vec<f64>,
) -> napi::Result<Vec<f64>> {
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<Self> {
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<Option<StarcBandsValue>> {
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<f64>,
low: Vec<f64>,
close: Vec<f64>,
) -> napi::Result<Vec<f64>> {
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<Self> {
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<Option<AtrBandsValue>> {
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<f64>,
low: Vec<f64>,
close: Vec<f64>,
) -> napi::Result<Vec<f64>> {
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<Self> {
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<Option<HurstChannelValue>> {
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<f64>,
low: Vec<f64>,
close: Vec<f64>,
) -> napi::Result<Vec<f64>> {
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<Self> {
Ok(Self {
inner: wc::LinRegChannel::new(period as usize, multiplier).map_err(map_err)?,
})
}
#[napi]
pub fn update(&mut self, value: f64) -> Option<LinRegChannelValue> {
self.inner.update(value).map(|o| LinRegChannelValue {
upper: o.upper,
middle: o.middle,
lower: o.lower,
})
}
#[napi]
pub fn batch(&mut self, prices: Vec<f64>) -> Vec<f64> {
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<Self> {
Ok(Self {
inner: wc::StandardErrorBands::new(period as usize, multiplier).map_err(map_err)?,
})
}
#[napi]
pub fn update(&mut self, value: f64) -> Option<StandardErrorBandsValue> {
self.inner.update(value).map(|o| StandardErrorBandsValue {
upper: o.upper,
middle: o.middle,
lower: o.lower,
})
}
#[napi]
pub fn batch(&mut self, prices: Vec<f64>) -> Vec<f64> {
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<Self> {
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<DoubleBollingerValue> {
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<f64>) -> Vec<f64> {
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<Self> {
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<Option<TtmSqueezeValue>> {
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<f64>,
low: Vec<f64>,
close: Vec<f64>,
) -> napi::Result<Vec<f64>> {
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<Self> {
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<Option<FractalChaosBandsValue>> {
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<f64>, low: Vec<f64>) -> napi::Result<Vec<f64>> {
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<Self> {
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<Option<VwapStdDevBandsValue>> {
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<f64>,
low: Vec<f64>,
close: Vec<f64>,
volume: Vec<f64>,
) -> napi::Result<Vec<f64>> {
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
}
}
+24
View File
@@ -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",
]
+738
View File
@@ -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<Self> {
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<Bound<'py, PyArray2<f64>>> {
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<Self> {
Ok(Self {
inner: wc::AccelerationBands::new(period, factor).map_err(map_err)?,
})
}
fn update(&mut self, candle: &Bound<'_, PyAny>) -> PyResult<Option<(f64, f64, f64)>> {
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<Bound<'py, PyArray2<f64>>> {
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<Self> {
Ok(Self {
inner: wc::StarcBands::new(sma_period, atr_period, multiplier).map_err(map_err)?,
})
}
fn update(&mut self, candle: &Bound<'_, PyAny>) -> PyResult<Option<(f64, f64, f64)>> {
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<Bound<'py, PyArray2<f64>>> {
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<Self> {
Ok(Self {
inner: wc::AtrBands::new(period, multiplier).map_err(map_err)?,
})
}
fn update(&mut self, candle: &Bound<'_, PyAny>) -> PyResult<Option<(f64, f64, f64)>> {
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<Bound<'py, PyArray2<f64>>> {
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<Self> {
Ok(Self {
inner: wc::HurstChannel::new(period, multiplier).map_err(map_err)?,
})
}
fn update(&mut self, candle: &Bound<'_, PyAny>) -> PyResult<Option<(f64, f64, f64)>> {
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<Bound<'py, PyArray2<f64>>> {
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<Self> {
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<Bound<'py, PyArray2<f64>>> {
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<Self> {
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<Bound<'py, PyArray2<f64>>> {
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<Self> {
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<Bound<'py, PyArray2<f64>>> {
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<Self> {
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<Option<(f64, f64)>> {
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<Bound<'py, PyArray2<f64>>> {
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<Self> {
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<Option<(f64, f64)>> {
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<Bound<'py, PyArray2<f64>>> {
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<Self> {
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<Option<(f64, f64, f64, f64)>> {
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<Bound<'py, PyArray2<f64>>> {
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::<PySma>()?;
@@ -6051,5 +6778,16 @@ fn _wickra(_py: Python<'_>, m: &Bound<'_, PyModule>) -> PyResult<()> {
m.add_class::<PyGarmanKlassVolatility>()?;
m.add_class::<PyRogersSatchellVolatility>()?;
m.add_class::<PyYangZhangVolatility>()?;
m.add_class::<PyMaEnvelope>()?;
m.add_class::<PyAccelerationBands>()?;
m.add_class::<PyStarcBands>()?;
m.add_class::<PyAtrBands>()?;
m.add_class::<PyHurstChannel>()?;
m.add_class::<PyLinRegChannel>()?;
m.add_class::<PyStandardErrorBands>()?;
m.add_class::<PyDoubleBollinger>()?;
m.add_class::<PyTtmSqueeze>()?;
m.add_class::<PyFractalChaosBands>()?;
m.add_class::<PyVwapStdDevBands>()?;
Ok(())
}
@@ -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:
+666
View File
@@ -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<WasmMaEnvelope, JsError> {
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<WasmAccelerationBands, JsError> {
Ok(Self {
inner: wc::AccelerationBands::new(period, factor).map_err(map_err)?,
})
}
pub fn update(&mut self, high: f64, low: f64, close: f64) -> Result<JsValue, JsError> {
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<Float64Array, JsError> {
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<WasmStarcBands, JsError> {
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<JsValue, JsError> {
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<Float64Array, JsError> {
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<WasmAtrBands, JsError> {
Ok(Self {
inner: wc::AtrBands::new(period, multiplier).map_err(map_err)?,
})
}
pub fn update(&mut self, high: f64, low: f64, close: f64) -> Result<JsValue, JsError> {
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<Float64Array, JsError> {
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<WasmHurstChannel, JsError> {
Ok(Self {
inner: wc::HurstChannel::new(period, multiplier).map_err(map_err)?,
})
}
pub fn update(&mut self, high: f64, low: f64, close: f64) -> Result<JsValue, JsError> {
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<Float64Array, JsError> {
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<WasmLinRegChannel, JsError> {
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<WasmStandardErrorBands, JsError> {
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<WasmDoubleBollinger, JsError> {
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<WasmTtmSqueeze, JsError> {
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<JsValue, JsError> {
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<Float64Array, JsError> {
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<WasmFractalChaosBands, JsError> {
Ok(Self {
inner: wc::FractalChaosBands::new(k).map_err(map_err)?,
})
}
pub fn update(&mut self, high: f64, low: f64) -> Result<JsValue, JsError> {
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<Float64Array, JsError> {
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<WasmVwapStdDevBands, JsError> {
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<JsValue, JsError> {
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<Float64Array, JsError> {
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::*;
@@ -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<Self> {
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<AccelerationBandsOutput> {
// (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<Candle> = (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<Candle> = (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<Candle> = (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::<Vec<_>>()
);
}
#[test]
fn reset_clears_state() {
let candles: Vec<Candle> = (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);
}
}
@@ -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<Self> {
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<AtrBandsOutput> {
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<Candle> = (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<Candle> = (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<Candle> = (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::<Vec<_>>()
);
}
#[test]
fn reset_clears_state() {
let candles: Vec<Candle> = (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<Candle> = (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);
}
}
@@ -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<Self> {
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<DoubleBollingerOutput> {
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<f64> = (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<f64> = (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::<Vec<_>>()
);
}
#[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<f64> = (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);
}
}
}
}
@@ -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<Candle>,
last_upper: Option<f64>,
last_lower: Option<f64>,
}
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<Self> {
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<FractalChaosBandsOutput> {
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<Candle> = (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<Candle> = (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::<Vec<_>>()
);
}
#[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<Candle> = (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);
}
}
}
@@ -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<f64>,
lows: VecDeque<f64>,
}
impl HurstChannel {
/// # Errors
/// Returns [`Error::PeriodZero`] / [`Error::NonPositiveMultiplier`] on
/// invalid inputs.
pub fn new(period: usize, multiplier: f64) -> Result<Self> {
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<HurstChannelOutput> {
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<Candle> = (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<Candle> = (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<Candle> = (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::<Vec<_>>()
);
}
#[test]
fn reset_clears_state() {
let candles: Vec<Candle> = (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<Candle> = (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);
}
}
@@ -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<f64>,
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<Self> {
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<LinRegChannelOutput> {
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<f64> = (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<f64> = (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<f64> = (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::<Vec<_>>()
);
}
#[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);
}
}
@@ -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<Self> {
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<MaEnvelopeOutput> {
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<f64> = (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<f64> = (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::<Vec<_>>()
);
}
#[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);
}
}
+22
View File
@@ -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;
@@ -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<f64>,
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<Self> {
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<StandardErrorBandsOutput> {
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<f64> = (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<f64> = (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<f64> = (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::<Vec<_>>()
);
}
#[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 n2 standard error must be strictly larger than the population
/// stddev (n divisor) on the same residuals — by the factor sqrt(n / (n2)).
#[test]
fn standard_error_exceeds_population_stddev() {
// Use n = 5 (factor = sqrt(5/3)) with non-trivial residuals.
let prices: Vec<f64> = 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, "n2 stderr must exceed n stddev");
}
}
@@ -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<Self> {
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<StarcBandsOutput> {
// 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<Candle> = (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<Candle> = (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<Candle> = (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::<Vec<_>>()
);
}
#[test]
fn reset_clears_state() {
let candles: Vec<Candle> = (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<Candle> = (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());
}
}
}
}
@@ -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<f64>,
lows: VecDeque<f64>,
closes: VecDeque<f64>,
// 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<Self> {
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<TtmSqueezeOutput> {
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<Candle> = (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<Candle> = (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::<Vec<_>>()
);
}
#[test]
fn reset_clears_state() {
let candles: Vec<Candle> = (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<Candle> = (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);
}
}
}
@@ -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<Self> {
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<VwapStdDevBandsOutput> {
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<Candle> = (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<Candle> = (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<Candle> = (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::<Vec<_>>()
);
}
#[test]
fn reset_clears_state() {
let candles: Vec<Candle> = (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);
}
}
+18 -14
View File
@@ -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};
+65 -3
View File
@@ -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<I, F, O>(c: &mut Criterion, name: &str, prices: &[f64], make: F)
where
F: Fn() -> I,
I: Indicator<Input = f64, Output = O>,
{
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);
+36 -5
View File
@@ -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<f64>| {
}
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);
}
});
+60 -8
View File
@@ -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<Candle>` by chunking it into
@@ -143,4 +144,55 @@ fuzz_target!(|data: Vec<f64>| {
}
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);
}
});