F8: add Bollinger Bandwidth and %b
Completes the F8 family (Bands & channels) end to end: - Rust core: bollinger_bandwidth.rs ((upper - lower) / middle — the squeeze gauge) and percent_b.rs ((price - lower) / (upper - lower) — price position within the bands, unclamped). Both wrap BollingerBands and carry a full Indicator impl, runnable doctest and reference / constant-series / definition-consistency / warmup / reset / batch==streaming tests. - Python: PyBollingerBandwidth / PyPercentB PyO3 classes + module registration + .pyi stubs (defaults (20, 2.0)). - Node: explicit BollingerBandwidthNode and PercentBNode; index.d.ts and index.js updated. - WASM: WasmBollingerBandwidth / WasmPercentB via the scalar macro. - Wiki: Indicator-BollingerBandwidth.md and Indicator-PercentB.md plus rows in Indicators-Overview.md and entries in Home.md. cargo fmt + clippy (core/wickra/data/wasm/node) clean; 362 core tests, 25 data tests and 51 doctests green.
This commit is contained in:
@@ -0,0 +1,176 @@
|
||||
//! Bollinger Bandwidth.
|
||||
|
||||
use crate::error::Result;
|
||||
use crate::traits::Indicator;
|
||||
|
||||
use super::BollingerBands;
|
||||
|
||||
/// Bollinger Bandwidth — the width of the Bollinger Bands relative to the
|
||||
/// middle band.
|
||||
///
|
||||
/// ```text
|
||||
/// Bandwidth = (upper − lower) / middle
|
||||
/// ```
|
||||
///
|
||||
/// Because the bands are `middle ± multiplier · stddev`, the bandwidth is
|
||||
/// `2 · multiplier · stddev / middle` — a normalised volatility reading. Its
|
||||
/// value is the basis of two classic patterns: the **squeeze** (bandwidth at a
|
||||
/// multi-month low, signalling a coiled, low-volatility market about to
|
||||
/// expand) and the **bulge** (bandwidth at an extreme high).
|
||||
///
|
||||
/// # Example
|
||||
///
|
||||
/// ```
|
||||
/// use wickra_core::{Indicator, BollingerBandwidth};
|
||||
///
|
||||
/// let mut indicator = BollingerBandwidth::new(20, 2.0).unwrap();
|
||||
/// let mut last = None;
|
||||
/// for i in 0..80 {
|
||||
/// last = indicator.update(100.0 + (f64::from(i) * 0.3).sin() * 6.0);
|
||||
/// }
|
||||
/// assert!(last.is_some());
|
||||
/// ```
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct BollingerBandwidth {
|
||||
bands: BollingerBands,
|
||||
last: Option<f64>,
|
||||
}
|
||||
|
||||
impl BollingerBandwidth {
|
||||
/// Construct a new Bollinger Bandwidth indicator.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns [`crate::Error::PeriodZero`] for `period == 0` and
|
||||
/// [`crate::Error::NonPositiveMultiplier`] for `multiplier <= 0`.
|
||||
pub fn new(period: usize, multiplier: f64) -> Result<Self> {
|
||||
Ok(Self {
|
||||
bands: BollingerBands::new(period, multiplier)?,
|
||||
last: None,
|
||||
})
|
||||
}
|
||||
|
||||
/// Configured period.
|
||||
pub const fn period(&self) -> usize {
|
||||
self.bands.period()
|
||||
}
|
||||
|
||||
/// Configured multiplier.
|
||||
pub const fn multiplier(&self) -> f64 {
|
||||
self.bands.multiplier()
|
||||
}
|
||||
|
||||
/// Current value if available.
|
||||
pub const fn value(&self) -> Option<f64> {
|
||||
self.last
|
||||
}
|
||||
}
|
||||
|
||||
impl Indicator for BollingerBandwidth {
|
||||
type Input = f64;
|
||||
type Output = f64;
|
||||
|
||||
fn update(&mut self, input: f64) -> Option<f64> {
|
||||
let o = self.bands.update(input)?;
|
||||
let bandwidth = if o.middle == 0.0 {
|
||||
// Undefined against a zero middle band.
|
||||
0.0
|
||||
} else {
|
||||
(o.upper - o.lower) / o.middle
|
||||
};
|
||||
self.last = Some(bandwidth);
|
||||
Some(bandwidth)
|
||||
}
|
||||
|
||||
fn reset(&mut self) {
|
||||
self.bands.reset();
|
||||
self.last = None;
|
||||
}
|
||||
|
||||
fn warmup_period(&self) -> usize {
|
||||
self.bands.warmup_period()
|
||||
}
|
||||
|
||||
fn is_ready(&self) -> bool {
|
||||
self.last.is_some()
|
||||
}
|
||||
|
||||
fn name(&self) -> &'static str {
|
||||
"BollingerBandwidth"
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::traits::BatchExt;
|
||||
use approx::assert_relative_eq;
|
||||
|
||||
#[test]
|
||||
fn new_rejects_invalid_parameters() {
|
||||
assert!(BollingerBandwidth::new(0, 2.0).is_err());
|
||||
assert!(BollingerBandwidth::new(20, 0.0).is_err());
|
||||
assert!(BollingerBandwidth::new(20, -1.0).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn constant_series_yields_zero() {
|
||||
// Flat prices: the bands collapse onto the middle, so width is 0.
|
||||
let mut bbw = BollingerBandwidth::new(5, 2.0).unwrap();
|
||||
let out = bbw.batch(&[100.0; 20]);
|
||||
for v in out.iter().skip(4).flatten() {
|
||||
assert_relative_eq!(*v, 0.0, epsilon = 1e-12);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn matches_bands_definition() {
|
||||
// Bandwidth must equal (upper - lower) / middle from BollingerBands.
|
||||
let prices: Vec<f64> = (1..=60)
|
||||
.map(|i| 100.0 + (f64::from(i) * 0.3).sin() * 8.0)
|
||||
.collect();
|
||||
let bbw_out = BollingerBandwidth::new(20, 2.0).unwrap().batch(&prices);
|
||||
let bands_out = BollingerBands::new(20, 2.0).unwrap().batch(&prices);
|
||||
for (w, b) in bbw_out.iter().zip(bands_out.iter()) {
|
||||
match (w, b) {
|
||||
(Some(wv), Some(bv)) => {
|
||||
assert_relative_eq!(*wv, (bv.upper - bv.lower) / bv.middle, epsilon = 1e-12);
|
||||
}
|
||||
(None, None) => {}
|
||||
_ => panic!("warmup mismatch"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn output_is_non_negative() {
|
||||
let mut bbw = BollingerBandwidth::new(20, 2.0).unwrap();
|
||||
let prices: Vec<f64> = (1..=120)
|
||||
.map(|i| 100.0 + (f64::from(i) * 0.25).sin() * 12.0)
|
||||
.collect();
|
||||
for v in bbw.batch(&prices).into_iter().flatten() {
|
||||
assert!(v >= 0.0, "bandwidth must be non-negative, got {v}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reset_clears_state() {
|
||||
let mut bbw = BollingerBandwidth::new(5, 2.0).unwrap();
|
||||
bbw.batch(&(1..=20).map(f64::from).collect::<Vec<_>>());
|
||||
assert!(bbw.is_ready());
|
||||
bbw.reset();
|
||||
assert!(!bbw.is_ready());
|
||||
assert_eq!(bbw.update(1.0), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn batch_equals_streaming() {
|
||||
let prices: Vec<f64> = (1..=80)
|
||||
.map(|i| 100.0 + (f64::from(i) * 0.3).cos() * 7.0)
|
||||
.collect();
|
||||
let batch = BollingerBandwidth::new(20, 2.0).unwrap().batch(&prices);
|
||||
let mut b = BollingerBandwidth::new(20, 2.0).unwrap();
|
||||
let streamed: Vec<_> = prices.iter().map(|p| b.update(*p)).collect();
|
||||
assert_eq!(batch, streamed);
|
||||
}
|
||||
}
|
||||
@@ -10,6 +10,7 @@ mod aroon_oscillator;
|
||||
mod atr;
|
||||
mod awesome_oscillator;
|
||||
mod bollinger;
|
||||
mod bollinger_bandwidth;
|
||||
mod cci;
|
||||
mod cmo;
|
||||
mod coppock;
|
||||
@@ -27,6 +28,7 @@ mod mfi;
|
||||
mod mom;
|
||||
mod natr;
|
||||
mod obv;
|
||||
mod percent_b;
|
||||
mod pmo;
|
||||
mod ppo;
|
||||
mod psar;
|
||||
@@ -57,6 +59,7 @@ pub use aroon_oscillator::AroonOscillator;
|
||||
pub use atr::Atr;
|
||||
pub use awesome_oscillator::AwesomeOscillator;
|
||||
pub use bollinger::{BollingerBands, BollingerOutput};
|
||||
pub use bollinger_bandwidth::BollingerBandwidth;
|
||||
pub use cci::Cci;
|
||||
pub use cmo::Cmo;
|
||||
pub use coppock::Coppock;
|
||||
@@ -74,6 +77,7 @@ pub use mfi::Mfi;
|
||||
pub use mom::Mom;
|
||||
pub use natr::Natr;
|
||||
pub use obv::Obv;
|
||||
pub use percent_b::PercentB;
|
||||
pub use pmo::Pmo;
|
||||
pub use ppo::Ppo;
|
||||
pub use psar::Psar;
|
||||
|
||||
@@ -0,0 +1,184 @@
|
||||
//! Bollinger %b.
|
||||
|
||||
use crate::error::Result;
|
||||
use crate::traits::Indicator;
|
||||
|
||||
use super::BollingerBands;
|
||||
|
||||
/// Bollinger %b — where price sits within the Bollinger Bands.
|
||||
///
|
||||
/// ```text
|
||||
/// %b = (price − lower) / (upper − lower)
|
||||
/// ```
|
||||
///
|
||||
/// `%b = 1` means price is exactly on the upper band, `%b = 0` on the lower
|
||||
/// band, `%b = 0.5` on the middle band. The value is **not** clamped: price
|
||||
/// breaking above the upper band gives `%b > 1`, breaking below the lower band
|
||||
/// gives `%b < 0`. That makes %b a clean, scale-free way to compare a price's
|
||||
/// band position across instruments and to spot band overshoots.
|
||||
///
|
||||
/// # Example
|
||||
///
|
||||
/// ```
|
||||
/// use wickra_core::{Indicator, PercentB};
|
||||
///
|
||||
/// let mut indicator = PercentB::new(20, 2.0).unwrap();
|
||||
/// let mut last = None;
|
||||
/// for i in 0..80 {
|
||||
/// last = indicator.update(100.0 + (f64::from(i) * 0.3).sin() * 6.0);
|
||||
/// }
|
||||
/// assert!(last.is_some());
|
||||
/// ```
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct PercentB {
|
||||
bands: BollingerBands,
|
||||
last: Option<f64>,
|
||||
}
|
||||
|
||||
impl PercentB {
|
||||
/// Construct a new %b indicator.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns [`crate::Error::PeriodZero`] for `period == 0` and
|
||||
/// [`crate::Error::NonPositiveMultiplier`] for `multiplier <= 0`.
|
||||
pub fn new(period: usize, multiplier: f64) -> Result<Self> {
|
||||
Ok(Self {
|
||||
bands: BollingerBands::new(period, multiplier)?,
|
||||
last: None,
|
||||
})
|
||||
}
|
||||
|
||||
/// Configured period.
|
||||
pub const fn period(&self) -> usize {
|
||||
self.bands.period()
|
||||
}
|
||||
|
||||
/// Configured multiplier.
|
||||
pub const fn multiplier(&self) -> f64 {
|
||||
self.bands.multiplier()
|
||||
}
|
||||
|
||||
/// Current value if available.
|
||||
pub const fn value(&self) -> Option<f64> {
|
||||
self.last
|
||||
}
|
||||
}
|
||||
|
||||
impl Indicator for PercentB {
|
||||
type Input = f64;
|
||||
type Output = f64;
|
||||
|
||||
fn update(&mut self, input: f64) -> Option<f64> {
|
||||
let o = self.bands.update(input)?;
|
||||
let width = o.upper - o.lower;
|
||||
let percent_b = if width == 0.0 {
|
||||
// Bands collapsed onto the middle: price is exactly mid-band.
|
||||
0.5
|
||||
} else {
|
||||
(input - o.lower) / width
|
||||
};
|
||||
self.last = Some(percent_b);
|
||||
Some(percent_b)
|
||||
}
|
||||
|
||||
fn reset(&mut self) {
|
||||
self.bands.reset();
|
||||
self.last = None;
|
||||
}
|
||||
|
||||
fn warmup_period(&self) -> usize {
|
||||
self.bands.warmup_period()
|
||||
}
|
||||
|
||||
fn is_ready(&self) -> bool {
|
||||
self.last.is_some()
|
||||
}
|
||||
|
||||
fn name(&self) -> &'static str {
|
||||
"PercentB"
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::traits::BatchExt;
|
||||
use approx::assert_relative_eq;
|
||||
|
||||
#[test]
|
||||
fn new_rejects_invalid_parameters() {
|
||||
assert!(PercentB::new(0, 2.0).is_err());
|
||||
assert!(PercentB::new(20, 0.0).is_err());
|
||||
assert!(PercentB::new(20, -1.0).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn constant_series_yields_midpoint() {
|
||||
// Flat prices: bands collapse, price is exactly mid-band -> 0.5.
|
||||
let mut pb = PercentB::new(5, 2.0).unwrap();
|
||||
let out = pb.batch(&[100.0; 20]);
|
||||
for v in out.iter().skip(4).flatten() {
|
||||
assert_relative_eq!(*v, 0.5, epsilon = 1e-12);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn matches_bands_definition() {
|
||||
// %b must equal (price - lower) / (upper - lower) from BollingerBands.
|
||||
let prices: Vec<f64> = (1..=60)
|
||||
.map(|i| 100.0 + (f64::from(i) * 0.3).sin() * 8.0)
|
||||
.collect();
|
||||
let pb_out = PercentB::new(20, 2.0).unwrap().batch(&prices);
|
||||
let bands_out = BollingerBands::new(20, 2.0).unwrap().batch(&prices);
|
||||
for (i, (p, b)) in pb_out.iter().zip(bands_out.iter()).enumerate() {
|
||||
match (p, b) {
|
||||
(Some(pv), Some(bv)) => {
|
||||
let want = (prices[i] - bv.lower) / (bv.upper - bv.lower);
|
||||
assert_relative_eq!(*pv, want, epsilon = 1e-12);
|
||||
}
|
||||
(None, None) => {}
|
||||
_ => panic!("warmup mismatch at {i}"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn price_at_middle_is_half() {
|
||||
// A symmetric oscillation keeps the SMA centred; when price crosses
|
||||
// the SMA, %b passes through 0.5. Verified via the bands definition.
|
||||
let prices: Vec<f64> = (1..=60)
|
||||
.map(|i| 100.0 + (f64::from(i) * 0.5).sin() * 5.0)
|
||||
.collect();
|
||||
let pb_out = PercentB::new(20, 2.0).unwrap().batch(&prices);
|
||||
let bands_out = BollingerBands::new(20, 2.0).unwrap().batch(&prices);
|
||||
for (i, (p, b)) in pb_out.iter().zip(bands_out.iter()).enumerate() {
|
||||
if let (Some(pv), Some(bv)) = (p, b) {
|
||||
if (prices[i] - bv.middle).abs() < 1e-9 {
|
||||
assert_relative_eq!(*pv, 0.5, epsilon = 1e-6);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reset_clears_state() {
|
||||
let mut pb = PercentB::new(5, 2.0).unwrap();
|
||||
pb.batch(&(1..=20).map(f64::from).collect::<Vec<_>>());
|
||||
assert!(pb.is_ready());
|
||||
pb.reset();
|
||||
assert!(!pb.is_ready());
|
||||
assert_eq!(pb.update(1.0), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn batch_equals_streaming() {
|
||||
let prices: Vec<f64> = (1..=80)
|
||||
.map(|i| 100.0 + (f64::from(i) * 0.3).cos() * 7.0)
|
||||
.collect();
|
||||
let batch = PercentB::new(20, 2.0).unwrap().batch(&prices);
|
||||
let mut b = PercentB::new(20, 2.0).unwrap();
|
||||
let streamed: Vec<_> = prices.iter().map(|p| b.update(*p)).collect();
|
||||
assert_eq!(batch, streamed);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user