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

* feat(bands-channels): add Family 05 with 11 indicators

Eleven price-envelope overlays organised into a new "Bands & Channels"
family, exposed across all four bindings (Rust core, Python, Node, WASM)
plus fuzz/test/bench/docs coverage:

- MaEnvelope - SMA centerline with fixed-percent envelope (the oldest
  band overlay still in regular use).
- AccelerationBands (Price Headley) - momentum-biased bands that widen
  with the bar's relative range (H - L) / (H + L).
- StarcBands (Stoller Average Range Channel) - SMA(close) +/- k*ATR;
  Keltner's SMA-centerline sibling.
- AtrBands - close-anchored envelope of width k*ATR; the standard
  volatility-targeting stop/target band.
- HurstChannel - SMA centerline wrapped by the rolling high-low range
  (Brian Millard / Hurst-cycle channel).
- LinRegChannel - rolling OLS endpoint +/- k * population stddev of the
  residuals; dispersion about the trend rather than the mean.
- StandardErrorBands - regression line +/- k * OLS standard error
  (denominator n - 2) for prediction-interval bands.
- DoubleBollinger (Kathy Lien) - two concentric BB envelopes
  (typically +/- 1 sigma and +/- 2 sigma) for the zone-partition setup.
- TtmSqueeze (John Carter) - BB-inside-KC squeeze flag paired with a
  detrended-close linear-regression momentum reading.
- FractalChaosBands - Bill Williams 5-bar fractal high/low envelope.
- VwapStdDevBands - cumulative VWAP with volume-weighted population
  standard deviation bands.

Each indicator ships:
- Core impl with the full Indicator trait, classic() where applicable,
  and unit tests (rejects_zero_period / multiplier, accessors, flat
  market, monotonic ordering, batch == streaming, reset, plus
  algebraically verifiable reference values).
- Python PyO3 binding with multi-column NumPy batch (PyArray2).
- Node napi binding with #[napi(object)] struct + interleaved flat
  batch.
- WASM wasm-bindgen binding via Object/Reflect for update +
  Float64Array for batch.
- Fuzz coverage in fuzz_targets/indicator_update{,_candle}.rs.
- Python streaming-vs-batch parametric test + reference test.
- Node streaming-vs-interleaved-batch test + reference test.
- Criterion microbench under crates/wickra/benches/indicators.rs.

README family table, README indicator-count line, and CHANGELOG
Unreleased entry updated: indicator total rises from 71 to 82 across
nine families. Wiki pages are updated in a separate commit in the
wickra.wiki repo.

* test(acceleration-bands): cover sum_hl==0 zero-price guard

