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