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
+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
}
}