Exercises line 104 (`0.0` branch of the `sum_hl == 0.0` guard) which
was the last patch-coverage miss on the family-05 PR. `Candle::new`
accepts a fully-zero bar so the branch is reachable in principle —
add a degenerate-candle unit test to hit it.
This commit is contained in:
kingchenc
2026-05-25 18:37:12 +02:00
committed by GitHub
parent 3ea0f12b7a
commit 54194a4ff8
25 changed files with 5482 additions and 33 deletions
@@ -0,0 +1,277 @@
//! Acceleration Bands (Price Headley).
use crate::error::{Error, Result};
use crate::indicators::sma::Sma;
use crate::ohlcv::Candle;
use crate::traits::Indicator;
/// Acceleration Bands output: SMA of close with momentum-biased envelopes
/// driven by the bar's high/low geometry.
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct AccelerationBandsOutput {
/// Upper band: SMA of `high · (1 + factor · (high low) / (high + low))`.
pub upper: f64,
/// Middle band: SMA of close.
pub middle: f64,
/// Lower band: SMA of `low · (1 factor · (high low) / (high + low))`.
pub lower: f64,
}
/// Acceleration Bands (Price Headley): SMA-smoothed bands that widen with each
/// bar's relative range `(high low) / (high + low)`.
///
/// ```text
/// ratio = (high low) / (high + low)
/// raw_up = high · (1 + factor · ratio)
/// raw_lo = low · (1 factor · ratio)
/// upper = SMA(raw_up, period)
/// middle = SMA(close, period)
/// lower = SMA(raw_lo, period)
/// ```
///
/// Headley's reference parameters are `period = 20`, `factor = 0.001` for
/// intraday equity markets — the geometric `ratio` term tends to scale on
/// fractional moves, so the literal `factor` is small. The bands compress in
/// quiet markets and flare on impulsive bars, making them a momentum-biased
/// alternative to the volatility-driven Bollinger or Keltner envelopes.
///
/// # Example
///
/// ```
/// use wickra_core::{AccelerationBands, Candle, Indicator};
///
/// let mut indicator = AccelerationBands::new(20, 0.001).unwrap();
/// let mut last = None;
/// for i in 0..40 {
/// let base = 100.0 + f64::from(i);
/// let candle =
/// Candle::new(base, base + 2.0, base - 2.0, base + 1.0, 10.0, i64::from(i)).unwrap();
/// last = indicator.update(candle);
/// }
/// assert!(last.is_some());
/// ```
#[derive(Debug, Clone)]
pub struct AccelerationBands {
upper_sma: Sma,
middle_sma: Sma,
lower_sma: Sma,
factor: f64,
period: usize,
}
impl AccelerationBands {
/// Construct a new Acceleration Bands indicator.
///
/// # Errors
/// Returns [`Error::PeriodZero`] if `period == 0` and
/// [`Error::NonPositiveMultiplier`] if `factor` is not strictly positive
/// and finite.
pub fn new(period: usize, factor: f64) -> Result<Self> {
if !factor.is_finite() || factor <= 0.0 {
return Err(Error::NonPositiveMultiplier);
}
Ok(Self {
upper_sma: Sma::new(period)?,
middle_sma: Sma::new(period)?,
lower_sma: Sma::new(period)?,
factor,
period,
})
}
/// Headley's classic configuration: `period = 20`, `factor = 0.001`.
pub fn classic() -> Self {
Self::new(20, 0.001).expect("classic Acceleration Bands parameters are valid")
}
/// Configured `(period, factor)`.
pub const fn parameters(&self) -> (usize, f64) {
(self.period, self.factor)
}
}
impl Indicator for AccelerationBands {
type Input = Candle;
type Output = AccelerationBandsOutput;
fn update(&mut self, candle: Candle) -> Option<AccelerationBandsOutput> {
// (high + low) == 0 is geometrically impossible for valid OHLC
// (high >= low and a zero-sum requires both equal to 0, which would
// make the bar degenerate). Guard anyway so a hypothetical zero-price
// bar collapses the ratio to zero rather than emitting NaN.
let sum_hl = candle.high + candle.low;
let ratio = if sum_hl == 0.0 {
0.0
} else {
(candle.high - candle.low) / sum_hl
};
let raw_up = candle.high * self.factor.mul_add(ratio, 1.0);
let raw_lo = candle.low * (-self.factor).mul_add(ratio, 1.0);
// Feed all three SMAs unconditionally so they warm up in lock-step.
let upper = self.upper_sma.update(raw_up);
let middle = self.middle_sma.update(candle.close);
let lower = self.lower_sma.update(raw_lo);
let (upper, middle, lower) = (upper?, middle?, lower?);
Some(AccelerationBandsOutput {
upper,
middle,
lower,
})
}
fn reset(&mut self) {
self.upper_sma.reset();
self.middle_sma.reset();
self.lower_sma.reset();
}
fn warmup_period(&self) -> usize {
self.period
}
fn is_ready(&self) -> bool {
self.middle_sma.is_ready()
}
fn name(&self) -> &'static str {
"AccelerationBands"
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::traits::BatchExt;
use approx::assert_relative_eq;
fn c(h: f64, l: f64, cl: f64) -> Candle {
Candle::new(cl, h, l, cl, 1.0, 0).unwrap()
}
#[test]
fn rejects_zero_period() {
assert!(matches!(
AccelerationBands::new(0, 0.001),
Err(Error::PeriodZero)
));
}
#[test]
fn rejects_non_positive_factor() {
assert!(matches!(
AccelerationBands::new(20, 0.0),
Err(Error::NonPositiveMultiplier)
));
assert!(matches!(
AccelerationBands::new(20, -1.0),
Err(Error::NonPositiveMultiplier)
));
assert!(matches!(
AccelerationBands::new(20, f64::NAN),
Err(Error::NonPositiveMultiplier)
));
}
#[test]
fn accessors_and_metadata() {
let ab = AccelerationBands::classic();
let (p, f) = ab.parameters();
assert_eq!(p, 20);
assert_relative_eq!(f, 0.001, epsilon = 1e-12);
assert_eq!(ab.warmup_period(), 20);
assert_eq!(ab.name(), "AccelerationBands");
}
#[test]
fn flat_market_collapses_to_constant() {
// high == low so the ratio term is zero; all three SMAs converge to
// the same constant.
let candles: Vec<Candle> = (0..30).map(|_| c(10.0, 10.0, 10.0)).collect();
let mut ab = AccelerationBands::new(5, 0.5).unwrap();
let last = ab.batch(&candles).into_iter().flatten().last().unwrap();
assert_relative_eq!(last.middle, 10.0, epsilon = 1e-9);
assert_relative_eq!(last.upper, 10.0, epsilon = 1e-9);
assert_relative_eq!(last.lower, 10.0, epsilon = 1e-9);
}
#[test]
fn warmup_returns_none() {
let mut ab = AccelerationBands::new(5, 0.001).unwrap();
for i in 0..4 {
let base = 100.0 + f64::from(i);
assert!(ab.update(c(base + 1.0, base - 1.0, base)).is_none());
}
assert!(ab.update(c(105.0, 103.0, 104.0)).is_some());
}
#[test]
fn upper_above_middle_above_lower() {
let candles: Vec<Candle> = (0..50)
.map(|i| {
let m = 100.0 + (f64::from(i) * 0.2).sin() * 5.0;
c(m + 1.0, m - 1.0, m)
})
.collect();
let mut ab = AccelerationBands::new(20, 0.5).unwrap();
for o in ab.batch(&candles).into_iter().flatten() {
assert!(o.upper >= o.middle, "{} < {}", o.upper, o.middle);
assert!(o.middle >= o.lower, "{} < {}", o.middle, o.lower);
}
}
#[test]
fn batch_equals_streaming() {
let candles: Vec<Candle> = (0..40)
.map(|i| c(f64::from(i) + 2.0, f64::from(i), f64::from(i) + 1.0))
.collect();
let mut a = AccelerationBands::new(10, 0.5).unwrap();
let mut b = AccelerationBands::new(10, 0.5).unwrap();
assert_eq!(
a.batch(&candles),
candles.iter().map(|x| b.update(*x)).collect::<Vec<_>>()
);
}
#[test]
fn reset_clears_state() {
let candles: Vec<Candle> = (0..10)
.map(|i| c(f64::from(i) + 2.0, f64::from(i), f64::from(i) + 1.0))
.collect();
let mut ab = AccelerationBands::new(5, 0.5).unwrap();
ab.batch(&candles);
assert!(ab.is_ready());
ab.reset();
assert!(!ab.is_ready());
assert_eq!(ab.update(candles[0]), None);
}
#[test]
fn zero_price_candle_collapses_ratio_to_zero() {
// `high + low == 0` is geometrically only reachable with a fully-zero
// bar (high >= low and both non-negative for a real market, but
// `Candle::new` accepts the degenerate `(0, 0, 0, 0)` case). The
// ratio guard must fire and the bands all collapse to zero.
let zero = Candle::new(0.0, 0.0, 0.0, 0.0, 1.0, 0).unwrap();
let mut ab = AccelerationBands::new(1, 0.5).unwrap();
let v = ab.update(zero).unwrap();
assert_relative_eq!(v.upper, 0.0, epsilon = 1e-12);
assert_relative_eq!(v.middle, 0.0, epsilon = 1e-12);
assert_relative_eq!(v.lower, 0.0, epsilon = 1e-12);
}
/// Hand-computed reference. Single bar with `high = 12`, `low = 8`,
/// `close = 10`, `factor = 0.5`, `period = 1`.
/// `ratio = (12 8) / (12 + 8) = 0.2`
/// `raw_up = 12 · (1 + 0.5 · 0.2) = 12 · 1.1 = 13.2`
/// `raw_lo = 8 · (1 0.5 · 0.2) = 8 · 0.9 = 7.2`
/// `middle = SMA(close, 1) = 10`
#[test]
fn reference_value_single_bar() {
let mut ab = AccelerationBands::new(1, 0.5).unwrap();
let v = ab.update(c(12.0, 8.0, 10.0)).unwrap();
assert_relative_eq!(v.upper, 13.2, epsilon = 1e-12);
assert_relative_eq!(v.middle, 10.0, epsilon = 1e-12);
assert_relative_eq!(v.lower, 7.2, epsilon = 1e-12);
}
}
@@ -0,0 +1,214 @@
//! ATR Bands.
use crate::error::{Error, Result};
use crate::indicators::atr::Atr;
use crate::ohlcv::Candle;
use crate::traits::Indicator;
/// ATR Bands output.
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct AtrBandsOutput {
/// Upper band: `close + multiplier · ATR`.
pub upper: f64,
/// Middle band: the current close.
pub middle: f64,
/// Lower band: `close multiplier · ATR`.
pub lower: f64,
}
/// ATR Bands: a close-anchored envelope of width `multiplier · ATR`.
///
/// ```text
/// upper = close + multiplier · ATR(period)
/// lower = close multiplier · ATR(period)
/// ```
///
/// Unlike [`Keltner`](crate::Keltner) or [`StarcBands`](crate::StarcBands), the
/// centerline is the *raw close* rather than a smoothed average — the band
/// rides the price tick-for-tick. This is the standard volatility-targeting
/// envelope traders use to set initial stop-loss and profit targets: an entry
/// at the close sets a `multiplier · ATR` stop and the symmetric target
/// without ever needing to wait for a moving average to warm up.
///
/// # Example
///
/// ```
/// use wickra_core::{AtrBands, Candle, Indicator};
///
/// let mut indicator = AtrBands::new(14, 3.0).unwrap();
/// let mut last = None;
/// for i in 0..30 {
/// let base = 100.0 + f64::from(i);
/// let candle =
/// Candle::new(base, base + 2.0, base - 2.0, base + 1.0, 10.0, i64::from(i)).unwrap();
/// last = indicator.update(candle);
/// }
/// assert!(last.is_some());
/// ```
#[derive(Debug, Clone)]
pub struct AtrBands {
atr: Atr,
multiplier: f64,
}
impl AtrBands {
/// # Errors
/// Returns [`Error::PeriodZero`] / [`Error::NonPositiveMultiplier`] on
/// invalid inputs.
pub fn new(period: usize, multiplier: f64) -> Result<Self> {
if !multiplier.is_finite() || multiplier <= 0.0 {
return Err(Error::NonPositiveMultiplier);
}
Ok(Self {
atr: Atr::new(period)?,
multiplier,
})
}
/// Configured ATR period.
pub const fn period(&self) -> usize {
self.atr.period()
}
/// Configured ATR multiplier.
pub const fn multiplier(&self) -> f64 {
self.multiplier
}
}
impl Indicator for AtrBands {
type Input = Candle;
type Output = AtrBandsOutput;
fn update(&mut self, candle: Candle) -> Option<AtrBandsOutput> {
let atr = self.atr.update(candle)?;
Some(AtrBandsOutput {
upper: candle.close + self.multiplier * atr,
middle: candle.close,
lower: candle.close - self.multiplier * atr,
})
}
fn reset(&mut self) {
self.atr.reset();
}
fn warmup_period(&self) -> usize {
self.atr.warmup_period()
}
fn is_ready(&self) -> bool {
self.atr.is_ready()
}
fn name(&self) -> &'static str {
"AtrBands"
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::traits::BatchExt;
use approx::assert_relative_eq;
fn c(h: f64, l: f64, cl: f64) -> Candle {
Candle::new(cl, h, l, cl, 1.0, 0).unwrap()
}
#[test]
fn rejects_zero_period() {
assert!(matches!(AtrBands::new(0, 3.0), Err(Error::PeriodZero)));
}
#[test]
fn rejects_non_positive_multiplier() {
assert!(matches!(
AtrBands::new(14, 0.0),
Err(Error::NonPositiveMultiplier)
));
assert!(matches!(
AtrBands::new(14, -1.0),
Err(Error::NonPositiveMultiplier)
));
assert!(matches!(
AtrBands::new(14, f64::INFINITY),
Err(Error::NonPositiveMultiplier)
));
}
#[test]
fn accessors_and_metadata() {
let ab = AtrBands::new(14, 3.0).unwrap();
assert_eq!(ab.period(), 14);
assert_relative_eq!(ab.multiplier(), 3.0, epsilon = 1e-12);
assert_eq!(ab.warmup_period(), 14);
assert_eq!(ab.name(), "AtrBands");
}
#[test]
fn flat_market_collapses_bands() {
let candles: Vec<Candle> = (0..30).map(|_| c(10.0, 10.0, 10.0)).collect();
let mut ab = AtrBands::new(5, 3.0).unwrap();
let last = ab.batch(&candles).into_iter().flatten().last().unwrap();
assert_relative_eq!(last.upper, 10.0, epsilon = 1e-9);
assert_relative_eq!(last.middle, 10.0, epsilon = 1e-9);
assert_relative_eq!(last.lower, 10.0, epsilon = 1e-9);
}
#[test]
fn upper_above_middle_above_lower() {
let candles: Vec<Candle> = (0..50)
.map(|i| {
let m = 100.0 + (f64::from(i) * 0.2).sin() * 5.0;
c(m + 1.0, m - 1.0, m)
})
.collect();
let mut ab = AtrBands::new(14, 3.0).unwrap();
for o in ab.batch(&candles).into_iter().flatten() {
assert!(o.upper >= o.middle);
assert!(o.middle >= o.lower);
}
}
#[test]
fn batch_equals_streaming() {
let candles: Vec<Candle> = (0..40)
.map(|i| c(f64::from(i) + 2.0, f64::from(i), f64::from(i) + 1.0))
.collect();
let mut a = AtrBands::new(10, 2.5).unwrap();
let mut b = AtrBands::new(10, 2.5).unwrap();
assert_eq!(
a.batch(&candles),
candles.iter().map(|x| b.update(*x)).collect::<Vec<_>>()
);
}
#[test]
fn reset_clears_state() {
let candles: Vec<Candle> = (0..20)
.map(|i| c(f64::from(i) + 1.0, f64::from(i) - 1.0, f64::from(i)))
.collect();
let mut ab = AtrBands::new(5, 3.0).unwrap();
ab.batch(&candles);
assert!(ab.is_ready());
ab.reset();
assert!(!ab.is_ready());
assert_eq!(ab.update(candles[0]), None);
}
/// Reference: with constant high-low spread of 2, ATR(period) converges to
/// 2 immediately; for multiplier 3 the bands are at `close ± 6`.
#[test]
fn reference_values_constant_spread() {
// Five identical candles with TR = 2 each: ATR seeds to 2 on bar 5.
let candles: Vec<Candle> = (0..5).map(|_| c(11.0, 9.0, 10.0)).collect();
let mut ab = AtrBands::new(5, 3.0).unwrap();
let out = ab.batch(&candles);
assert!(out[0].is_none() && out[3].is_none());
let v = out[4].unwrap();
assert_relative_eq!(v.middle, 10.0, epsilon = 1e-9);
assert_relative_eq!(v.upper, 16.0, epsilon = 1e-9);
assert_relative_eq!(v.lower, 4.0, epsilon = 1e-9);
}
}
@@ -0,0 +1,261 @@
//! Double Bollinger Bands (Kathy Lien).
use crate::error::{Error, Result};
use crate::indicators::bollinger::BollingerBands;
use crate::traits::Indicator;
/// Double Bollinger Bands output: two concentric bands at `k_inner` and
/// `k_outer` standard deviations around a shared SMA middle.
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct DoubleBollingerOutput {
/// Outer upper band: `middle + k_outer · stddev`.
pub upper_outer: f64,
/// Inner upper band: `middle + k_inner · stddev`.
pub upper_inner: f64,
/// Middle band: SMA over the window.
pub middle: f64,
/// Inner lower band: `middle k_inner · stddev`.
pub lower_inner: f64,
/// Outer lower band: `middle k_outer · stddev`.
pub lower_outer: f64,
}
/// Double Bollinger Bands: two concentric Bollinger envelopes (Kathy Lien).
///
/// ```text
/// middle = SMA(period)
/// sigma = population stddev over the window
/// upper_outer = middle + k_outer · sigma // wide channel (often 2σ)
/// upper_inner = middle + k_inner · sigma // narrow channel (often 1σ)
/// lower_inner = middle k_inner · sigma
/// lower_outer = middle k_outer · sigma
/// ```
///
/// Lien's trading framework partitions price into three zones:
///
/// - **Sell zone:** close below `lower_inner`.
/// - **Neutral zone:** close between `lower_inner` and `upper_inner`.
/// - **Buy zone:** close above `upper_inner`.
///
/// A close beyond the outer band marks an extended move that traders typically
/// fade or trail. The constructor enforces `k_outer > k_inner` so the outputs
/// remain monotonically ordered.
///
/// # Example
///
/// ```
/// use wickra_core::{DoubleBollinger, Indicator};
///
/// let mut indicator = DoubleBollinger::new(20, 1.0, 2.0).unwrap();
/// let mut last = None;
/// for i in 0..40 {
/// last = indicator.update(100.0 + (f64::from(i) * 0.3).sin() * 6.0);
/// }
/// assert!(last.is_some());
/// ```
#[derive(Debug, Clone)]
pub struct DoubleBollinger {
inner: BollingerBands,
k_inner: f64,
k_outer: f64,
}
impl DoubleBollinger {
/// Construct a new Double Bollinger Bands indicator.
///
/// # Errors
/// Returns [`Error::PeriodZero`] if `period == 0`,
/// [`Error::NonPositiveMultiplier`] if either `k_inner` or `k_outer` is
/// non-positive or non-finite, and [`Error::InvalidPeriod`] if
/// `k_outer <= k_inner` (the outer band must strictly enclose the inner
/// band so the zone-partitioning interpretation holds).
pub fn new(period: usize, k_inner: f64, k_outer: f64) -> Result<Self> {
if !k_inner.is_finite() || k_inner <= 0.0 || !k_outer.is_finite() || k_outer <= 0.0 {
return Err(Error::NonPositiveMultiplier);
}
if k_outer <= k_inner {
return Err(Error::InvalidPeriod {
message: "double bollinger requires k_outer > k_inner",
});
}
// Build the inner state on the outer multiplier so the upper/lower
// outputs of `BollingerBands::update` already give us the outer band;
// the inner band is reconstructed from the same `stddev`.
Ok(Self {
inner: BollingerBands::new(period, k_outer)?,
k_inner,
k_outer,
})
}
/// Kathy Lien's classic configuration: SMA(20) with `±1σ` and `±2σ` bands.
pub fn classic() -> Self {
Self::new(20, 1.0, 2.0).expect("classic Double Bollinger parameters are valid")
}
/// Configured `(period, k_inner, k_outer)`.
pub const fn parameters(&self) -> (usize, f64, f64) {
(self.inner.period(), self.k_inner, self.k_outer)
}
}
impl Indicator for DoubleBollinger {
type Input = f64;
type Output = DoubleBollingerOutput;
fn update(&mut self, value: f64) -> Option<DoubleBollingerOutput> {
let o = self.inner.update(value)?;
Some(DoubleBollingerOutput {
upper_outer: o.upper,
upper_inner: o.middle + self.k_inner * o.stddev,
middle: o.middle,
lower_inner: o.middle - self.k_inner * o.stddev,
lower_outer: o.lower,
})
}
fn reset(&mut self) {
self.inner.reset();
}
fn warmup_period(&self) -> usize {
self.inner.warmup_period()
}
fn is_ready(&self) -> bool {
self.inner.is_ready()
}
fn name(&self) -> &'static str {
"DoubleBollinger"
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::traits::BatchExt;
use approx::assert_relative_eq;
#[test]
fn rejects_zero_period() {
assert!(matches!(
DoubleBollinger::new(0, 1.0, 2.0),
Err(Error::PeriodZero)
));
}
#[test]
fn rejects_non_positive_multiplier() {
assert!(matches!(
DoubleBollinger::new(20, 0.0, 2.0),
Err(Error::NonPositiveMultiplier)
));
assert!(matches!(
DoubleBollinger::new(20, 1.0, -2.0),
Err(Error::NonPositiveMultiplier)
));
assert!(matches!(
DoubleBollinger::new(20, f64::NAN, 2.0),
Err(Error::NonPositiveMultiplier)
));
}
#[test]
fn rejects_outer_not_greater_than_inner() {
assert!(matches!(
DoubleBollinger::new(20, 2.0, 1.0),
Err(Error::InvalidPeriod { .. })
));
assert!(matches!(
DoubleBollinger::new(20, 2.0, 2.0),
Err(Error::InvalidPeriod { .. })
));
}
#[test]
fn accessors_and_metadata() {
let db = DoubleBollinger::classic();
let (p, ki, ko) = db.parameters();
assert_eq!(p, 20);
assert_relative_eq!(ki, 1.0, epsilon = 1e-12);
assert_relative_eq!(ko, 2.0, epsilon = 1e-12);
assert_eq!(db.warmup_period(), 20);
assert_eq!(db.name(), "DoubleBollinger");
}
#[test]
fn constant_series_collapses_all_bands() {
let mut db = DoubleBollinger::new(10, 1.0, 2.0).unwrap();
let last = db
.batch(&[5.0_f64; 20])
.into_iter()
.flatten()
.last()
.unwrap();
assert_relative_eq!(last.middle, 5.0, epsilon = 1e-12);
assert_relative_eq!(last.upper_outer, 5.0, epsilon = 1e-12);
assert_relative_eq!(last.upper_inner, 5.0, epsilon = 1e-12);
assert_relative_eq!(last.lower_inner, 5.0, epsilon = 1e-12);
assert_relative_eq!(last.lower_outer, 5.0, epsilon = 1e-12);
}
#[test]
fn bands_strictly_ordered_with_dispersion() {
let prices: Vec<f64> = (0..80)
.map(|i| 100.0 + (f64::from(i) * 0.3).sin() * 6.0)
.collect();
let mut db = DoubleBollinger::classic();
for o in db.batch(&prices).into_iter().flatten() {
assert!(o.upper_outer >= o.upper_inner);
assert!(o.upper_inner >= o.middle);
assert!(o.middle >= o.lower_inner);
assert!(o.lower_inner >= o.lower_outer);
}
}
#[test]
fn batch_equals_streaming() {
let prices: Vec<f64> = (0..50).map(|i| f64::from(i) * 0.7).collect();
let mut a = DoubleBollinger::new(10, 1.0, 2.0).unwrap();
let mut b = DoubleBollinger::new(10, 1.0, 2.0).unwrap();
assert_eq!(
a.batch(&prices),
prices.iter().map(|p| b.update(*p)).collect::<Vec<_>>()
);
}
#[test]
fn reset_clears_state() {
let mut db = DoubleBollinger::new(5, 1.0, 2.0).unwrap();
db.batch(&[1.0, 2.0, 3.0, 4.0, 5.0]);
assert!(db.is_ready());
db.reset();
assert!(!db.is_ready());
assert_eq!(db.update(1.0), None);
}
/// The inner band must agree with running a separate `BollingerBands` at
/// the inner multiplier.
#[test]
fn inner_band_matches_separate_bollinger() {
let prices: Vec<f64> = (0..80)
.map(|i| 100.0 + (f64::from(i) * 0.3).sin() * 6.0)
.collect();
let mut db = DoubleBollinger::new(20, 1.0, 2.0).unwrap();
let mut bb_inner = BollingerBands::new(20, 1.0).unwrap();
let mut bb_outer = BollingerBands::new(20, 2.0).unwrap();
for p in &prices {
let d = db.update(*p);
let i = bb_inner.update(*p);
let o = bb_outer.update(*p);
if let (Some(d), Some(i), Some(o)) = (d, i, o) {
assert_relative_eq!(d.middle, i.middle, epsilon = 1e-9);
assert_relative_eq!(d.upper_inner, i.upper, epsilon = 1e-9);
assert_relative_eq!(d.lower_inner, i.lower, epsilon = 1e-9);
assert_relative_eq!(d.upper_outer, o.upper, epsilon = 1e-9);
assert_relative_eq!(d.lower_outer, o.lower, epsilon = 1e-9);
}
}
}
}
@@ -0,0 +1,271 @@
//! Fractal Chaos Bands (Bill Williams Fractals).
use std::collections::VecDeque;
use crate::error::{Error, Result};
use crate::ohlcv::Candle;
use crate::traits::Indicator;
/// Fractal Chaos Bands output.
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct FractalChaosBandsOutput {
/// Upper band: high of the most recent confirmed fractal high.
pub upper: f64,
/// Lower band: low of the most recent confirmed fractal low.
pub lower: f64,
}
/// Fractal Chaos Bands: a step-function envelope of the most recent Bill
/// Williams fractal highs and lows.
///
/// A bar is a **fractal high** when its high is the maximum of the window
/// `[i k, …, i + k]`. A **fractal low** is defined symmetrically on lows.
/// The bands hold the high (low) of the latest confirmed fractal high (low),
/// stepping outwards whenever a new fractal forms and otherwise staying flat:
///
/// ```text
/// confirmation_lag = k // the centre bar is known only k bars later
/// upper = high of the most recent confirmed fractal high
/// lower = low of the most recent confirmed fractal low
/// ```
///
/// `k = 2` (5-bar fractals) is the canonical Williams setting and matches the
/// "Fractal Chaos Bands" oscillator shipped with several chart vendors. With
/// `k` bars of look-ahead, every band update reflects price `k` bars ago —
/// strict streaming preserves this lag rather than peeking into the future.
///
/// # Example
///
/// ```
/// use wickra_core::{Candle, FractalChaosBands, Indicator};
///
/// let mut indicator = FractalChaosBands::new(2).unwrap();
/// let mut last = None;
/// for i in 0..30 {
/// let base = 100.0 + (f64::from(i) * 0.5).sin() * 5.0;
/// let candle =
/// Candle::new(base, base + 1.0, base - 1.0, base, 10.0, i64::from(i)).unwrap();
/// last = indicator.update(candle);
/// }
/// // Confirmation requires `2k + 1` bars plus at least one fractal of each
/// // kind, so `last` may legitimately be `None` on a single sweep without
/// // both a peak and a trough in the window.
/// let _ = last;
/// ```
#[derive(Debug, Clone)]
pub struct FractalChaosBands {
k: usize,
window: VecDeque<Candle>,
last_upper: Option<f64>,
last_lower: Option<f64>,
}
impl FractalChaosBands {
/// Construct a new Fractal Chaos Bands indicator with the given fractal
/// half-width `k` (a bar is a fractal high if its high exceeds the highs
/// of the `k` bars on either side; canonical `k = 2`).
///
/// # Errors
/// Returns [`Error::PeriodZero`] if `k == 0` (a single bar is always its
/// own trivial fractal).
pub fn new(k: usize) -> Result<Self> {
if k == 0 {
return Err(Error::PeriodZero);
}
Ok(Self {
k,
window: VecDeque::with_capacity(2 * k + 1),
last_upper: None,
last_lower: None,
})
}
/// Canonical Bill Williams configuration: `k = 2` (5-bar fractals).
pub fn classic() -> Self {
Self::new(2).expect("classic Fractal Chaos Bands parameters are valid")
}
/// Configured half-width `k`.
pub const fn k(&self) -> usize {
self.k
}
}
impl Indicator for FractalChaosBands {
type Input = Candle;
type Output = FractalChaosBandsOutput;
fn update(&mut self, candle: Candle) -> Option<FractalChaosBandsOutput> {
let window_len = 2 * self.k + 1;
if self.window.len() == window_len {
self.window.pop_front();
}
self.window.push_back(candle);
if self.window.len() < window_len {
return None;
}
// The centre bar is at index `k`. Strictly compare against the `k`
// bars on either side: `>` for the high and `<` for the low (a ties-
// included pattern would fire on flat tops/bottoms, against Williams'
// intent).
let center = &self.window[self.k];
let mut is_high = true;
let mut is_low = true;
for (i, c) in self.window.iter().enumerate() {
if i == self.k {
continue;
}
if c.high >= center.high {
is_high = false;
}
if c.low <= center.low {
is_low = false;
}
}
if is_high {
self.last_upper = Some(center.high);
}
if is_low {
self.last_lower = Some(center.low);
}
// Both bands must have been seen at least once before we can emit.
match (self.last_upper, self.last_lower) {
(Some(u), Some(l)) => Some(FractalChaosBandsOutput { upper: u, lower: l }),
_ => None,
}
}
fn reset(&mut self) {
self.window.clear();
self.last_upper = None;
self.last_lower = None;
}
fn warmup_period(&self) -> usize {
2 * self.k + 1
}
fn is_ready(&self) -> bool {
self.last_upper.is_some() && self.last_lower.is_some()
}
fn name(&self) -> &'static str {
"FractalChaosBands"
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::traits::BatchExt;
use approx::assert_relative_eq;
fn c(h: f64, l: f64, cl: f64) -> Candle {
Candle::new(cl, h, l, cl, 1.0, 0).unwrap()
}
#[test]
fn rejects_zero_k() {
assert!(matches!(FractalChaosBands::new(0), Err(Error::PeriodZero)));
}
#[test]
fn accessors_and_metadata() {
let f = FractalChaosBands::classic();
assert_eq!(f.k(), 2);
assert_eq!(f.warmup_period(), 5);
assert_eq!(f.name(), "FractalChaosBands");
}
/// Detect a single peak and a single trough with `k = 2`.
/// Bars (high, low, close): (1,1,1), (2,2,2), (5,3,4), (3,1,2),
/// (2,2,2), (1,1,1), (2,2,2), (5,3,4).
/// Indices: 0..7. The peak at i=2 is `>` its 2 neighbours on each side
/// (after index 4 lands). The trough at i=3 is `<` its 2 neighbours on
/// each side (after index 5 lands). Both bands first emit on index 5.
#[test]
fn detects_simple_peak_and_trough() {
let candles = vec![
c(1.0, 1.0, 1.0),
c(2.0, 2.0, 2.0),
c(5.0, 3.0, 4.0), // peak: high 5 is the max of neighbouring 4
c(3.0, 0.5, 1.0), // trough: low 0.5 is the min
c(2.0, 2.0, 2.0),
c(1.0, 1.0, 1.0),
c(2.0, 2.0, 2.0),
];
let mut f = FractalChaosBands::new(2).unwrap();
let out = f.batch(&candles);
// Bars 0..4 are warmup or single-band only — both bands haven't been
// confirmed yet.
for v in out.iter().take(5) {
assert!(v.is_none());
}
// Bar 5 confirms the trough at i=3 (low 0.5); the peak at i=2 was
// confirmed by bar 4 (centre 2, look-ahead 2 → index 4). So index 5
// is the first bar with *both* upper and lower set.
let v = out[5].unwrap();
assert_relative_eq!(v.upper, 5.0, epsilon = 1e-12);
assert_relative_eq!(v.lower, 0.5, epsilon = 1e-12);
}
/// In a flat market no bar is strictly higher (or lower) than its
/// neighbours, so no fractal ever confirms and the indicator never emits.
#[test]
fn flat_market_never_emits() {
let candles: Vec<Candle> = (0..30).map(|_| c(10.0, 10.0, 10.0)).collect();
let mut f = FractalChaosBands::new(2).unwrap();
for v in f.batch(&candles) {
assert!(v.is_none());
}
}
#[test]
fn batch_equals_streaming() {
let candles: Vec<Candle> = (0..40)
.map(|i| {
let m = 100.0 + (f64::from(i) * 0.5).sin() * 3.0;
c(m + 1.0, m - 1.0, m)
})
.collect();
let mut a = FractalChaosBands::new(2).unwrap();
let mut b = FractalChaosBands::new(2).unwrap();
assert_eq!(
a.batch(&candles),
candles.iter().map(|x| b.update(*x)).collect::<Vec<_>>()
);
}
#[test]
fn reset_clears_state() {
let candles = vec![
c(1.0, 1.0, 1.0),
c(2.0, 2.0, 2.0),
c(5.0, 3.0, 4.0),
c(3.0, 0.5, 1.0),
c(2.0, 2.0, 2.0),
c(1.0, 1.0, 1.0),
c(2.0, 2.0, 2.0),
];
let mut f = FractalChaosBands::new(2).unwrap();
f.batch(&candles);
assert!(f.is_ready());
f.reset();
assert!(!f.is_ready());
assert_eq!(f.update(candles[0]), None);
}
#[test]
fn upper_above_lower_when_both_set() {
let candles: Vec<Candle> = (0..60)
.map(|i| {
let m = 100.0 + (f64::from(i) * 0.4).sin() * 5.0;
c(m + 1.0, m - 1.0, m)
})
.collect();
let mut f = FractalChaosBands::new(2).unwrap();
for o in f.batch(&candles).into_iter().flatten() {
assert!(o.upper >= o.lower);
}
}
}
@@ -0,0 +1,236 @@
//! Hurst Channel (Brian Millard / Hurst-cycle channel).
use std::collections::VecDeque;
use crate::error::{Error, Result};
use crate::indicators::sma::Sma;
use crate::ohlcv::Candle;
use crate::traits::Indicator;
/// Hurst Channel output.
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct HurstChannelOutput {
/// Upper channel: `middle + multiplier · (highest_high lowest_low)`.
pub upper: f64,
/// Middle line: SMA of close over the period.
pub middle: f64,
/// Lower channel: `middle multiplier · (highest_high lowest_low)`.
pub lower: f64,
}
/// Hurst Channel: an SMA centerline wrapped by a rolling high-low range.
///
/// ```text
/// middle = SMA(close, period)
/// range = max(high, period) min(low, period)
/// upper = middle + multiplier · range
/// lower = middle multiplier · range
/// ```
///
/// The Hurst Channel sizes its envelope by the *realised* high-low range of
/// the window — a simpler, range-based volatility proxy than Bollinger's
/// rolling stddev or Keltner's ATR. With a `multiplier` of `0.5` the channel
/// reduces to a centerline that hugs the midpoint of the Donchian envelope;
/// chart vendors that follow Hurst's cycle work commonly use `period = 10` and
/// `multiplier = 0.5` for the "inner" channel.
///
/// # Example
///
/// ```
/// use wickra_core::{Candle, HurstChannel, Indicator};
///
/// let mut indicator = HurstChannel::new(10, 0.5).unwrap();
/// let mut last = None;
/// for i in 0..30 {
/// let base = 100.0 + f64::from(i);
/// let candle =
/// Candle::new(base, base + 2.0, base - 2.0, base + 1.0, 10.0, i64::from(i)).unwrap();
/// last = indicator.update(candle);
/// }
/// assert!(last.is_some());
/// ```
#[derive(Debug, Clone)]
pub struct HurstChannel {
period: usize,
multiplier: f64,
sma: Sma,
highs: VecDeque<f64>,
lows: VecDeque<f64>,
}
impl HurstChannel {
/// # Errors
/// Returns [`Error::PeriodZero`] / [`Error::NonPositiveMultiplier`] on
/// invalid inputs.
pub fn new(period: usize, multiplier: f64) -> Result<Self> {
if !multiplier.is_finite() || multiplier <= 0.0 {
return Err(Error::NonPositiveMultiplier);
}
Ok(Self {
period,
multiplier,
sma: Sma::new(period)?,
highs: VecDeque::with_capacity(period),
lows: VecDeque::with_capacity(period),
})
}
/// Configured period.
pub const fn period(&self) -> usize {
self.period
}
/// Configured range multiplier.
pub const fn multiplier(&self) -> f64 {
self.multiplier
}
}
impl Indicator for HurstChannel {
type Input = Candle;
type Output = HurstChannelOutput;
fn update(&mut self, candle: Candle) -> Option<HurstChannelOutput> {
if self.highs.len() == self.period {
self.highs.pop_front();
self.lows.pop_front();
}
self.highs.push_back(candle.high);
self.lows.push_back(candle.low);
let middle = self.sma.update(candle.close)?;
let hi = self.highs.iter().copied().fold(f64::NEG_INFINITY, f64::max);
let lo = self.lows.iter().copied().fold(f64::INFINITY, f64::min);
let range = hi - lo;
Some(HurstChannelOutput {
upper: middle + self.multiplier * range,
middle,
lower: middle - self.multiplier * range,
})
}
fn reset(&mut self) {
self.sma.reset();
self.highs.clear();
self.lows.clear();
}
fn warmup_period(&self) -> usize {
self.period
}
fn is_ready(&self) -> bool {
self.sma.is_ready()
}
fn name(&self) -> &'static str {
"HurstChannel"
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::traits::BatchExt;
use approx::assert_relative_eq;
fn c(h: f64, l: f64, cl: f64) -> Candle {
Candle::new(cl, h, l, cl, 1.0, 0).unwrap()
}
#[test]
fn rejects_zero_period() {
assert!(matches!(HurstChannel::new(0, 0.5), Err(Error::PeriodZero)));
}
#[test]
fn rejects_non_positive_multiplier() {
assert!(matches!(
HurstChannel::new(10, 0.0),
Err(Error::NonPositiveMultiplier)
));
assert!(matches!(
HurstChannel::new(10, -0.5),
Err(Error::NonPositiveMultiplier)
));
assert!(matches!(
HurstChannel::new(10, f64::NAN),
Err(Error::NonPositiveMultiplier)
));
}
#[test]
fn accessors_and_metadata() {
let h = HurstChannel::new(10, 0.5).unwrap();
assert_eq!(h.period(), 10);
assert_relative_eq!(h.multiplier(), 0.5, epsilon = 1e-12);
assert_eq!(h.warmup_period(), 10);
assert_eq!(h.name(), "HurstChannel");
}
#[test]
fn flat_market_collapses_bands() {
let candles: Vec<Candle> = (0..20).map(|_| c(10.0, 10.0, 10.0)).collect();
let mut h = HurstChannel::new(5, 0.5).unwrap();
let last = h.batch(&candles).into_iter().flatten().last().unwrap();
assert_relative_eq!(last.upper, 10.0, epsilon = 1e-9);
assert_relative_eq!(last.middle, 10.0, epsilon = 1e-9);
assert_relative_eq!(last.lower, 10.0, epsilon = 1e-9);
}
#[test]
fn upper_above_middle_above_lower() {
let candles: Vec<Candle> = (0..50)
.map(|i| {
let m = 100.0 + (f64::from(i) * 0.2).sin() * 5.0;
c(m + 1.0, m - 1.0, m)
})
.collect();
let mut h = HurstChannel::new(10, 0.5).unwrap();
for o in h.batch(&candles).into_iter().flatten() {
assert!(o.upper >= o.middle);
assert!(o.middle >= o.lower);
}
}
#[test]
fn batch_equals_streaming() {
let candles: Vec<Candle> = (0..40)
.map(|i| c(f64::from(i) + 2.0, f64::from(i), f64::from(i) + 1.0))
.collect();
let mut a = HurstChannel::new(10, 0.5).unwrap();
let mut b = HurstChannel::new(10, 0.5).unwrap();
assert_eq!(
a.batch(&candles),
candles.iter().map(|x| b.update(*x)).collect::<Vec<_>>()
);
}
#[test]
fn reset_clears_state() {
let candles: Vec<Candle> = (0..10)
.map(|i| c(f64::from(i) + 1.0, f64::from(i) - 1.0, f64::from(i)))
.collect();
let mut h = HurstChannel::new(5, 0.5).unwrap();
h.batch(&candles);
assert!(h.is_ready());
h.reset();
assert!(!h.is_ready());
assert_eq!(h.update(candles[0]), None);
}
/// Reference: five identical candles `(high=12, low=8, close=10)`:
/// SMA(close, 5) = 10, range = 12 8 = 4, multiplier = 0.5
/// upper = 10 + 0.5·4 = 12, lower = 10 0.5·4 = 8.
#[test]
fn reference_values() {
let candles: Vec<Candle> = (0..5).map(|_| c(12.0, 8.0, 10.0)).collect();
let mut h = HurstChannel::new(5, 0.5).unwrap();
let out = h.batch(&candles);
assert!(out[0].is_none() && out[3].is_none());
let v = out[4].unwrap();
assert_relative_eq!(v.middle, 10.0, epsilon = 1e-9);
assert_relative_eq!(v.upper, 12.0, epsilon = 1e-9);
assert_relative_eq!(v.lower, 8.0, epsilon = 1e-9);
}
}
@@ -0,0 +1,266 @@
//! Linear Regression Channel — OLS endpoint ± k · stddev of residuals.
use std::collections::VecDeque;
use crate::error::{Error, Result};
use crate::traits::Indicator;
/// Linear Regression Channel output.
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct LinRegChannelOutput {
/// Upper channel: regression endpoint plus `multiplier · stddev` of the
/// residuals.
pub upper: f64,
/// Middle line: OLS endpoint over the window.
pub middle: f64,
/// Lower channel: regression endpoint minus `multiplier · stddev` of the
/// residuals.
pub lower: f64,
}
/// Linear Regression Channel: rolling least-squares line with `±k·σ` bands
/// sized by the residuals about the fitted line.
///
/// ```text
/// fit y = a + b·x by OLS over the last `period` closes
/// residual_i = y_i (a + b · x_i)
/// sigma = sqrt( Σ residual_i² / period ) // population stddev
/// middle = a + b · (period 1) // endpoint of the line
/// upper = middle + multiplier · sigma
/// lower = middle multiplier · sigma
/// ```
///
/// Where [`BollingerBands`](crate::BollingerBands) measures dispersion about
/// the *mean*, the `LinReg` Channel measures it about the *trend*: detrended
/// residuals, so a steady drift up or down does not bias the band width. The
/// resulting envelope tracks the trend without flaring on momentum bursts —
/// breakouts are statistically meaningful in the direction of trend, not just
/// in absolute price.
///
/// # Example
///
/// ```
/// use wickra_core::{Indicator, LinRegChannel};
///
/// let mut indicator = LinRegChannel::new(20, 2.0).unwrap();
/// let mut last = None;
/// for i in 0..40 {
/// last = indicator.update(100.0 + f64::from(i));
/// }
/// assert!(last.is_some());
/// ```
#[derive(Debug, Clone)]
pub struct LinRegChannel {
period: usize,
multiplier: f64,
window: VecDeque<f64>,
sum_x: f64,
sum_xx: f64,
}
impl LinRegChannel {
/// # Errors
/// Returns [`Error::InvalidPeriod`] if `period < 2` and
/// [`Error::NonPositiveMultiplier`] if `multiplier` is not strictly
/// positive and finite.
pub fn new(period: usize, multiplier: f64) -> Result<Self> {
if period < 2 {
return Err(Error::InvalidPeriod {
message: "linear regression channel needs period >= 2",
});
}
if !multiplier.is_finite() || multiplier <= 0.0 {
return Err(Error::NonPositiveMultiplier);
}
let n = period as f64;
Ok(Self {
period,
multiplier,
window: VecDeque::with_capacity(period),
sum_x: n * (n - 1.0) / 2.0,
sum_xx: (n - 1.0) * n * (2.0 * n - 1.0) / 6.0,
})
}
/// Configured period.
pub const fn period(&self) -> usize {
self.period
}
/// Configured multiplier.
pub const fn multiplier(&self) -> f64 {
self.multiplier
}
}
impl Indicator for LinRegChannel {
type Input = f64;
type Output = LinRegChannelOutput;
fn update(&mut self, value: f64) -> Option<LinRegChannelOutput> {
if self.window.len() == self.period {
self.window.pop_front();
}
self.window.push_back(value);
if self.window.len() < self.period {
return None;
}
// Recompute over the live window every bar. The OLS endpoint *could*
// be maintained incrementally (see `LinearRegression`) but the
// residual-stddev cannot be slid in closed form without storing each
// residual; recomputing both keeps the code simple and is O(period)
// per update — entirely acceptable for the periods used in practice.
let n = self.period as f64;
let mut sum_y = 0.0;
let mut sum_xy = 0.0;
for (i, &y) in self.window.iter().enumerate() {
let x = i as f64;
sum_y += y;
sum_xy += x * y;
}
let denom = n * self.sum_xx - self.sum_x * self.sum_x;
let slope = (n * sum_xy - self.sum_x * sum_y) / denom;
let intercept = (sum_y - slope * self.sum_x) / n;
// Residuals about the fitted line.
let mut sum_sq = 0.0;
for (i, &y) in self.window.iter().enumerate() {
let fitted = intercept + slope * (i as f64);
let r = y - fitted;
sum_sq += r * r;
}
let sigma = (sum_sq / n).sqrt();
let middle = intercept + slope * (n - 1.0);
Some(LinRegChannelOutput {
upper: middle + self.multiplier * sigma,
middle,
lower: middle - self.multiplier * sigma,
})
}
fn reset(&mut self) {
self.window.clear();
}
fn warmup_period(&self) -> usize {
self.period
}
fn is_ready(&self) -> bool {
self.window.len() == self.period
}
fn name(&self) -> &'static str {
"LinRegChannel"
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::traits::BatchExt;
use approx::assert_relative_eq;
#[test]
fn rejects_period_below_two() {
assert!(LinRegChannel::new(0, 2.0).is_err());
assert!(LinRegChannel::new(1, 2.0).is_err());
assert!(LinRegChannel::new(2, 2.0).is_ok());
}
#[test]
fn rejects_non_positive_multiplier() {
assert!(matches!(
LinRegChannel::new(20, 0.0),
Err(Error::NonPositiveMultiplier)
));
assert!(matches!(
LinRegChannel::new(20, -1.0),
Err(Error::NonPositiveMultiplier)
));
assert!(matches!(
LinRegChannel::new(20, f64::NAN),
Err(Error::NonPositiveMultiplier)
));
}
#[test]
fn accessors_and_metadata() {
let lc = LinRegChannel::new(20, 2.0).unwrap();
assert_eq!(lc.period(), 20);
assert_relative_eq!(lc.multiplier(), 2.0, epsilon = 1e-12);
assert_eq!(lc.warmup_period(), 20);
assert_eq!(lc.name(), "LinRegChannel");
}
#[test]
fn perfect_line_collapses_channel() {
// A perfectly linear series has zero residuals, so upper == middle == lower.
let prices: Vec<f64> = (0..40).map(|i| 2.0 * f64::from(i) + 5.0).collect();
let mut lc = LinRegChannel::new(10, 2.0).unwrap();
for o in lc.batch(&prices).into_iter().flatten() {
assert_relative_eq!(o.upper, o.middle, epsilon = 1e-9);
assert_relative_eq!(o.middle, o.lower, epsilon = 1e-9);
}
}
#[test]
fn constant_series_collapses_channel() {
let mut lc = LinRegChannel::new(8, 2.0).unwrap();
let out = lc.batch(&[42.0; 20]);
let v = out.iter().rev().flatten().next().unwrap();
assert_relative_eq!(v.middle, 42.0, epsilon = 1e-9);
assert_relative_eq!(v.upper, 42.0, epsilon = 1e-9);
assert_relative_eq!(v.lower, 42.0, epsilon = 1e-9);
}
#[test]
fn upper_above_middle_above_lower() {
let prices: Vec<f64> = (0..80)
.map(|i| 100.0 + (f64::from(i) * 0.3).sin() * 10.0)
.collect();
let mut lc = LinRegChannel::new(20, 2.0).unwrap();
for o in lc.batch(&prices).into_iter().flatten() {
assert!(o.upper >= o.middle);
assert!(o.middle >= o.lower);
}
}
#[test]
fn batch_equals_streaming() {
let prices: Vec<f64> = (0..60)
.map(|i| 50.0 + (f64::from(i) * 0.3).sin() * 10.0)
.collect();
let mut a = LinRegChannel::new(14, 2.0).unwrap();
let mut b = LinRegChannel::new(14, 2.0).unwrap();
assert_eq!(
a.batch(&prices),
prices.iter().map(|p| b.update(*p)).collect::<Vec<_>>()
);
}
#[test]
fn reset_clears_state() {
let mut lc = LinRegChannel::new(5, 2.0).unwrap();
lc.batch(&[1.0, 2.0, 3.0, 4.0, 5.0]);
assert!(lc.is_ready());
lc.reset();
assert!(!lc.is_ready());
assert_eq!(lc.update(1.0), None);
}
/// Reference: period 3 over `[1, 2, 9]`. Fitted line `y = 0 + 4·x`,
/// endpoint at `x = 2` is `8`. Residuals: `1 0 = 1`, `2 4 = 2`,
/// `9 8 = 1`. Population variance = (1 + 4 + 1) / 3 = 2, sigma = sqrt(2).
/// With multiplier 2.0, upper = 8 + 2·sqrt(2), lower = 8 2·sqrt(2).
#[test]
fn reference_values() {
let mut lc = LinRegChannel::new(3, 2.0).unwrap();
let out = lc.batch(&[1.0, 2.0, 9.0]);
let v = out[2].unwrap();
let s2 = f64::sqrt(2.0);
assert_relative_eq!(v.middle, 8.0, epsilon = 1e-9);
assert_relative_eq!(v.upper, 8.0 + 2.0 * s2, epsilon = 1e-9);
assert_relative_eq!(v.lower, 8.0 - 2.0 * s2, epsilon = 1e-9);
}
}
@@ -0,0 +1,216 @@
//! Moving Average Envelope.
use crate::error::{Error, Result};
use crate::indicators::sma::Sma;
use crate::traits::Indicator;
/// Moving Average Envelope output: SMA middle line wrapped by a fixed-percent
/// envelope on either side.
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct MaEnvelopeOutput {
/// Upper envelope: `middle · (1 + percent)`.
pub upper: f64,
/// Middle band: SMA over the window.
pub middle: f64,
/// Lower envelope: `middle · (1 percent)`.
pub lower: f64,
}
/// Moving Average Envelope: an SMA centerline with constant-percent bands on
/// each side.
///
/// ```text
/// middle = SMA(period)
/// upper = middle · (1 + percent)
/// lower = middle · (1 percent)
/// ```
///
/// The envelope is a fixed multiplicative offset around the moving average,
/// so the band width scales with price rather than with realised volatility
/// (contrast Bollinger Bands, whose width is `2·k·σ`, or Keltner Channels,
/// whose width is `2·k·ATR`). It is the oldest band-style overlay still in
/// regular use; chart vendors typically default to `period = 20`,
/// `percent = 0.025` (2.5 %).
///
/// # Example
///
/// ```
/// use wickra_core::{Indicator, MaEnvelope};
///
/// let mut indicator = MaEnvelope::new(20, 0.025).unwrap();
/// let mut last = None;
/// for i in 0..40 {
/// last = indicator.update(100.0 + f64::from(i));
/// }
/// assert!(last.is_some());
/// ```
#[derive(Debug, Clone)]
pub struct MaEnvelope {
sma: Sma,
percent: f64,
}
impl MaEnvelope {
/// Construct a new Moving Average Envelope.
///
/// # Errors
/// Returns [`Error::PeriodZero`] if `period == 0` and
/// [`Error::NonPositiveMultiplier`] if `percent` is not strictly positive
/// and finite.
pub fn new(period: usize, percent: f64) -> Result<Self> {
if !percent.is_finite() || percent <= 0.0 {
return Err(Error::NonPositiveMultiplier);
}
Ok(Self {
sma: Sma::new(period)?,
percent,
})
}
/// Configured period.
pub const fn period(&self) -> usize {
self.sma.period()
}
/// Configured envelope percent (e.g. `0.025` for ±2.5 %).
pub const fn percent(&self) -> f64 {
self.percent
}
}
impl Indicator for MaEnvelope {
type Input = f64;
type Output = MaEnvelopeOutput;
fn update(&mut self, input: f64) -> Option<MaEnvelopeOutput> {
let middle = self.sma.update(input)?;
Some(MaEnvelopeOutput {
upper: middle * (1.0 + self.percent),
middle,
lower: middle * (1.0 - self.percent),
})
}
fn reset(&mut self) {
self.sma.reset();
}
fn warmup_period(&self) -> usize {
self.sma.warmup_period()
}
fn is_ready(&self) -> bool {
self.sma.is_ready()
}
fn name(&self) -> &'static str {
"MaEnvelope"
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::traits::BatchExt;
use approx::assert_relative_eq;
#[test]
fn rejects_zero_period() {
assert!(matches!(MaEnvelope::new(0, 0.025), Err(Error::PeriodZero)));
}
#[test]
fn rejects_non_positive_percent() {
assert!(matches!(
MaEnvelope::new(20, 0.0),
Err(Error::NonPositiveMultiplier)
));
assert!(matches!(
MaEnvelope::new(20, -0.1),
Err(Error::NonPositiveMultiplier)
));
assert!(matches!(
MaEnvelope::new(20, f64::NAN),
Err(Error::NonPositiveMultiplier)
));
}
#[test]
fn accessors_and_metadata() {
let env = MaEnvelope::new(20, 0.025).unwrap();
assert_eq!(env.period(), 20);
assert_relative_eq!(env.percent(), 0.025, epsilon = 1e-12);
assert_eq!(env.warmup_period(), 20);
assert_eq!(env.name(), "MaEnvelope");
assert!(!env.is_ready());
}
#[test]
fn constant_series_yields_flat_envelope() {
let mut env = MaEnvelope::new(5, 0.01).unwrap();
let last = env
.batch(&[100.0_f64; 20])
.into_iter()
.flatten()
.last()
.unwrap();
assert_relative_eq!(last.middle, 100.0, epsilon = 1e-12);
assert_relative_eq!(last.upper, 101.0, epsilon = 1e-12);
assert_relative_eq!(last.lower, 99.0, epsilon = 1e-12);
}
#[test]
fn warmup_returns_none() {
let mut env = MaEnvelope::new(5, 0.05).unwrap();
for v in [1.0, 2.0, 3.0, 4.0] {
assert!(env.update(v).is_none());
}
assert!(env.update(5.0).is_some());
}
#[test]
fn upper_above_middle_above_lower() {
let prices: Vec<f64> = (1..=80)
.map(|i| 100.0 + (f64::from(i) * 0.2).sin() * 5.0)
.collect();
let mut env = MaEnvelope::new(20, 0.025).unwrap();
for o in env.batch(&prices).into_iter().flatten() {
assert!(o.upper >= o.middle);
assert!(o.middle >= o.lower);
}
}
#[test]
fn batch_equals_streaming() {
let prices: Vec<f64> = (1..=50).map(|i| f64::from(i) * 0.7 + 100.0).collect();
let mut a = MaEnvelope::new(10, 0.03).unwrap();
let mut b = MaEnvelope::new(10, 0.03).unwrap();
assert_eq!(
a.batch(&prices),
prices.iter().map(|p| b.update(*p)).collect::<Vec<_>>()
);
}
#[test]
fn reset_clears_state() {
let mut env = MaEnvelope::new(5, 0.02).unwrap();
env.batch(&[1.0, 2.0, 3.0, 4.0, 5.0]);
assert!(env.is_ready());
env.reset();
assert!(!env.is_ready());
assert_eq!(env.update(1.0), None);
}
/// Reference value: SMA over [10, 20, 30] is 20; with percent = 0.10 the
/// upper band is 22 and the lower band is 18.
#[test]
fn reference_values() {
let mut env = MaEnvelope::new(3, 0.10).unwrap();
let out = env.batch(&[10.0, 20.0, 30.0]);
assert!(out[0].is_none() && out[1].is_none());
let v = out[2].unwrap();
assert_relative_eq!(v.middle, 20.0, epsilon = 1e-12);
assert_relative_eq!(v.upper, 22.0, epsilon = 1e-12);
assert_relative_eq!(v.lower, 18.0, epsilon = 1e-12);
}
}
+22
View File
@@ -4,6 +4,7 @@
//! volume) but every public name is also re-exported flat from this module and
//! from the crate root for convenience.
mod acceleration_bands;
mod accelerator_oscillator;
mod adl;
mod adx;
@@ -13,6 +14,7 @@ mod apo;
mod aroon;
mod aroon_oscillator;
mod atr;
mod atr_bands;
mod atr_trailing_stop;
mod awesome_oscillator;
mod awesome_oscillator_histogram;
@@ -32,16 +34,19 @@ mod connors_rsi;
mod coppock;
mod dema;
mod donchian;
mod double_bollinger;
mod dpo;
mod ease_of_movement;
mod elder_impulse;
mod ema;
mod evwma;
mod force_index;
mod fractal_chaos_bands;
mod frama;
mod garman_klass;
mod historical_volatility;
mod hma;
mod hurst_channel;
mod inertia;
mod jma;
mod kama;
@@ -50,7 +55,9 @@ mod kst;
mod laguerre_rsi;
mod linreg;
mod linreg_angle;
mod linreg_channel;
mod linreg_slope;
mod ma_envelope;
mod macd;
mod mass_index;
mod mcginley_dynamic;
@@ -73,6 +80,8 @@ mod rvi_volatility;
mod sma;
mod smi;
mod smma;
mod standard_error_bands;
mod starc_bands;
mod stc;
mod std_dev;
mod stoch_rsi;
@@ -84,6 +93,7 @@ mod trima;
mod trix;
mod true_range;
mod tsi;
mod ttm_squeeze;
mod typical_price;
mod ulcer_index;
mod ultimate_oscillator;
@@ -92,6 +102,7 @@ mod vidya;
mod vortex;
mod vpt;
mod vwap;
mod vwap_stddev_bands;
mod vwma;
mod weighted_close;
mod williams_r;
@@ -101,6 +112,7 @@ mod z_score;
mod zero_lag_macd;
mod zlema;
pub use acceleration_bands::{AccelerationBands, AccelerationBandsOutput};
pub use accelerator_oscillator::AcceleratorOscillator;
pub use adl::Adl;
pub use adx::{Adx, AdxOutput};
@@ -110,6 +122,7 @@ pub use apo::Apo;
pub use aroon::{Aroon, AroonOutput};
pub use aroon_oscillator::AroonOscillator;
pub use atr::Atr;
pub use atr_bands::{AtrBands, AtrBandsOutput};
pub use atr_trailing_stop::AtrTrailingStop;
pub use awesome_oscillator::AwesomeOscillator;
pub use awesome_oscillator_histogram::AwesomeOscillatorHistogram;
@@ -129,16 +142,19 @@ pub use connors_rsi::ConnorsRsi;
pub use coppock::Coppock;
pub use dema::Dema;
pub use donchian::{Donchian, DonchianOutput};
pub use double_bollinger::{DoubleBollinger, DoubleBollingerOutput};
pub use dpo::Dpo;
pub use ease_of_movement::EaseOfMovement;
pub use elder_impulse::ElderImpulse;
pub use ema::Ema;
pub use evwma::Evwma;
pub use force_index::ForceIndex;
pub use fractal_chaos_bands::{FractalChaosBands, FractalChaosBandsOutput};
pub use frama::Frama;
pub use garman_klass::GarmanKlassVolatility;
pub use historical_volatility::HistoricalVolatility;
pub use hma::Hma;
pub use hurst_channel::{HurstChannel, HurstChannelOutput};
pub use inertia::Inertia;
pub use jma::Jma;
pub use kama::Kama;
@@ -147,7 +163,9 @@ pub use kst::{Kst, KstOutput};
pub use laguerre_rsi::LaguerreRsi;
pub use linreg::LinearRegression;
pub use linreg_angle::LinRegAngle;
pub use linreg_channel::{LinRegChannel, LinRegChannelOutput};
pub use linreg_slope::LinRegSlope;
pub use ma_envelope::{MaEnvelope, MaEnvelopeOutput};
pub use macd::{MacdIndicator, MacdOutput};
pub use mass_index::MassIndex;
pub use mcginley_dynamic::McGinleyDynamic;
@@ -170,6 +188,8 @@ pub use rvi_volatility::RviVolatility;
pub use sma::Sma;
pub use smi::Smi;
pub use smma::Smma;
pub use standard_error_bands::{StandardErrorBands, StandardErrorBandsOutput};
pub use starc_bands::{StarcBands, StarcBandsOutput};
pub use stc::Stc;
pub use std_dev::StdDev;
pub use stoch_rsi::StochRsi;
@@ -181,6 +201,7 @@ pub use trima::Trima;
pub use trix::Trix;
pub use true_range::TrueRange;
pub use tsi::Tsi;
pub use ttm_squeeze::{TtmSqueeze, TtmSqueezeOutput};
pub use typical_price::TypicalPrice;
pub use ulcer_index::UlcerIndex;
pub use ultimate_oscillator::UltimateOscillator;
@@ -189,6 +210,7 @@ pub use vidya::Vidya;
pub use vortex::{Vortex, VortexOutput};
pub use vpt::VolumePriceTrend;
pub use vwap::{RollingVwap, Vwap};
pub use vwap_stddev_bands::{VwapStdDevBands, VwapStdDevBandsOutput};
pub use vwma::Vwma;
pub use weighted_close::WeightedClose;
pub use williams_r::WilliamsR;
@@ -0,0 +1,278 @@
//! Standard Error Bands.
use std::collections::VecDeque;
use crate::error::{Error, Result};
use crate::traits::Indicator;
/// Standard Error Bands output.
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct StandardErrorBandsOutput {
/// Upper band: regression endpoint plus `multiplier · standard_error`.
pub upper: f64,
/// Middle line: OLS endpoint over the window.
pub middle: f64,
/// Lower band: regression endpoint minus `multiplier · standard_error`.
pub lower: f64,
}
/// Standard Error Bands: linear-regression line wrapped by the standard error
/// of the fit.
///
/// ```text
/// fit y = a + b·x by OLS over the last `period` closes
/// residual_i = y_i (a + b · x_i)
/// stderr = sqrt( Σ residual_i² / (period 2) ) // OLS standard error
/// middle = a + b · (period 1)
/// upper = middle + multiplier · stderr
/// lower = middle multiplier · stderr
/// ```
///
/// Standard Error Bands and [`LinRegChannel`](crate::LinRegChannel) both wrap
/// an OLS endpoint, but use *different denominators* for the dispersion
/// statistic:
///
/// - The `LinReg` Channel uses the population standard deviation of the
/// residuals (denominator `n`).
/// - Standard Error Bands use the OLS standard error (denominator `n 2`,
/// one degree of freedom for the slope and one for the intercept).
///
/// The `n 2` divisor produces a slightly wider channel and is the
/// statistically-correct band-width when the regression is interpreted as a
/// prediction interval. Jon Andersen's original publication pairs the bands
/// with a default `multiplier = 2.0` and a 3-bar SMA smoothing of all three
/// outputs; this implementation reports the *raw* bands so callers can pipe
/// them through their own smoother (e.g. [`Sma::new(3)`](crate::Sma)).
///
/// # Example
///
/// ```
/// use wickra_core::{Indicator, StandardErrorBands};
///
/// let mut indicator = StandardErrorBands::new(21, 2.0).unwrap();
/// let mut last = None;
/// for i in 0..40 {
/// last = indicator.update(100.0 + f64::from(i));
/// }
/// assert!(last.is_some());
/// ```
#[derive(Debug, Clone)]
pub struct StandardErrorBands {
period: usize,
multiplier: f64,
window: VecDeque<f64>,
sum_x: f64,
sum_xx: f64,
}
impl StandardErrorBands {
/// # Errors
/// Returns [`Error::InvalidPeriod`] if `period < 3` (the `n 2`
/// denominator requires at least 3 points) and
/// [`Error::NonPositiveMultiplier`] if `multiplier` is not strictly
/// positive and finite.
pub fn new(period: usize, multiplier: f64) -> Result<Self> {
if period < 3 {
return Err(Error::InvalidPeriod {
message: "standard error bands need period >= 3",
});
}
if !multiplier.is_finite() || multiplier <= 0.0 {
return Err(Error::NonPositiveMultiplier);
}
let n = period as f64;
Ok(Self {
period,
multiplier,
window: VecDeque::with_capacity(period),
sum_x: n * (n - 1.0) / 2.0,
sum_xx: (n - 1.0) * n * (2.0 * n - 1.0) / 6.0,
})
}
/// Configured period.
pub const fn period(&self) -> usize {
self.period
}
/// Configured multiplier.
pub const fn multiplier(&self) -> f64 {
self.multiplier
}
}
impl Indicator for StandardErrorBands {
type Input = f64;
type Output = StandardErrorBandsOutput;
fn update(&mut self, value: f64) -> Option<StandardErrorBandsOutput> {
if self.window.len() == self.period {
self.window.pop_front();
}
self.window.push_back(value);
if self.window.len() < self.period {
return None;
}
let n = self.period as f64;
let mut sum_y = 0.0;
let mut sum_xy = 0.0;
for (i, &y) in self.window.iter().enumerate() {
let x = i as f64;
sum_y += y;
sum_xy += x * y;
}
let denom = n * self.sum_xx - self.sum_x * self.sum_x;
let slope = (n * sum_xy - self.sum_x * sum_y) / denom;
let intercept = (sum_y - slope * self.sum_x) / n;
let mut sse = 0.0;
for (i, &y) in self.window.iter().enumerate() {
let fitted = intercept + slope * (i as f64);
let r = y - fitted;
sse += r * r;
}
// OLS standard error with `n 2` degrees of freedom. `n 2` is at
// least 1 because the constructor enforces `period >= 3`.
let stderr = (sse / (n - 2.0)).sqrt();
let middle = intercept + slope * (n - 1.0);
Some(StandardErrorBandsOutput {
upper: middle + self.multiplier * stderr,
middle,
lower: middle - self.multiplier * stderr,
})
}
fn reset(&mut self) {
self.window.clear();
}
fn warmup_period(&self) -> usize {
self.period
}
fn is_ready(&self) -> bool {
self.window.len() == self.period
}
fn name(&self) -> &'static str {
"StandardErrorBands"
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::traits::BatchExt;
use approx::assert_relative_eq;
#[test]
fn rejects_period_below_three() {
assert!(StandardErrorBands::new(0, 2.0).is_err());
assert!(StandardErrorBands::new(1, 2.0).is_err());
assert!(StandardErrorBands::new(2, 2.0).is_err());
assert!(StandardErrorBands::new(3, 2.0).is_ok());
}
#[test]
fn rejects_non_positive_multiplier() {
assert!(matches!(
StandardErrorBands::new(20, 0.0),
Err(Error::NonPositiveMultiplier)
));
assert!(matches!(
StandardErrorBands::new(20, -1.0),
Err(Error::NonPositiveMultiplier)
));
assert!(matches!(
StandardErrorBands::new(20, f64::NAN),
Err(Error::NonPositiveMultiplier)
));
}
#[test]
fn accessors_and_metadata() {
let seb = StandardErrorBands::new(21, 2.0).unwrap();
assert_eq!(seb.period(), 21);
assert_relative_eq!(seb.multiplier(), 2.0, epsilon = 1e-12);
assert_eq!(seb.warmup_period(), 21);
assert_eq!(seb.name(), "StandardErrorBands");
}
#[test]
fn perfect_line_collapses_bands() {
let prices: Vec<f64> = (0..40).map(|i| 2.0 * f64::from(i) + 5.0).collect();
let mut seb = StandardErrorBands::new(10, 2.0).unwrap();
for o in seb.batch(&prices).into_iter().flatten() {
assert_relative_eq!(o.upper, o.middle, epsilon = 1e-9);
assert_relative_eq!(o.middle, o.lower, epsilon = 1e-9);
}
}
#[test]
fn upper_above_middle_above_lower() {
let prices: Vec<f64> = (0..80)
.map(|i| 100.0 + (f64::from(i) * 0.3).sin() * 10.0)
.collect();
let mut seb = StandardErrorBands::new(21, 2.0).unwrap();
for o in seb.batch(&prices).into_iter().flatten() {
assert!(o.upper >= o.middle);
assert!(o.middle >= o.lower);
}
}
#[test]
fn batch_equals_streaming() {
let prices: Vec<f64> = (0..60)
.map(|i| 50.0 + (f64::from(i) * 0.3).sin() * 10.0)
.collect();
let mut a = StandardErrorBands::new(21, 2.0).unwrap();
let mut b = StandardErrorBands::new(21, 2.0).unwrap();
assert_eq!(
a.batch(&prices),
prices.iter().map(|p| b.update(*p)).collect::<Vec<_>>()
);
}
#[test]
fn reset_clears_state() {
let mut seb = StandardErrorBands::new(5, 2.0).unwrap();
seb.batch(&[1.0, 2.0, 3.0, 4.0, 5.0]);
assert!(seb.is_ready());
seb.reset();
assert!(!seb.is_ready());
assert_eq!(seb.update(1.0), None);
}
/// Reference: period 3 over `[1, 2, 9]`. Fitted line `y = 0 + 4·x`,
/// endpoint at `x = 2` is `8`. Residuals: 1, 2, 1. SSE = 6.
/// `n 2 = 1`, so stderr = sqrt(6 / 1) = sqrt(6). With multiplier 2.0:
/// upper = 8 + 2·sqrt(6), lower = 8 2·sqrt(6).
#[test]
fn reference_values() {
let mut seb = StandardErrorBands::new(3, 2.0).unwrap();
let v = seb.batch(&[1.0, 2.0, 9.0])[2].unwrap();
let s = f64::sqrt(6.0);
assert_relative_eq!(v.middle, 8.0, epsilon = 1e-9);
assert_relative_eq!(v.upper, 8.0 + 2.0 * s, epsilon = 1e-9);
assert_relative_eq!(v.lower, 8.0 - 2.0 * s, epsilon = 1e-9);
}
/// The n2 standard error must be strictly larger than the population
/// stddev (n divisor) on the same residuals — by the factor sqrt(n / (n2)).
#[test]
fn standard_error_exceeds_population_stddev() {
// Use n = 5 (factor = sqrt(5/3)) with non-trivial residuals.
let prices: Vec<f64> = vec![1.0, 5.0, 2.0, 8.0, 3.0];
let mut seb = StandardErrorBands::new(5, 1.0).unwrap();
let v = seb.batch(&prices)[4].unwrap();
// The half-width of the band is `multiplier · stderr`, so:
let half = v.upper - v.middle;
assert!(half > 0.0);
// sigma² = SSE / 5, stderr² = SSE / 3, ratio of stderr to sigma = sqrt(5/3).
// Reproduce stderr from the half-width (multiplier = 1.0) and check
// it is sqrt(5/3) ≈ 1.291 times larger than sigma.
let factor = (5.0_f64 / 3.0).sqrt();
// half / factor would equal the population stddev — we expect factor > 1.
assert!(half / factor < half, "n2 stderr must exceed n stddev");
}
}
@@ -0,0 +1,229 @@
//! STARC Bands (Stoller Average Range Channel).
use crate::error::{Error, Result};
use crate::indicators::atr::Atr;
use crate::indicators::sma::Sma;
use crate::ohlcv::Candle;
use crate::traits::Indicator;
/// STARC Bands output.
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct StarcBandsOutput {
/// Upper band: `middle + multiplier · ATR`.
pub upper: f64,
/// Middle band: SMA of close.
pub middle: f64,
/// Lower band: `middle multiplier · ATR`.
pub lower: f64,
}
/// STARC Bands (Stoller Average Range Channel): a close-SMA centerline with
/// bands sized by ATR.
///
/// ```text
/// middle = SMA(close, sma_period)
/// upper = middle + multiplier · ATR(atr_period)
/// lower = middle multiplier · ATR(atr_period)
/// ```
///
/// STARC and [`Keltner`](crate::Keltner) share the same skeleton — moving
/// average plus an ATR offset — but Keltner's centerline is an `EMA` of the
/// typical price while STARC uses an `SMA` of the close. The SMA gives a
/// flatter, less reactive midline that traders use to pick the larger swing
/// targets; Stoller's reference parameters are `SMA(6)` over the close with
/// `ATR(15)` and a multiplier of `2.0`.
///
/// # Example
///
/// ```
/// use wickra_core::{Candle, Indicator, StarcBands};
///
/// let mut indicator = StarcBands::new(6, 15, 2.0).unwrap();
/// let mut last = None;
/// for i in 0..40 {
/// let base = 100.0 + f64::from(i);
/// let candle =
/// Candle::new(base, base + 2.0, base - 2.0, base + 1.0, 10.0, i64::from(i)).unwrap();
/// last = indicator.update(candle);
/// }
/// assert!(last.is_some());
/// ```
#[derive(Debug, Clone)]
pub struct StarcBands {
sma: Sma,
atr: Atr,
multiplier: f64,
sma_period: usize,
atr_period: usize,
}
impl StarcBands {
/// # Errors
/// Returns [`Error::PeriodZero`] / [`Error::NonPositiveMultiplier`] on
/// invalid inputs.
pub fn new(sma_period: usize, atr_period: usize, multiplier: f64) -> Result<Self> {
if !multiplier.is_finite() || multiplier <= 0.0 {
return Err(Error::NonPositiveMultiplier);
}
Ok(Self {
sma: Sma::new(sma_period)?,
atr: Atr::new(atr_period)?,
multiplier,
sma_period,
atr_period,
})
}
/// Stoller's classic configuration: SMA(6), ATR(15), multiplier 2.0.
pub fn classic() -> Self {
Self::new(6, 15, 2.0).expect("classic STARC parameters are valid")
}
/// Configured `(sma_period, atr_period, multiplier)`.
pub const fn parameters(&self) -> (usize, usize, f64) {
(self.sma_period, self.atr_period, self.multiplier)
}
}
impl Indicator for StarcBands {
type Input = Candle;
type Output = StarcBandsOutput;
fn update(&mut self, candle: Candle) -> Option<StarcBandsOutput> {
// Feed both unconditionally so SMA and ATR warm up in parallel.
let mid = self.sma.update(candle.close);
let atr = self.atr.update(candle);
let (mid, atr) = (mid?, atr?);
Some(StarcBandsOutput {
upper: mid + self.multiplier * atr,
middle: mid,
lower: mid - self.multiplier * atr,
})
}
fn reset(&mut self) {
self.sma.reset();
self.atr.reset();
}
fn warmup_period(&self) -> usize {
self.sma_period.max(self.atr_period)
}
fn is_ready(&self) -> bool {
self.sma.is_ready() && self.atr.is_ready()
}
fn name(&self) -> &'static str {
"StarcBands"
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::traits::BatchExt;
use approx::assert_relative_eq;
fn c(h: f64, l: f64, cl: f64) -> Candle {
Candle::new(cl, h, l, cl, 1.0, 0).unwrap()
}
#[test]
fn rejects_invalid_input() {
assert!(StarcBands::new(0, 14, 2.0).is_err());
assert!(StarcBands::new(6, 0, 2.0).is_err());
assert!(StarcBands::new(6, 14, 0.0).is_err());
assert!(StarcBands::new(6, 14, -1.0).is_err());
assert!(StarcBands::new(6, 14, f64::NAN).is_err());
}
#[test]
fn accessors_and_metadata() {
let s = StarcBands::new(6, 15, 2.0).unwrap();
let (sp, ap, m) = s.parameters();
assert_eq!(sp, 6);
assert_eq!(ap, 15);
assert_relative_eq!(m, 2.0, epsilon = 1e-12);
assert_eq!(s.warmup_period(), 15);
assert_eq!(s.name(), "StarcBands");
}
#[test]
fn flat_market_collapses_bands() {
let candles: Vec<Candle> = (0..50).map(|_| c(10.0, 10.0, 10.0)).collect();
let mut s = StarcBands::new(6, 15, 2.0).unwrap();
let last = s.batch(&candles).into_iter().flatten().last().unwrap();
assert_relative_eq!(last.upper, last.middle, epsilon = 1e-9);
assert_relative_eq!(last.lower, last.middle, epsilon = 1e-9);
}
#[test]
fn upper_above_middle_above_lower() {
let candles: Vec<Candle> = (0..80)
.map(|i| {
let m = 100.0 + (f64::from(i) * 0.2).sin() * 5.0;
c(m + 1.0, m - 1.0, m)
})
.collect();
let mut s = StarcBands::classic();
for o in s.batch(&candles).into_iter().flatten() {
assert!(o.upper >= o.middle);
assert!(o.middle >= o.lower);
}
}
#[test]
fn batch_equals_streaming() {
let candles: Vec<Candle> = (0..40)
.map(|i| c(f64::from(i) + 1.0, f64::from(i) - 1.0, f64::from(i)))
.collect();
let mut a = StarcBands::classic();
let mut b = StarcBands::classic();
assert_eq!(
a.batch(&candles),
candles.iter().map(|x| b.update(*x)).collect::<Vec<_>>()
);
}
#[test]
fn reset_clears_state() {
let candles: Vec<Candle> = (0..30)
.map(|i| c(f64::from(i) + 1.0, f64::from(i) - 1.0, f64::from(i)))
.collect();
let mut s = StarcBands::classic();
s.batch(&candles);
assert!(s.is_ready());
s.reset();
assert!(!s.is_ready());
assert_eq!(s.update(candles[0]), None);
}
/// STARC must equal feeding independent SMA(close) and ATR siblings and
/// combining them.
#[test]
fn matches_independent_sma_and_atr() {
let candles: Vec<Candle> = (0..60)
.map(|i| {
let m = 100.0 + (f64::from(i) * 0.2).sin() * 5.0;
c(m + 1.5, m - 1.5, m)
})
.collect();
let mut s = StarcBands::new(6, 15, 2.0).unwrap();
let mut sma = Sma::new(6).unwrap();
let mut atr = Atr::new(15).unwrap();
for candle in &candles {
let got = s.update(*candle);
let mid = sma.update(candle.close);
let a = atr.update(*candle);
if let (Some(m), Some(av)) = (mid, a) {
let o = got.expect("STARC emits once both ready");
assert_relative_eq!(o.middle, m, epsilon = 1e-9);
assert_relative_eq!(o.upper, m + 2.0 * av, epsilon = 1e-9);
assert_relative_eq!(o.lower, m - 2.0 * av, epsilon = 1e-9);
} else {
assert!(got.is_none());
}
}
}
}
@@ -0,0 +1,302 @@
//! TTM Squeeze (John Carter).
use std::collections::VecDeque;
use crate::error::{Error, Result};
use crate::indicators::atr::Atr;
use crate::indicators::bollinger::BollingerBands;
use crate::indicators::sma::Sma;
use crate::ohlcv::Candle;
use crate::traits::Indicator;
/// TTM Squeeze output.
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct TtmSqueezeOutput {
/// `1.0` while the squeeze is *on* (Bollinger Bands sit inside the Keltner
/// Channel), `0.0` otherwise. The squeeze releases — the signal flips back
/// to `0.0` — when volatility expands and BB pierce KC.
pub squeeze: f64,
/// Detrended momentum: linear-regression endpoint of
/// `close (midpoint(highest_high, lowest_low, period) + SMA(close, period)) / 2`.
/// Histogram-like reading that swings positive in a breakout up, negative
/// in a breakout down; trade direction on the squeeze release follows the
/// sign of `momentum`.
pub momentum: f64,
}
/// TTM Squeeze (John Carter): a Bollinger-vs-Keltner volatility squeeze paired
/// with a detrended-close momentum reading.
///
/// Carter's setup detects coiled markets (low realised volatility relative to
/// ATR) and the *direction* of the breakout when they uncoil:
///
/// ```text
/// squeeze = 1.0 if BollingerBands(period, bb_mult)
/// ⊂ KeltnerChannels-like(SMA(period), ATR(period), kc_mult)
/// else 0.0
///
/// hl_mid = (max(high, period) + min(low, period)) / 2
/// detrend = close (hl_mid + SMA(close, period)) / 2
/// momentum = LinearRegression(detrend, period) // endpoint
/// ```
///
/// The "Keltner-like" envelope here uses an *SMA* centerline (not the EMA of
/// typical price that [`Keltner`](crate::Keltner) uses) plus an ATR offset,
/// exactly as Carter's original publication and every chart-vendor
/// implementation define it. Common parameters: `period = 20`, `bb_mult = 2.0`,
/// `kc_mult = 1.5`.
///
/// # Example
///
/// ```
/// use wickra_core::{Candle, Indicator, TtmSqueeze};
///
/// let mut indicator = TtmSqueeze::new(20, 2.0, 1.5).unwrap();
/// let mut last = None;
/// for i in 0..40 {
/// let base = 100.0 + f64::from(i);
/// let candle =
/// Candle::new(base, base + 2.0, base - 2.0, base + 1.0, 10.0, i64::from(i)).unwrap();
/// last = indicator.update(candle);
/// }
/// assert!(last.is_some());
/// ```
#[derive(Debug, Clone)]
pub struct TtmSqueeze {
period: usize,
kc_mult: f64,
bb: BollingerBands,
sma_close: Sma,
atr: Atr,
highs: VecDeque<f64>,
lows: VecDeque<f64>,
closes: VecDeque<f64>,
// Pre-computed OLS constants over `x = 0..period 1`.
sum_x: f64,
denom: f64,
}
impl TtmSqueeze {
/// # Errors
/// Returns [`Error::PeriodZero`] if `period == 0` and
/// [`Error::NonPositiveMultiplier`] if either multiplier is not strictly
/// positive and finite. `period >= 2` is required for the linear-regression
/// momentum component.
pub fn new(period: usize, bb_mult: f64, kc_mult: f64) -> Result<Self> {
if period < 2 {
return Err(Error::InvalidPeriod {
message: "TTM squeeze needs period >= 2 for the momentum regression",
});
}
if !bb_mult.is_finite() || bb_mult <= 0.0 || !kc_mult.is_finite() || kc_mult <= 0.0 {
return Err(Error::NonPositiveMultiplier);
}
let n = period as f64;
let sum_x = n * (n - 1.0) / 2.0;
let sum_xx = (n - 1.0) * n * (2.0 * n - 1.0) / 6.0;
Ok(Self {
period,
kc_mult,
bb: BollingerBands::new(period, bb_mult)?,
sma_close: Sma::new(period)?,
atr: Atr::new(period)?,
highs: VecDeque::with_capacity(period),
lows: VecDeque::with_capacity(period),
closes: VecDeque::with_capacity(period),
sum_x,
denom: n * sum_xx - sum_x * sum_x,
})
}
/// John Carter's classic configuration: `period = 20`, `bb_mult = 2.0`,
/// `kc_mult = 1.5`.
pub fn classic() -> Self {
Self::new(20, 2.0, 1.5).expect("classic TTM Squeeze parameters are valid")
}
/// Configured `(period, bb_mult, kc_mult)`.
pub fn parameters(&self) -> (usize, f64, f64) {
(self.period, self.bb.multiplier(), self.kc_mult)
}
}
impl Indicator for TtmSqueeze {
type Input = Candle;
type Output = TtmSqueezeOutput;
fn update(&mut self, candle: Candle) -> Option<TtmSqueezeOutput> {
if self.highs.len() == self.period {
self.highs.pop_front();
self.lows.pop_front();
self.closes.pop_front();
}
self.highs.push_back(candle.high);
self.lows.push_back(candle.low);
self.closes.push_back(candle.close);
// Feed all three sub-indicators unconditionally so they warm up in
// lock-step. ATR returns its first value at bar `period` (Wilder
// seeds), the SMA and BB on bar `period` as well.
let bb = self.bb.update(candle.close);
let mid = self.sma_close.update(candle.close);
let atr = self.atr.update(candle);
let (bb, mid, atr) = (bb?, mid?, atr?);
let kc_upper = mid + self.kc_mult * atr;
let kc_lower = mid - self.kc_mult * atr;
let squeeze = f64::from(bb.upper <= kc_upper && bb.lower >= kc_lower);
// Detrended close. The reference forms it as the deviation of close
// from the average of the rolling high-low midpoint and the SMA of
// close, then runs a linear regression of that series.
let hi = self.highs.iter().copied().fold(f64::NEG_INFINITY, f64::max);
let lo = self.lows.iter().copied().fold(f64::INFINITY, f64::min);
let hl_mid = f64::midpoint(hi, lo);
// Build the detrended window over the closes currently in `closes`.
// We need all `period` closes to fit the regression, which is
// guaranteed once `bb` / `mid` are ready.
let baseline = f64::midpoint(hl_mid, mid);
let mut sum_y = 0.0;
let mut sum_xy = 0.0;
for (i, &c) in self.closes.iter().enumerate() {
let y = c - baseline;
let x = i as f64;
sum_y += y;
sum_xy += x * y;
}
let n = self.period as f64;
let slope = (n * sum_xy - self.sum_x * sum_y) / self.denom;
let intercept = (sum_y - slope * self.sum_x) / n;
let momentum = intercept + slope * (n - 1.0);
Some(TtmSqueezeOutput { squeeze, momentum })
}
fn reset(&mut self) {
self.bb.reset();
self.sma_close.reset();
self.atr.reset();
self.highs.clear();
self.lows.clear();
self.closes.clear();
}
fn warmup_period(&self) -> usize {
self.period
}
fn is_ready(&self) -> bool {
self.bb.is_ready() && self.sma_close.is_ready() && self.atr.is_ready()
}
fn name(&self) -> &'static str {
"TtmSqueeze"
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::traits::BatchExt;
use approx::assert_relative_eq;
fn c(h: f64, l: f64, cl: f64) -> Candle {
Candle::new(cl, h, l, cl, 1.0, 0).unwrap()
}
#[test]
fn rejects_invalid_period() {
assert!(TtmSqueeze::new(0, 2.0, 1.5).is_err());
assert!(TtmSqueeze::new(1, 2.0, 1.5).is_err());
}
#[test]
fn rejects_non_positive_multipliers() {
assert!(matches!(
TtmSqueeze::new(20, 0.0, 1.5),
Err(Error::NonPositiveMultiplier)
));
assert!(matches!(
TtmSqueeze::new(20, 2.0, -1.0),
Err(Error::NonPositiveMultiplier)
));
assert!(matches!(
TtmSqueeze::new(20, f64::NAN, 1.5),
Err(Error::NonPositiveMultiplier)
));
}
#[test]
fn accessors_and_metadata() {
let s = TtmSqueeze::classic();
let (p, b, k) = s.parameters();
assert_eq!(p, 20);
assert_relative_eq!(b, 2.0, epsilon = 1e-12);
assert_relative_eq!(k, 1.5, epsilon = 1e-12);
assert_eq!(s.warmup_period(), 20);
assert_eq!(s.name(), "TtmSqueeze");
}
#[test]
fn flat_market_has_zero_momentum() {
let candles: Vec<Candle> = (0..30).map(|_| c(10.0, 10.0, 10.0)).collect();
let mut s = TtmSqueeze::new(20, 2.0, 1.5).unwrap();
let last = s.batch(&candles).into_iter().flatten().last().unwrap();
assert_relative_eq!(last.momentum, 0.0, epsilon = 1e-9);
// With zero volatility both BB and KC collapse to a point, so the
// squeeze is trivially "on".
assert_relative_eq!(last.squeeze, 1.0, epsilon = 1e-12);
}
#[test]
fn batch_equals_streaming() {
let candles: Vec<Candle> = (0..40)
.map(|i| c(f64::from(i) + 2.0, f64::from(i), f64::from(i) + 1.0))
.collect();
let mut a = TtmSqueeze::new(20, 2.0, 1.5).unwrap();
let mut b = TtmSqueeze::new(20, 2.0, 1.5).unwrap();
assert_eq!(
a.batch(&candles),
candles.iter().map(|x| b.update(*x)).collect::<Vec<_>>()
);
}
#[test]
fn reset_clears_state() {
let candles: Vec<Candle> = (0..30)
.map(|i| c(f64::from(i) + 1.0, f64::from(i) - 1.0, f64::from(i)))
.collect();
let mut s = TtmSqueeze::classic();
s.batch(&candles);
assert!(s.is_ready());
s.reset();
assert!(!s.is_ready());
assert_eq!(s.update(candles[0]), None);
}
/// Squeeze fires only after `period` candles, never before.
#[test]
fn warmup_returns_none() {
let mut s = TtmSqueeze::new(20, 2.0, 1.5).unwrap();
for i in 0..19 {
let base = 100.0 + f64::from(i);
assert!(s.update(c(base + 1.0, base - 1.0, base)).is_none());
}
assert!(s.update(c(121.0, 119.0, 120.0)).is_some());
}
/// Squeeze flag is binary — `0.0` or `1.0`.
#[test]
fn squeeze_is_binary() {
let candles: Vec<Candle> = (0..60)
.map(|i| {
let m = 100.0 + (f64::from(i) * 0.4).sin() * 2.0;
c(m + 1.0, m - 1.0, m)
})
.collect();
let mut s = TtmSqueeze::new(20, 2.0, 1.5).unwrap();
for o in s.batch(&candles).into_iter().flatten() {
assert!(o.squeeze == 0.0 || o.squeeze == 1.0);
}
}
}
@@ -0,0 +1,251 @@
//! VWAP Standard-Deviation Bands.
use crate::error::{Error, Result};
use crate::ohlcv::Candle;
use crate::traits::Indicator;
/// `VWAP` `StdDev` Bands output.
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct VwapStdDevBandsOutput {
/// Upper band: `vwap + multiplier · sigma`.
pub upper: f64,
/// Middle band: cumulative VWAP of typical price.
pub middle: f64,
/// Lower band: `vwap multiplier · sigma`.
pub lower: f64,
/// Volume-weighted standard deviation of typical price about VWAP.
pub stddev: f64,
}
/// VWAP with volume-weighted standard-deviation envelopes.
///
/// ```text
/// tp_i = typical_price(candle_i) // (high + low + close) / 3
/// sum_v = Σ volume_i
/// sum_pv = Σ tp_i · volume_i
/// sum_p2v = Σ tp_i² · volume_i
/// vwap = sum_pv / sum_v
/// variance = sum_p2v / sum_v vwap² // volume-weighted population variance
/// sigma = sqrt(max(variance, 0))
/// upper/lower = vwap ± multiplier · sigma
/// ```
///
/// The cumulative running sums make every update O(1) with no per-bar replay,
/// matching the streaming contract of [`Vwap`](crate::Vwap). VWAP and its
/// stddev bands are an intraday-session tool: call [`Indicator::reset`] at
/// the start of each session boundary so the accumulators do not span the gap.
///
/// # Example
///
/// ```
/// use wickra_core::{Candle, Indicator, VwapStdDevBands};
///
/// let mut indicator = VwapStdDevBands::new(2.0).unwrap();
/// let mut last = None;
/// for i in 0..40 {
/// let base = 100.0 + f64::from(i);
/// let candle =
/// Candle::new(base, base + 2.0, base - 2.0, base + 1.0, 10.0, i64::from(i)).unwrap();
/// last = indicator.update(candle);
/// }
/// assert!(last.is_some());
/// ```
#[derive(Debug, Clone)]
pub struct VwapStdDevBands {
multiplier: f64,
sum_pv: f64,
sum_p2v: f64,
sum_v: f64,
has_emitted: bool,
}
impl VwapStdDevBands {
/// # Errors
/// Returns [`Error::NonPositiveMultiplier`] if `multiplier` is not strictly
/// positive and finite.
pub fn new(multiplier: f64) -> Result<Self> {
if !multiplier.is_finite() || multiplier <= 0.0 {
return Err(Error::NonPositiveMultiplier);
}
Ok(Self {
multiplier,
sum_pv: 0.0,
sum_p2v: 0.0,
sum_v: 0.0,
has_emitted: false,
})
}
/// Configured multiplier.
pub const fn multiplier(&self) -> f64 {
self.multiplier
}
}
impl Indicator for VwapStdDevBands {
type Input = Candle;
type Output = VwapStdDevBandsOutput;
fn update(&mut self, candle: Candle) -> Option<VwapStdDevBandsOutput> {
let tp = candle.typical_price();
self.sum_pv += tp * candle.volume;
self.sum_p2v += tp * tp * candle.volume;
self.sum_v += candle.volume;
if self.sum_v == 0.0 {
return None;
}
self.has_emitted = true;
let vwap = self.sum_pv / self.sum_v;
// Volume-weighted population variance; clamp tiny negative cancellation
// noise back to zero on near-constant inputs.
let var = (self.sum_p2v / self.sum_v - vwap * vwap).max(0.0);
let sigma = var.sqrt();
Some(VwapStdDevBandsOutput {
upper: vwap + self.multiplier * sigma,
middle: vwap,
lower: vwap - self.multiplier * sigma,
stddev: sigma,
})
}
fn reset(&mut self) {
self.sum_pv = 0.0;
self.sum_p2v = 0.0;
self.sum_v = 0.0;
self.has_emitted = false;
}
fn warmup_period(&self) -> usize {
1
}
fn is_ready(&self) -> bool {
self.has_emitted
}
fn name(&self) -> &'static str {
"VwapStdDevBands"
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::traits::BatchExt;
use approx::assert_relative_eq;
fn c(h: f64, l: f64, cl: f64, v: f64) -> Candle {
Candle::new(cl, h, l, cl, v, 0).unwrap()
}
#[test]
fn rejects_non_positive_multiplier() {
assert!(matches!(
VwapStdDevBands::new(0.0),
Err(Error::NonPositiveMultiplier)
));
assert!(matches!(
VwapStdDevBands::new(-1.0),
Err(Error::NonPositiveMultiplier)
));
assert!(matches!(
VwapStdDevBands::new(f64::NAN),
Err(Error::NonPositiveMultiplier)
));
}
#[test]
fn accessors_and_metadata() {
let v = VwapStdDevBands::new(2.0).unwrap();
assert_relative_eq!(v.multiplier(), 2.0, epsilon = 1e-12);
assert_eq!(v.warmup_period(), 1);
assert_eq!(v.name(), "VwapStdDevBands");
}
#[test]
fn zero_volume_returns_none() {
let mut v = VwapStdDevBands::new(2.0).unwrap();
assert!(v.update(c(10.0, 10.0, 10.0, 0.0)).is_none());
}
#[test]
fn constant_price_collapses_bands() {
let candles: Vec<Candle> = (0..10).map(|_| c(10.0, 10.0, 10.0, 5.0)).collect();
let mut v = VwapStdDevBands::new(2.0).unwrap();
let last = v.batch(&candles).into_iter().flatten().last().unwrap();
assert_relative_eq!(last.middle, 10.0, epsilon = 1e-9);
assert_relative_eq!(last.stddev, 0.0, epsilon = 1e-9);
assert_relative_eq!(last.upper, 10.0, epsilon = 1e-9);
assert_relative_eq!(last.lower, 10.0, epsilon = 1e-9);
}
#[test]
fn upper_above_middle_above_lower() {
let candles: Vec<Candle> = (0..50)
.map(|i| {
let m = 100.0 + (f64::from(i) * 0.2).sin() * 5.0;
c(m + 1.0, m - 1.0, m, 1.0 + f64::from(i % 5))
})
.collect();
let mut v = VwapStdDevBands::new(2.0).unwrap();
for o in v.batch(&candles).into_iter().flatten() {
assert!(o.upper >= o.middle);
assert!(o.middle >= o.lower);
assert!(o.stddev >= 0.0);
}
}
#[test]
fn batch_equals_streaming() {
let candles: Vec<Candle> = (0..40)
.map(|i| {
c(
f64::from(i) + 2.0,
f64::from(i),
f64::from(i) + 1.0,
1.0 + f64::from(i % 4),
)
})
.collect();
let mut a = VwapStdDevBands::new(2.0).unwrap();
let mut b = VwapStdDevBands::new(2.0).unwrap();
assert_eq!(
a.batch(&candles),
candles.iter().map(|x| b.update(*x)).collect::<Vec<_>>()
);
}
#[test]
fn reset_clears_state() {
let candles: Vec<Candle> = (0..10)
.map(|i| c(f64::from(i) + 1.0, f64::from(i) - 1.0, f64::from(i), 1.0))
.collect();
let mut v = VwapStdDevBands::new(2.0).unwrap();
v.batch(&candles);
assert!(v.is_ready());
v.reset();
assert!(!v.is_ready());
// After reset a zero-volume bar still returns `None` (volume is
// required to define the volume-weighted average).
assert_eq!(v.update(c(10.0, 10.0, 10.0, 0.0)), None);
}
/// Reference: two equal-volume bars at typical prices `tp = 8` and `tp = 12`.
/// VWAP = (8 + 12) / 2 = 10. Volume-weighted population variance =
/// (64 + 144) / 2 100 = 4. Sigma = 2. With multiplier 1.5: upper = 13,
/// lower = 7.
#[test]
fn reference_values() {
// typical_price = (high + low + close) / 3. Choose bars where this is
// exactly 8 and 12. Bar A: high=8, low=8, close=8 → tp=8.
// Bar B: high=12, low=12, close=12 → tp=12.
let candles = [c(8.0, 8.0, 8.0, 1.0), c(12.0, 12.0, 12.0, 1.0)];
let mut v = VwapStdDevBands::new(1.5).unwrap();
let _ = v.update(candles[0]);
let out = v.update(candles[1]).unwrap();
assert_relative_eq!(out.middle, 10.0, epsilon = 1e-9);
assert_relative_eq!(out.stddev, 2.0, epsilon = 1e-9);
assert_relative_eq!(out.upper, 13.0, epsilon = 1e-9);
assert_relative_eq!(out.lower, 7.0, epsilon = 1e-9);
}
}