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