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:
@@ -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
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
]
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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 n−2 standard error must be strictly larger than the population
|
||||
/// stddev (n divisor) on the same residuals — by the factor sqrt(n / (n−2)).
|
||||
#[test]
|
||||
fn standard_error_exceeds_population_stddev() {
|
||||
// Use n = 5 (factor = sqrt(5/3)) with non-trivial residuals.
|
||||
let prices: Vec<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, "n−2 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);
|
||||
}
|
||||
}
|
||||
@@ -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};
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user