feat(indicators): add B6 Bands & Channels family (429 -> 434) (#191)
Adds the **B6 Bands & Channels** batch — five band/channel indicators, taking the catalogue from 429 to 434.
| Indicator | Input → Output | Summary |
|-----------|----------------|---------|
| `ProjectionBands` | `Candle` → `{upper,middle,lower}` | Widner forward-projected high/low regression envelope |
| `ProjectionOscillator` | `Candle` → `f64` | Close position inside the projection bands, scaled 0..100 |
| `QuartileBands` | `f64` → `{upper,middle,lower}` | Rolling 25th/50th/75th-percentile (Q1/median/Q3) envelope |
| `BomarBands` | `f64` → `{upper,middle,lower}` | Adaptive percentage bands containing a target coverage fraction of recent closes |
| `MedianChannel` | `f64` → `{upper,middle,lower}` | Robust median ± multiplier·MAD envelope |
All five are distinct from existing indicators (verified against the core: `LinRegChannel`, `StandardErrorBands`, `Donchian`, `RollingQuantile`, `HurstChannel`). SKIPped from the roadmap: Price Channel (= `Donchian`) and Moving-Average Channel (≈ `MaEnvelope`/`Keltner`).
Each ships:
- Core indicator with per-branch unit tests (Codecov-strict 100%).
- python / node / wasm bindings (struct outputs are hand-written; `ProjectionOscillator` uses the generated candle→f64 path).
- Fuzz drives, python (`MULTI`/`SCALAR_MULTI`/`CANDLE_SCALAR`) + node test registries, README + CHANGELOG counter bump to 434.
Verified locally: `cargo fmt`, `clippy --workspace --all-targets --all-features -D warnings` (clean), `wickra-core` 3511 lib + 392 doc tests, node 509 tests, pytest 840.
This commit is contained in:
@@ -0,0 +1,256 @@
|
||||
//! Bomar Bands — adaptive percentage bands that contain a target fraction of
|
||||
//! recent price.
|
||||
|
||||
use std::collections::VecDeque;
|
||||
|
||||
use crate::error::{Error, Result};
|
||||
use crate::indicators::rolling_quantile::quantile_sorted;
|
||||
use crate::traits::Indicator;
|
||||
|
||||
/// Bomar Bands output.
|
||||
#[derive(Debug, Clone, Copy, PartialEq)]
|
||||
pub struct BomarBandsOutput {
|
||||
/// Upper band: `middle + |middle| · p`.
|
||||
pub upper: f64,
|
||||
/// Middle line: the simple moving average over the window.
|
||||
pub middle: f64,
|
||||
/// Lower band: `middle − |middle| · p`.
|
||||
pub lower: f64,
|
||||
}
|
||||
|
||||
/// Bomar Bands: percentage bands whose width adapts so that a fixed `coverage`
|
||||
/// fraction of recent closes falls inside them.
|
||||
///
|
||||
/// The Bomar Bands predate Bollinger Bands; John Bollinger cites them as an
|
||||
/// inspiration — percentage bands around a moving average, with the percentage
|
||||
/// tuned so a fixed share (classically ~85%) of price stayed within. Wickra
|
||||
/// realises that idea deterministically: the half-width is the `coverage`
|
||||
/// quantile of the relative deviations from the midline, so by construction
|
||||
/// `coverage` of the window's closes lie inside the bands.
|
||||
///
|
||||
/// ```text
|
||||
/// middle = SMA(close, period)
|
||||
/// dev_i = | close_i / middle − 1 | // relative distance from midline
|
||||
/// p = coverage-quantile of { dev_i } // type-7 interpolation
|
||||
/// upper = middle + |middle| · p
|
||||
/// lower = middle − |middle| · p
|
||||
/// ```
|
||||
///
|
||||
/// Unlike the fixed-percentage [`MaEnvelope`](crate::MaEnvelope), the offset
|
||||
/// here is data-driven: the bands widen in turbulent regimes and tighten in
|
||||
/// quiet ones without a volatility input. Unlike Bollinger Bands, the width is
|
||||
/// an order statistic of the actual deviations rather than a multiple of the
|
||||
/// standard deviation, so it is unaffected by the shape of the tails beyond the
|
||||
/// `coverage` rank. When the midline is zero the relative deviation is
|
||||
/// undefined and the bands collapse onto the midline.
|
||||
///
|
||||
/// # Example
|
||||
///
|
||||
/// ```
|
||||
/// use wickra_core::{BomarBands, Indicator};
|
||||
///
|
||||
/// let mut indicator = BomarBands::new(20, 0.85).unwrap();
|
||||
/// let mut last = None;
|
||||
/// for i in 0..40 {
|
||||
/// last = indicator.update(100.0 + f64::from(i % 7));
|
||||
/// }
|
||||
/// assert!(last.is_some());
|
||||
/// ```
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct BomarBands {
|
||||
period: usize,
|
||||
coverage: f64,
|
||||
window: VecDeque<f64>,
|
||||
scratch: Vec<f64>,
|
||||
}
|
||||
|
||||
impl BomarBands {
|
||||
/// Construct new Bomar Bands.
|
||||
///
|
||||
/// `coverage` is the target fraction of closes to contain, in `(0.0, 1.0]`.
|
||||
///
|
||||
/// # Errors
|
||||
/// Returns [`Error::PeriodZero`] if `period == 0`, or
|
||||
/// [`Error::InvalidParameter`] if `coverage` is not a finite value in
|
||||
/// `(0.0, 1.0]`.
|
||||
pub fn new(period: usize, coverage: f64) -> Result<Self> {
|
||||
if period == 0 {
|
||||
return Err(Error::PeriodZero);
|
||||
}
|
||||
if !coverage.is_finite() || coverage <= 0.0 || coverage > 1.0 {
|
||||
return Err(Error::InvalidParameter {
|
||||
message: "bomar bands coverage must be a finite value in (0.0, 1.0]",
|
||||
});
|
||||
}
|
||||
Ok(Self {
|
||||
period,
|
||||
coverage,
|
||||
window: VecDeque::with_capacity(period),
|
||||
scratch: Vec::with_capacity(period),
|
||||
})
|
||||
}
|
||||
|
||||
/// Configured period.
|
||||
pub const fn period(&self) -> usize {
|
||||
self.period
|
||||
}
|
||||
|
||||
/// Configured coverage fraction.
|
||||
pub const fn coverage(&self) -> f64 {
|
||||
self.coverage
|
||||
}
|
||||
}
|
||||
|
||||
impl Indicator for BomarBands {
|
||||
type Input = f64;
|
||||
type Output = BomarBandsOutput;
|
||||
|
||||
fn update(&mut self, value: f64) -> Option<BomarBandsOutput> {
|
||||
if self.window.len() == self.period {
|
||||
self.window.pop_front();
|
||||
}
|
||||
self.window.push_back(value);
|
||||
if self.window.len() < self.period {
|
||||
return None;
|
||||
}
|
||||
let sum: f64 = self.window.iter().sum();
|
||||
let middle = sum / (self.period as f64);
|
||||
let denom = middle.abs();
|
||||
|
||||
self.scratch.clear();
|
||||
for &v in &self.window {
|
||||
let dev = if denom == 0.0 {
|
||||
0.0
|
||||
} else {
|
||||
((v - middle) / denom).abs()
|
||||
};
|
||||
self.scratch.push(dev);
|
||||
}
|
||||
self.scratch.sort_by(f64::total_cmp);
|
||||
let p = quantile_sorted(&self.scratch, self.coverage);
|
||||
let offset = denom * p;
|
||||
|
||||
Some(BomarBandsOutput {
|
||||
upper: middle + offset,
|
||||
middle,
|
||||
lower: middle - offset,
|
||||
})
|
||||
}
|
||||
|
||||
fn reset(&mut self) {
|
||||
self.window.clear();
|
||||
self.scratch.clear();
|
||||
}
|
||||
|
||||
fn warmup_period(&self) -> usize {
|
||||
self.period
|
||||
}
|
||||
|
||||
fn is_ready(&self) -> bool {
|
||||
self.window.len() == self.period
|
||||
}
|
||||
|
||||
fn name(&self) -> &'static str {
|
||||
"BomarBands"
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::traits::BatchExt;
|
||||
use approx::assert_relative_eq;
|
||||
|
||||
#[test]
|
||||
fn rejects_zero_period() {
|
||||
assert!(matches!(BomarBands::new(0, 0.85), Err(Error::PeriodZero)));
|
||||
assert!(BomarBands::new(1, 0.85).is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_out_of_range_coverage() {
|
||||
assert!(matches!(
|
||||
BomarBands::new(20, 0.0),
|
||||
Err(Error::InvalidParameter { .. })
|
||||
));
|
||||
assert!(matches!(
|
||||
BomarBands::new(20, 1.1),
|
||||
Err(Error::InvalidParameter { .. })
|
||||
));
|
||||
assert!(matches!(
|
||||
BomarBands::new(20, -0.5),
|
||||
Err(Error::InvalidParameter { .. })
|
||||
));
|
||||
assert!(matches!(
|
||||
BomarBands::new(20, f64::NAN),
|
||||
Err(Error::InvalidParameter { .. })
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn accessors_and_metadata() {
|
||||
let bb = BomarBands::new(20, 0.85).unwrap();
|
||||
assert_eq!(bb.period(), 20);
|
||||
assert_relative_eq!(bb.coverage(), 0.85, epsilon = 1e-12);
|
||||
assert_eq!(bb.warmup_period(), 20);
|
||||
assert_eq!(bb.name(), "BomarBands");
|
||||
assert!(!bb.is_ready());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn warms_up_then_emits() {
|
||||
let mut bb = BomarBands::new(4, 0.85).unwrap();
|
||||
assert!(bb.update(100.0).is_none());
|
||||
assert!(bb.update(102.0).is_none());
|
||||
assert!(bb.update(98.0).is_none());
|
||||
assert!(bb.update(104.0).is_some());
|
||||
assert!(bb.is_ready());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn known_bands() {
|
||||
// mean=101; |dev| = {1,1,3,3}/101; coverage 0.85 quantile -> 3/101.
|
||||
// offset = 101 * 3/101 = 3 -> upper 104, lower 98.
|
||||
let mut bb = BomarBands::new(4, 0.85).unwrap();
|
||||
let out = bb.batch(&[100.0, 102.0, 98.0, 104.0]);
|
||||
let last = out[3].unwrap();
|
||||
assert_relative_eq!(last.middle, 101.0, epsilon = 1e-9);
|
||||
assert_relative_eq!(last.upper, 104.0, epsilon = 1e-9);
|
||||
assert_relative_eq!(last.lower, 98.0, epsilon = 1e-9);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn zero_midline_collapses_bands() {
|
||||
// Window mean exactly zero -> relative deviation undefined -> collapse.
|
||||
let mut bb = BomarBands::new(2, 0.85).unwrap();
|
||||
let out = bb.batch(&[3.0, -3.0]);
|
||||
let last = out[1].unwrap();
|
||||
assert_relative_eq!(last.middle, 0.0, epsilon = 1e-12);
|
||||
assert_relative_eq!(last.upper, 0.0, epsilon = 1e-12);
|
||||
assert_relative_eq!(last.lower, 0.0, epsilon = 1e-12);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rolling_window_evicts_oldest() {
|
||||
// Eight values through a period-4 window: only the last four survive,
|
||||
// reproducing the `known_bands` window.
|
||||
let mut bb = BomarBands::new(4, 0.85).unwrap();
|
||||
let out = bb.batch(&[50.0, 50.0, 50.0, 50.0, 100.0, 102.0, 98.0, 104.0]);
|
||||
let last = out[7].unwrap();
|
||||
assert_relative_eq!(last.middle, 101.0, epsilon = 1e-9);
|
||||
assert_relative_eq!(last.upper, 104.0, epsilon = 1e-9);
|
||||
assert_relative_eq!(last.lower, 98.0, epsilon = 1e-9);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reset_clears_state() {
|
||||
let mut bb = BomarBands::new(4, 0.85).unwrap();
|
||||
for v in [100.0, 102.0, 98.0, 104.0] {
|
||||
bb.update(v);
|
||||
}
|
||||
assert!(bb.is_ready());
|
||||
bb.reset();
|
||||
assert!(!bb.is_ready());
|
||||
assert!(bb.update(100.0).is_none());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,237 @@
|
||||
//! Median Channel — a robust median ± MAD envelope.
|
||||
|
||||
use std::collections::VecDeque;
|
||||
|
||||
use crate::error::{Error, Result};
|
||||
use crate::indicators::rolling_quantile::quantile_sorted;
|
||||
use crate::traits::Indicator;
|
||||
|
||||
/// Median Channel output.
|
||||
#[derive(Debug, Clone, Copy, PartialEq)]
|
||||
pub struct MedianChannelOutput {
|
||||
/// Upper band: `median + multiplier · MAD`.
|
||||
pub upper: f64,
|
||||
/// Middle line: the rolling median.
|
||||
pub middle: f64,
|
||||
/// Lower band: `median − multiplier · MAD`.
|
||||
pub lower: f64,
|
||||
}
|
||||
|
||||
/// Median Channel: a robust analogue of Bollinger Bands built from the rolling
|
||||
/// median and the median absolute deviation (MAD).
|
||||
///
|
||||
/// ```text
|
||||
/// middle = median(close, period)
|
||||
/// MAD = median( | close_i − middle | )
|
||||
/// upper = middle + multiplier · MAD
|
||||
/// lower = middle − multiplier · MAD
|
||||
/// ```
|
||||
///
|
||||
/// Where [`BollingerBands`](crate::BollingerBands) centre on the mean and scale
|
||||
/// by the standard deviation — both of which a single spike can drag
|
||||
/// arbitrarily far — the Median Channel uses two order statistics. The
|
||||
/// breakdown point of the median and MAD is 50%: up to half the window can be
|
||||
/// contaminated before the centre or width is materially distorted. That makes
|
||||
/// the channel well suited to noisy, gap-prone, or fat-tailed series where
|
||||
/// Bollinger Bands flare on every outlier. Both quantiles use the type-7
|
||||
/// interpolation shared with [`RollingQuantile`](crate::RollingQuantile).
|
||||
///
|
||||
/// # Example
|
||||
///
|
||||
/// ```
|
||||
/// use wickra_core::{Indicator, MedianChannel};
|
||||
///
|
||||
/// let mut indicator = MedianChannel::new(20, 2.0).unwrap();
|
||||
/// let mut last = None;
|
||||
/// for i in 0..40 {
|
||||
/// last = indicator.update(100.0 + f64::from(i % 5));
|
||||
/// }
|
||||
/// assert!(last.is_some());
|
||||
/// ```
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct MedianChannel {
|
||||
period: usize,
|
||||
multiplier: f64,
|
||||
window: VecDeque<f64>,
|
||||
scratch: Vec<f64>,
|
||||
deviations: Vec<f64>,
|
||||
}
|
||||
|
||||
impl MedianChannel {
|
||||
/// Construct a new Median Channel.
|
||||
///
|
||||
/// # Errors
|
||||
/// Returns [`Error::PeriodZero`] if `period == 0`, or
|
||||
/// [`Error::NonPositiveMultiplier`] if `multiplier` is not strictly
|
||||
/// positive and finite.
|
||||
pub fn new(period: usize, multiplier: f64) -> Result<Self> {
|
||||
if period == 0 {
|
||||
return Err(Error::PeriodZero);
|
||||
}
|
||||
if !multiplier.is_finite() || multiplier <= 0.0 {
|
||||
return Err(Error::NonPositiveMultiplier);
|
||||
}
|
||||
Ok(Self {
|
||||
period,
|
||||
multiplier,
|
||||
window: VecDeque::with_capacity(period),
|
||||
scratch: Vec::with_capacity(period),
|
||||
deviations: Vec::with_capacity(period),
|
||||
})
|
||||
}
|
||||
|
||||
/// Configured period.
|
||||
pub const fn period(&self) -> usize {
|
||||
self.period
|
||||
}
|
||||
|
||||
/// Configured multiplier.
|
||||
pub const fn multiplier(&self) -> f64 {
|
||||
self.multiplier
|
||||
}
|
||||
}
|
||||
|
||||
impl Indicator for MedianChannel {
|
||||
type Input = f64;
|
||||
type Output = MedianChannelOutput;
|
||||
|
||||
fn update(&mut self, value: f64) -> Option<MedianChannelOutput> {
|
||||
if self.window.len() == self.period {
|
||||
self.window.pop_front();
|
||||
}
|
||||
self.window.push_back(value);
|
||||
if self.window.len() < self.period {
|
||||
return None;
|
||||
}
|
||||
self.scratch.clear();
|
||||
self.scratch.extend(self.window.iter().copied());
|
||||
self.scratch.sort_by(f64::total_cmp);
|
||||
let median = quantile_sorted(&self.scratch, 0.5);
|
||||
|
||||
self.deviations.clear();
|
||||
for &v in &self.window {
|
||||
self.deviations.push((v - median).abs());
|
||||
}
|
||||
self.deviations.sort_by(f64::total_cmp);
|
||||
let mad = quantile_sorted(&self.deviations, 0.5);
|
||||
let offset = self.multiplier * mad;
|
||||
|
||||
Some(MedianChannelOutput {
|
||||
upper: median + offset,
|
||||
middle: median,
|
||||
lower: median - offset,
|
||||
})
|
||||
}
|
||||
|
||||
fn reset(&mut self) {
|
||||
self.window.clear();
|
||||
self.scratch.clear();
|
||||
self.deviations.clear();
|
||||
}
|
||||
|
||||
fn warmup_period(&self) -> usize {
|
||||
self.period
|
||||
}
|
||||
|
||||
fn is_ready(&self) -> bool {
|
||||
self.window.len() == self.period
|
||||
}
|
||||
|
||||
fn name(&self) -> &'static str {
|
||||
"MedianChannel"
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::traits::BatchExt;
|
||||
use approx::assert_relative_eq;
|
||||
|
||||
#[test]
|
||||
fn rejects_zero_period() {
|
||||
assert!(matches!(MedianChannel::new(0, 2.0), Err(Error::PeriodZero)));
|
||||
assert!(MedianChannel::new(1, 2.0).is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_non_positive_multiplier() {
|
||||
assert!(matches!(
|
||||
MedianChannel::new(20, 0.0),
|
||||
Err(Error::NonPositiveMultiplier)
|
||||
));
|
||||
assert!(matches!(
|
||||
MedianChannel::new(20, -1.0),
|
||||
Err(Error::NonPositiveMultiplier)
|
||||
));
|
||||
assert!(matches!(
|
||||
MedianChannel::new(20, f64::NAN),
|
||||
Err(Error::NonPositiveMultiplier)
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn accessors_and_metadata() {
|
||||
let mc = MedianChannel::new(20, 2.0).unwrap();
|
||||
assert_eq!(mc.period(), 20);
|
||||
assert_relative_eq!(mc.multiplier(), 2.0, epsilon = 1e-12);
|
||||
assert_eq!(mc.warmup_period(), 20);
|
||||
assert_eq!(mc.name(), "MedianChannel");
|
||||
assert!(!mc.is_ready());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn warms_up_then_emits() {
|
||||
let mut mc = MedianChannel::new(5, 2.0).unwrap();
|
||||
for v in [1.0, 2.0, 3.0, 4.0] {
|
||||
assert!(mc.update(v).is_none());
|
||||
}
|
||||
assert!(mc.update(5.0).is_some());
|
||||
assert!(mc.is_ready());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn known_channel() {
|
||||
// [1,2,3,4,5]: median 3; |dev| sorted [0,1,1,2,2] -> MAD 1.
|
||||
// upper = 3 + 2*1 = 5; lower = 3 - 2*1 = 1.
|
||||
let mut mc = MedianChannel::new(5, 2.0).unwrap();
|
||||
let out = mc.batch(&[1.0, 2.0, 3.0, 4.0, 5.0]);
|
||||
let last = out[4].unwrap();
|
||||
assert_relative_eq!(last.middle, 3.0, epsilon = 1e-12);
|
||||
assert_relative_eq!(last.upper, 5.0, epsilon = 1e-12);
|
||||
assert_relative_eq!(last.lower, 1.0, epsilon = 1e-12);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn robust_to_outlier() {
|
||||
// Replacing the last value with a huge spike leaves the median centre
|
||||
// unchanged (still the middle order statistic).
|
||||
let mut mc = MedianChannel::new(5, 2.0).unwrap();
|
||||
let out = mc.batch(&[1.0, 2.0, 3.0, 4.0, 1_000.0]);
|
||||
assert_relative_eq!(out[4].unwrap().middle, 3.0, epsilon = 1e-12);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rolling_window_evicts_oldest() {
|
||||
// Ten values through a period-5 window: only the last five survive,
|
||||
// reproducing the `known_channel` window.
|
||||
let mut mc = MedianChannel::new(5, 2.0).unwrap();
|
||||
let out = mc.batch(&[10.0, 10.0, 10.0, 10.0, 10.0, 1.0, 2.0, 3.0, 4.0, 5.0]);
|
||||
let last = out[9].unwrap();
|
||||
assert_relative_eq!(last.middle, 3.0, epsilon = 1e-12);
|
||||
assert_relative_eq!(last.upper, 5.0, epsilon = 1e-12);
|
||||
assert_relative_eq!(last.lower, 1.0, epsilon = 1e-12);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reset_clears_state() {
|
||||
let mut mc = MedianChannel::new(5, 2.0).unwrap();
|
||||
for v in [1.0, 2.0, 3.0, 4.0, 5.0] {
|
||||
mc.update(v);
|
||||
}
|
||||
assert!(mc.is_ready());
|
||||
mc.reset();
|
||||
assert!(!mc.is_ready());
|
||||
assert!(mc.update(1.0).is_none());
|
||||
}
|
||||
}
|
||||
@@ -52,6 +52,7 @@ mod bipower_variation;
|
||||
mod body_size_pct;
|
||||
mod bollinger;
|
||||
mod bollinger_bandwidth;
|
||||
mod bomar_bands;
|
||||
mod breadth_thrust;
|
||||
mod breakaway;
|
||||
mod bullish_percent_index;
|
||||
@@ -229,6 +230,7 @@ mod mcclellan_oscillator;
|
||||
mod mcclellan_summation_index;
|
||||
mod mcginley_dynamic;
|
||||
mod median_absolute_deviation;
|
||||
mod median_channel;
|
||||
mod median_ma;
|
||||
mod median_price;
|
||||
mod mfi;
|
||||
@@ -276,10 +278,13 @@ mod polarized_fractal_efficiency;
|
||||
mod ppo;
|
||||
mod ppo_histogram;
|
||||
mod profit_factor;
|
||||
mod projection_bands;
|
||||
mod projection_oscillator;
|
||||
mod psar;
|
||||
mod pvi;
|
||||
mod qqe;
|
||||
mod qstick;
|
||||
mod quartile_bands;
|
||||
mod quoted_spread;
|
||||
mod r_squared;
|
||||
mod realized_spread;
|
||||
@@ -481,6 +486,7 @@ pub use bipower_variation::BipowerVariation;
|
||||
pub use body_size_pct::BodySizePct;
|
||||
pub use bollinger::{BollingerBands, BollingerOutput};
|
||||
pub use bollinger_bandwidth::BollingerBandwidth;
|
||||
pub use bomar_bands::{BomarBands, BomarBandsOutput};
|
||||
pub use breadth_thrust::BreadthThrust;
|
||||
pub use breakaway::Breakaway;
|
||||
pub use bullish_percent_index::BullishPercentIndex;
|
||||
@@ -658,6 +664,7 @@ pub use mcclellan_oscillator::McClellanOscillator;
|
||||
pub use mcclellan_summation_index::McClellanSummationIndex;
|
||||
pub use mcginley_dynamic::McGinleyDynamic;
|
||||
pub use median_absolute_deviation::MedianAbsoluteDeviation;
|
||||
pub use median_channel::{MedianChannel, MedianChannelOutput};
|
||||
pub use median_ma::MedianMa;
|
||||
pub use median_price::MedianPrice;
|
||||
pub use mfi::Mfi;
|
||||
@@ -705,10 +712,13 @@ pub use polarized_fractal_efficiency::PolarizedFractalEfficiency;
|
||||
pub use ppo::Ppo;
|
||||
pub use ppo_histogram::PpoHistogram;
|
||||
pub use profit_factor::ProfitFactor;
|
||||
pub use projection_bands::{ProjectionBands, ProjectionBandsOutput};
|
||||
pub use projection_oscillator::ProjectionOscillator;
|
||||
pub use psar::Psar;
|
||||
pub use pvi::Pvi;
|
||||
pub use qqe::{Qqe, QqeOutput};
|
||||
pub use qstick::Qstick;
|
||||
pub use quartile_bands::{QuartileBands, QuartileBandsOutput};
|
||||
pub use quoted_spread::QuotedSpread;
|
||||
pub use r_squared::RSquared;
|
||||
pub use realized_spread::RealizedSpread;
|
||||
@@ -1040,6 +1050,11 @@ pub const FAMILIES: &[(&str, &[&str])] = &[
|
||||
"TtmSqueeze",
|
||||
"FractalChaosBands",
|
||||
"VwapStdDevBands",
|
||||
"QuartileBands",
|
||||
"BomarBands",
|
||||
"MedianChannel",
|
||||
"ProjectionBands",
|
||||
"ProjectionOscillator",
|
||||
],
|
||||
),
|
||||
(
|
||||
@@ -1441,6 +1456,6 @@ mod family_tests {
|
||||
// the actual indicator count is the early-warning signal that an
|
||||
// indicator was added without being assigned a family.
|
||||
let total: usize = FAMILIES.iter().map(|(_, ns)| ns.len()).sum();
|
||||
assert_eq!(total, 429, "FAMILIES total drifted from indicator count");
|
||||
assert_eq!(total, 434, "FAMILIES total drifted from indicator count");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,253 @@
|
||||
//! Projection Bands (Mel Widner) — a high/low linear-regression projection
|
||||
//! envelope.
|
||||
|
||||
use std::collections::VecDeque;
|
||||
|
||||
use crate::error::{Error, Result};
|
||||
use crate::ohlcv::Candle;
|
||||
use crate::traits::Indicator;
|
||||
|
||||
/// Projection Bands output.
|
||||
#[derive(Debug, Clone, Copy, PartialEq)]
|
||||
pub struct ProjectionBandsOutput {
|
||||
/// Upper band: the maximum forward-projected high in the window.
|
||||
pub upper: f64,
|
||||
/// Middle line: the midpoint of the upper and lower bands.
|
||||
pub middle: f64,
|
||||
/// Lower band: the minimum forward-projected low in the window.
|
||||
pub lower: f64,
|
||||
}
|
||||
|
||||
/// Projection Bands: forward-projected high/low envelope.
|
||||
///
|
||||
/// Mel Widner ("Projection Bands and the Projection Oscillator", *Technical
|
||||
/// Analysis of Stocks & Commodities*, May 1995) fits a separate linear
|
||||
/// regression to the highs and to the lows over the last `period` bars, then
|
||||
/// slides every bar's high and low forward to the current bar along its own
|
||||
/// slope. The upper band is the maximum of the projected highs, the lower band
|
||||
/// the minimum of the projected lows:
|
||||
///
|
||||
/// ```text
|
||||
/// slope_h = OLS slope of (x, high) over the window
|
||||
/// slope_l = OLS slope of (x, low) over the window
|
||||
/// // bar i (0 = oldest, period-1 = newest) is (period-1-i) bars in the past
|
||||
/// upper = max over i of [ high_i + slope_h · (period-1-i) ]
|
||||
/// lower = min over i of [ low_i + slope_l · (period-1-i) ]
|
||||
/// middle = (upper + lower) / 2
|
||||
/// ```
|
||||
///
|
||||
/// Unlike [`LinRegChannel`](crate::LinRegChannel) and
|
||||
/// [`StandardErrorBands`](crate::StandardErrorBands) — which wrap a single
|
||||
/// close-regression endpoint by a dispersion statistic — Projection Bands are
|
||||
/// built from the *extremes*: the envelope adapts to the trend's slope yet
|
||||
/// always contains every projected high and low, so by construction price never
|
||||
/// pierces the bands within the window. A flat slope reduces the bands to the
|
||||
/// rolling highest-high / lowest-low (a Donchian channel); a steep slope tilts
|
||||
/// the whole envelope with the trend.
|
||||
///
|
||||
/// # Example
|
||||
///
|
||||
/// ```
|
||||
/// use wickra_core::{Candle, Indicator, ProjectionBands};
|
||||
///
|
||||
/// let mut indicator = ProjectionBands::new(14).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 ProjectionBands {
|
||||
period: usize,
|
||||
highs: VecDeque<f64>,
|
||||
lows: VecDeque<f64>,
|
||||
sum_x: f64,
|
||||
sum_xx: f64,
|
||||
}
|
||||
|
||||
impl ProjectionBands {
|
||||
/// Construct new Projection Bands.
|
||||
///
|
||||
/// # Errors
|
||||
/// Returns [`Error::InvalidPeriod`] if `period < 2` (a regression slope
|
||||
/// needs at least two points).
|
||||
pub fn new(period: usize) -> Result<Self> {
|
||||
if period < 2 {
|
||||
return Err(Error::InvalidPeriod {
|
||||
message: "projection bands need period >= 2",
|
||||
});
|
||||
}
|
||||
let n = period as f64;
|
||||
Ok(Self {
|
||||
period,
|
||||
highs: VecDeque::with_capacity(period),
|
||||
lows: 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
|
||||
}
|
||||
|
||||
/// OLS slope of `(0..period, values)` over the live window.
|
||||
fn slope(&self, values: &VecDeque<f64>) -> f64 {
|
||||
let n = self.period as f64;
|
||||
let mut sum_y = 0.0;
|
||||
let mut sum_xy = 0.0;
|
||||
for (i, &y) in values.iter().enumerate() {
|
||||
sum_y += y;
|
||||
sum_xy += (i as f64) * y;
|
||||
}
|
||||
let denom = n * self.sum_xx - self.sum_x * self.sum_x;
|
||||
(n * sum_xy - self.sum_x * sum_y) / denom
|
||||
}
|
||||
}
|
||||
|
||||
impl Indicator for ProjectionBands {
|
||||
type Input = Candle;
|
||||
type Output = ProjectionBandsOutput;
|
||||
|
||||
fn update(&mut self, candle: Candle) -> Option<ProjectionBandsOutput> {
|
||||
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);
|
||||
if self.highs.len() < self.period {
|
||||
return None;
|
||||
}
|
||||
|
||||
let slope_h = self.slope(&self.highs);
|
||||
let slope_l = self.slope(&self.lows);
|
||||
let last = (self.period - 1) as f64;
|
||||
|
||||
let mut upper = f64::NEG_INFINITY;
|
||||
let mut lower = f64::INFINITY;
|
||||
for (i, (&high, &low)) in self.highs.iter().zip(self.lows.iter()).enumerate() {
|
||||
let forward = last - (i as f64);
|
||||
let projected_high = high + slope_h * forward;
|
||||
let projected_low = low + slope_l * forward;
|
||||
if projected_high > upper {
|
||||
upper = projected_high;
|
||||
}
|
||||
if projected_low < lower {
|
||||
lower = projected_low;
|
||||
}
|
||||
}
|
||||
|
||||
Some(ProjectionBandsOutput {
|
||||
upper,
|
||||
middle: f64::midpoint(upper, lower),
|
||||
lower,
|
||||
})
|
||||
}
|
||||
|
||||
fn reset(&mut self) {
|
||||
self.highs.clear();
|
||||
self.lows.clear();
|
||||
}
|
||||
|
||||
fn warmup_period(&self) -> usize {
|
||||
self.period
|
||||
}
|
||||
|
||||
fn is_ready(&self) -> bool {
|
||||
self.highs.len() == self.period
|
||||
}
|
||||
|
||||
fn name(&self) -> &'static str {
|
||||
"ProjectionBands"
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use approx::assert_relative_eq;
|
||||
|
||||
fn candle(high: f64, low: f64, close: f64, ts: i64) -> Candle {
|
||||
Candle::new(low, high, low, close, 10.0, ts).unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_period_below_two() {
|
||||
assert!(matches!(
|
||||
ProjectionBands::new(0),
|
||||
Err(Error::InvalidPeriod { .. })
|
||||
));
|
||||
assert!(matches!(
|
||||
ProjectionBands::new(1),
|
||||
Err(Error::InvalidPeriod { .. })
|
||||
));
|
||||
assert!(ProjectionBands::new(2).is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn accessors_and_metadata() {
|
||||
let pb = ProjectionBands::new(14).unwrap();
|
||||
assert_eq!(pb.period(), 14);
|
||||
assert_eq!(pb.warmup_period(), 14);
|
||||
assert_eq!(pb.name(), "ProjectionBands");
|
||||
assert!(!pb.is_ready());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn warms_up_then_emits() {
|
||||
let mut pb = ProjectionBands::new(3).unwrap();
|
||||
assert!(pb.update(candle(10.0, 8.0, 9.0, 0)).is_none());
|
||||
assert!(pb.update(candle(12.0, 9.0, 11.0, 1)).is_none());
|
||||
assert!(pb.update(candle(11.0, 10.0, 11.0, 2)).is_some());
|
||||
assert!(pb.is_ready());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn known_projection() {
|
||||
// highs 10,12,11 -> slope_h = 0.5; projected = 11, 12.5, 11 -> upper 12.5
|
||||
// lows 8, 9,10 -> slope_l = 1.0; projected = 10, 10, 10 -> lower 10
|
||||
let mut pb = ProjectionBands::new(3).unwrap();
|
||||
pb.update(candle(10.0, 8.0, 9.0, 0));
|
||||
pb.update(candle(12.0, 9.0, 11.0, 1));
|
||||
let out = pb.update(candle(11.0, 10.0, 11.0, 2)).unwrap();
|
||||
assert_relative_eq!(out.upper, 12.5, epsilon = 1e-9);
|
||||
assert_relative_eq!(out.lower, 10.0, epsilon = 1e-9);
|
||||
assert_relative_eq!(out.middle, 11.25, epsilon = 1e-9);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn perfect_trend_pins_bands_to_current_extremes() {
|
||||
// High_i and Low_i both rise by exactly 1 per bar: every projected high
|
||||
// collapses onto the current high, every projected low onto the current
|
||||
// low.
|
||||
let mut pb = ProjectionBands::new(5).unwrap();
|
||||
let mut last = None;
|
||||
for i in 0..10 {
|
||||
let high = 100.0 + f64::from(i);
|
||||
let low = 95.0 + f64::from(i);
|
||||
last = pb.update(candle(high, low, high, i64::from(i)));
|
||||
}
|
||||
let out = last.unwrap();
|
||||
assert_relative_eq!(out.upper, 109.0, epsilon = 1e-9);
|
||||
assert_relative_eq!(out.lower, 104.0, epsilon = 1e-9);
|
||||
assert_relative_eq!(out.middle, 106.5, epsilon = 1e-9);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reset_clears_state() {
|
||||
let mut pb = ProjectionBands::new(3).unwrap();
|
||||
pb.update(candle(10.0, 8.0, 9.0, 0));
|
||||
pb.update(candle(12.0, 9.0, 11.0, 1));
|
||||
pb.update(candle(11.0, 10.0, 11.0, 2));
|
||||
assert!(pb.is_ready());
|
||||
pb.reset();
|
||||
assert!(!pb.is_ready());
|
||||
assert!(pb.update(candle(10.0, 8.0, 9.0, 3)).is_none());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,168 @@
|
||||
//! Projection Oscillator (Mel Widner) — the close's position inside the
|
||||
//! [`ProjectionBands`](crate::ProjectionBands).
|
||||
|
||||
use crate::error::Result;
|
||||
use crate::indicators::projection_bands::ProjectionBands;
|
||||
use crate::ohlcv::Candle;
|
||||
use crate::traits::Indicator;
|
||||
|
||||
/// Projection Oscillator: where the close sits inside the projection bands,
|
||||
/// scaled to `0..100`.
|
||||
///
|
||||
/// The companion to [`ProjectionBands`](crate::ProjectionBands) from Mel
|
||||
/// Widner's May 1995 *Stocks & Commodities* article. It maps the close onto the
|
||||
/// `[lower, upper]` projection envelope:
|
||||
///
|
||||
/// ```text
|
||||
/// PO = 100 · (close − lower) / (upper − lower)
|
||||
/// ```
|
||||
///
|
||||
/// `PO = 0` means the close is sitting on the lower band, `PO = 100` on the
|
||||
/// upper band, and `PO = 50` at the midline. Because the bands by construction
|
||||
/// bracket every projected high and low, the close almost always falls inside
|
||||
/// them and the oscillator stays in `0..100` — readings near the extremes flag
|
||||
/// an overbought/oversold position *relative to the trend-tilted channel*
|
||||
/// rather than to a horizontal level. When the bands collapse (a zero-range
|
||||
/// window, `upper == lower`) the position is undefined and the oscillator
|
||||
/// returns the neutral `50.0`.
|
||||
///
|
||||
/// # Example
|
||||
///
|
||||
/// ```
|
||||
/// use wickra_core::{Candle, Indicator, ProjectionOscillator};
|
||||
///
|
||||
/// let mut indicator = ProjectionOscillator::new(14).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 ProjectionOscillator {
|
||||
bands: ProjectionBands,
|
||||
}
|
||||
|
||||
impl ProjectionOscillator {
|
||||
/// Construct a new Projection Oscillator.
|
||||
///
|
||||
/// # Errors
|
||||
/// Returns [`Error::InvalidPeriod`](crate::Error::InvalidPeriod) if
|
||||
/// `period < 2`.
|
||||
pub fn new(period: usize) -> Result<Self> {
|
||||
Ok(Self {
|
||||
bands: ProjectionBands::new(period)?,
|
||||
})
|
||||
}
|
||||
|
||||
/// Configured period.
|
||||
pub const fn period(&self) -> usize {
|
||||
self.bands.period()
|
||||
}
|
||||
}
|
||||
|
||||
impl Indicator for ProjectionOscillator {
|
||||
type Input = Candle;
|
||||
type Output = f64;
|
||||
|
||||
fn update(&mut self, candle: Candle) -> Option<f64> {
|
||||
let bands = self.bands.update(candle)?;
|
||||
let width = bands.upper - bands.lower;
|
||||
if width == 0.0 {
|
||||
return Some(50.0);
|
||||
}
|
||||
Some(100.0 * (candle.close - bands.lower) / width)
|
||||
}
|
||||
|
||||
fn reset(&mut self) {
|
||||
self.bands.reset();
|
||||
}
|
||||
|
||||
fn warmup_period(&self) -> usize {
|
||||
self.bands.warmup_period()
|
||||
}
|
||||
|
||||
fn is_ready(&self) -> bool {
|
||||
self.bands.is_ready()
|
||||
}
|
||||
|
||||
fn name(&self) -> &'static str {
|
||||
"ProjectionOscillator"
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::error::Error;
|
||||
use approx::assert_relative_eq;
|
||||
|
||||
fn candle(high: f64, low: f64, close: f64, ts: i64) -> Candle {
|
||||
Candle::new(low, high, low, close, 10.0, ts).unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_period_below_two() {
|
||||
assert!(matches!(
|
||||
ProjectionOscillator::new(1),
|
||||
Err(Error::InvalidPeriod { .. })
|
||||
));
|
||||
assert!(ProjectionOscillator::new(2).is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn accessors_and_metadata() {
|
||||
let po = ProjectionOscillator::new(14).unwrap();
|
||||
assert_eq!(po.period(), 14);
|
||||
assert_eq!(po.warmup_period(), 14);
|
||||
assert_eq!(po.name(), "ProjectionOscillator");
|
||||
assert!(!po.is_ready());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn warms_up_then_emits() {
|
||||
let mut po = ProjectionOscillator::new(3).unwrap();
|
||||
assert!(po.update(candle(10.0, 8.0, 9.0, 0)).is_none());
|
||||
assert!(po.update(candle(12.0, 9.0, 11.0, 1)).is_none());
|
||||
assert!(po.update(candle(11.0, 10.0, 11.0, 2)).is_some());
|
||||
assert!(po.is_ready());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn known_position() {
|
||||
// Same window as ProjectionBands::known_projection: upper 12.5, lower 10.
|
||||
// close 11 -> 100 * (11 - 10) / (12.5 - 10) = 40.
|
||||
let mut po = ProjectionOscillator::new(3).unwrap();
|
||||
po.update(candle(10.0, 8.0, 9.0, 0));
|
||||
po.update(candle(12.0, 9.0, 11.0, 1));
|
||||
let out = po.update(candle(11.0, 10.0, 11.0, 2)).unwrap();
|
||||
assert_relative_eq!(out, 40.0, epsilon = 1e-9);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn collapsed_bands_return_neutral() {
|
||||
// Zero-range, perfectly trending candles: upper == lower every bar.
|
||||
let mut po = ProjectionOscillator::new(3).unwrap();
|
||||
let mut last = None;
|
||||
for i in 0..6 {
|
||||
let v = 100.0 + f64::from(i);
|
||||
last = po.update(candle(v, v, v, i64::from(i)));
|
||||
}
|
||||
assert_relative_eq!(last.unwrap(), 50.0, epsilon = 1e-12);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reset_clears_state() {
|
||||
let mut po = ProjectionOscillator::new(3).unwrap();
|
||||
po.update(candle(10.0, 8.0, 9.0, 0));
|
||||
po.update(candle(12.0, 9.0, 11.0, 1));
|
||||
po.update(candle(11.0, 10.0, 11.0, 2));
|
||||
assert!(po.is_ready());
|
||||
po.reset();
|
||||
assert!(!po.is_ready());
|
||||
assert!(po.update(candle(10.0, 8.0, 9.0, 3)).is_none());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,194 @@
|
||||
//! Quartile Bands — rolling 25th / 50th / 75th percentile envelope.
|
||||
|
||||
use std::collections::VecDeque;
|
||||
|
||||
use crate::error::{Error, Result};
|
||||
use crate::indicators::rolling_quantile::quantile_sorted;
|
||||
use crate::traits::Indicator;
|
||||
|
||||
/// Quartile Bands output.
|
||||
#[derive(Debug, Clone, Copy, PartialEq)]
|
||||
pub struct QuartileBandsOutput {
|
||||
/// Upper band: the rolling third quartile (75th percentile, `Q3`).
|
||||
pub upper: f64,
|
||||
/// Middle line: the rolling median (50th percentile, `Q2`).
|
||||
pub middle: f64,
|
||||
/// Lower band: the rolling first quartile (25th percentile, `Q1`).
|
||||
pub lower: f64,
|
||||
}
|
||||
|
||||
/// Quartile Bands: a distribution-based envelope drawn at the rolling quartiles.
|
||||
///
|
||||
/// ```text
|
||||
/// lower = Q1 = 25th percentile of the last `period` values
|
||||
/// middle = Q2 = 50th percentile (median)
|
||||
/// upper = Q3 = 75th percentile
|
||||
/// ```
|
||||
///
|
||||
/// Quantiles use the type-7 (`NumPy`/`R-7`) linear interpolation shared with
|
||||
/// [`RollingQuantile`](crate::RollingQuantile). Where Bollinger Bands assume an
|
||||
/// approximately normal distribution and size the envelope by the mean and
|
||||
/// standard deviation, Quartile Bands are fully **non-parametric**: the band
|
||||
/// edges are order statistics, so a single outlier shifts at most one rank
|
||||
/// rather than inflating the whole width, and the inter-quartile span between
|
||||
/// the bands is exactly the [`RollingIqr`](crate::RollingIqr). The middle line
|
||||
/// is the robust median rather than the mean, so it is unmoved by spikes.
|
||||
///
|
||||
/// # Example
|
||||
///
|
||||
/// ```
|
||||
/// use wickra_core::{Indicator, QuartileBands};
|
||||
///
|
||||
/// let mut indicator = QuartileBands::new(20).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 QuartileBands {
|
||||
period: usize,
|
||||
window: VecDeque<f64>,
|
||||
scratch: Vec<f64>,
|
||||
}
|
||||
|
||||
impl QuartileBands {
|
||||
/// Construct new Quartile Bands.
|
||||
///
|
||||
/// # Errors
|
||||
/// Returns [`Error::PeriodZero`] if `period == 0`.
|
||||
pub fn new(period: usize) -> Result<Self> {
|
||||
if period == 0 {
|
||||
return Err(Error::PeriodZero);
|
||||
}
|
||||
Ok(Self {
|
||||
period,
|
||||
window: VecDeque::with_capacity(period),
|
||||
scratch: Vec::with_capacity(period),
|
||||
})
|
||||
}
|
||||
|
||||
/// Configured period.
|
||||
pub const fn period(&self) -> usize {
|
||||
self.period
|
||||
}
|
||||
}
|
||||
|
||||
impl Indicator for QuartileBands {
|
||||
type Input = f64;
|
||||
type Output = QuartileBandsOutput;
|
||||
|
||||
fn update(&mut self, value: f64) -> Option<QuartileBandsOutput> {
|
||||
if self.window.len() == self.period {
|
||||
self.window.pop_front();
|
||||
}
|
||||
self.window.push_back(value);
|
||||
if self.window.len() < self.period {
|
||||
return None;
|
||||
}
|
||||
self.scratch.clear();
|
||||
self.scratch.extend(self.window.iter().copied());
|
||||
self.scratch.sort_by(f64::total_cmp);
|
||||
Some(QuartileBandsOutput {
|
||||
upper: quantile_sorted(&self.scratch, 0.75),
|
||||
middle: quantile_sorted(&self.scratch, 0.5),
|
||||
lower: quantile_sorted(&self.scratch, 0.25),
|
||||
})
|
||||
}
|
||||
|
||||
fn reset(&mut self) {
|
||||
self.window.clear();
|
||||
self.scratch.clear();
|
||||
}
|
||||
|
||||
fn warmup_period(&self) -> usize {
|
||||
self.period
|
||||
}
|
||||
|
||||
fn is_ready(&self) -> bool {
|
||||
self.window.len() == self.period
|
||||
}
|
||||
|
||||
fn name(&self) -> &'static str {
|
||||
"QuartileBands"
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::traits::BatchExt;
|
||||
use approx::assert_relative_eq;
|
||||
|
||||
#[test]
|
||||
fn rejects_zero_period() {
|
||||
assert!(matches!(QuartileBands::new(0), Err(Error::PeriodZero)));
|
||||
assert!(QuartileBands::new(1).is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn accessors_and_metadata() {
|
||||
let qb = QuartileBands::new(20).unwrap();
|
||||
assert_eq!(qb.period(), 20);
|
||||
assert_eq!(qb.warmup_period(), 20);
|
||||
assert_eq!(qb.name(), "QuartileBands");
|
||||
assert!(!qb.is_ready());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn warms_up_then_emits() {
|
||||
let mut qb = QuartileBands::new(4).unwrap();
|
||||
assert!(qb.update(10.0).is_none());
|
||||
assert!(qb.update(20.0).is_none());
|
||||
assert!(qb.update(30.0).is_none());
|
||||
assert!(qb.update(40.0).is_some());
|
||||
assert!(qb.is_ready());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn known_quartiles() {
|
||||
// sorted [10,20,30,40]:
|
||||
// Q1 h=(4-1)*0.25=0.75 -> 10 + 0.75*10 = 17.5
|
||||
// Q2 h=1.5 -> 20 + 0.5*10 = 25.0
|
||||
// Q3 h=2.25 -> 30 + 0.25*10 = 32.5
|
||||
let mut qb = QuartileBands::new(4).unwrap();
|
||||
let out = qb.batch(&[40.0, 30.0, 20.0, 10.0]);
|
||||
let last = out[3].unwrap();
|
||||
assert_relative_eq!(last.lower, 17.5, epsilon = 1e-9);
|
||||
assert_relative_eq!(last.middle, 25.0, epsilon = 1e-9);
|
||||
assert_relative_eq!(last.upper, 32.5, epsilon = 1e-9);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn median_robust_to_outlier() {
|
||||
// A single spike shifts the mean a lot but the median by at most one rank.
|
||||
let mut qb = QuartileBands::new(5).unwrap();
|
||||
let out = qb.batch(&[1.0, 2.0, 3.0, 4.0, 1000.0]);
|
||||
assert_relative_eq!(out[4].unwrap().middle, 3.0, epsilon = 1e-12);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rolling_window_evicts_oldest() {
|
||||
// Eight values through a period-4 window: only the last four survive,
|
||||
// reproducing the `known_quartiles` window.
|
||||
let mut qb = QuartileBands::new(4).unwrap();
|
||||
let out = qb.batch(&[1.0, 2.0, 3.0, 4.0, 40.0, 30.0, 20.0, 10.0]);
|
||||
let last = out[7].unwrap();
|
||||
assert_relative_eq!(last.lower, 17.5, epsilon = 1e-9);
|
||||
assert_relative_eq!(last.middle, 25.0, epsilon = 1e-9);
|
||||
assert_relative_eq!(last.upper, 32.5, epsilon = 1e-9);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reset_clears_state() {
|
||||
let mut qb = QuartileBands::new(4).unwrap();
|
||||
for v in [10.0, 20.0, 30.0, 40.0] {
|
||||
qb.update(v);
|
||||
}
|
||||
assert!(qb.is_ready());
|
||||
qb.reset();
|
||||
assert!(!qb.is_ready());
|
||||
assert!(qb.update(10.0).is_none());
|
||||
}
|
||||
}
|
||||
@@ -64,20 +64,20 @@ pub use indicators::{
|
||||
AutoFibOutput, Autocorrelation, AverageDailyRange, AverageDrawdown, AvgPrice,
|
||||
AwesomeOscillator, AwesomeOscillatorHistogram, BalanceOfPower, Bat, BeltHold, Beta,
|
||||
BetaNeutralSpread, BipowerVariation, BodySizePct, BollingerBands, BollingerBandwidth,
|
||||
BollingerOutput, BreadthThrust, Breakaway, BullishPercentIndex, Butterfly, CalendarSpread,
|
||||
CalmarRatio, Camarilla, CamarillaPivotsOutput, Cci, CenterOfGravity, Cfo, ChaikinMoneyFlow,
|
||||
ChaikinOscillator, ChaikinVolatility, ChandeKrollStop, ChandeKrollStopOutput, ChandelierExit,
|
||||
ChandelierExitOutput, ChoppinessIndex, ClassicPivots, ClassicPivotsOutput, CloseVsOpen,
|
||||
ClosingMarubozu, Cmo, CoefficientOfVariation, Cointegration, CointegrationOutput,
|
||||
ConcealingBabySwallow, ConditionalValueAtRisk, ConnorsRsi, Coppock, Counterattack, Crab,
|
||||
CumulativeVolumeDelta, CumulativeVolumeIndex, CupAndHandle, CyberneticCycle, Cypher,
|
||||
DayOfWeekProfile, DayOfWeekProfileOutput, Decycler, DecyclerOscillator, Dema, DemandIndex,
|
||||
DemarkPivots, DemarkPivotsOutput, DepthSlope, DerivativeOscillator, DetrendedStdDev,
|
||||
DisparityIndex, DistanceSsd, Doji, DojiStar, Donchian, DonchianOutput, DonchianStop,
|
||||
DonchianStopOutput, DoubleBollinger, DoubleBollingerOutput, DoubleTopBottom,
|
||||
DownsideGapThreeMethods, Dpo, DragonflyDoji, DrawdownDuration, Dx, DynamicMomentumIndex,
|
||||
EaseOfMovement, EffectiveSpread, EhlersStochastic, Ehma, ElderImpulse, ElderRay,
|
||||
ElderRayOutput, Ema, EmpiricalModeDecomposition, Engulfing, EveningDojiStar, Evwma,
|
||||
BollingerOutput, BomarBands, BomarBandsOutput, BreadthThrust, Breakaway, BullishPercentIndex,
|
||||
Butterfly, CalendarSpread, CalmarRatio, Camarilla, CamarillaPivotsOutput, Cci, CenterOfGravity,
|
||||
Cfo, ChaikinMoneyFlow, ChaikinOscillator, ChaikinVolatility, ChandeKrollStop,
|
||||
ChandeKrollStopOutput, ChandelierExit, ChandelierExitOutput, ChoppinessIndex, ClassicPivots,
|
||||
ClassicPivotsOutput, CloseVsOpen, ClosingMarubozu, Cmo, CoefficientOfVariation, Cointegration,
|
||||
CointegrationOutput, ConcealingBabySwallow, ConditionalValueAtRisk, ConnorsRsi, Coppock,
|
||||
Counterattack, Crab, CumulativeVolumeDelta, CumulativeVolumeIndex, CupAndHandle,
|
||||
CyberneticCycle, Cypher, DayOfWeekProfile, DayOfWeekProfileOutput, Decycler,
|
||||
DecyclerOscillator, Dema, DemandIndex, DemarkPivots, DemarkPivotsOutput, DepthSlope,
|
||||
DerivativeOscillator, DetrendedStdDev, DisparityIndex, DistanceSsd, Doji, DojiStar, Donchian,
|
||||
DonchianOutput, DonchianStop, DonchianStopOutput, DoubleBollinger, DoubleBollingerOutput,
|
||||
DoubleTopBottom, DownsideGapThreeMethods, Dpo, DragonflyDoji, DrawdownDuration, Dx,
|
||||
DynamicMomentumIndex, EaseOfMovement, EffectiveSpread, EhlersStochastic, Ehma, ElderImpulse,
|
||||
ElderRay, ElderRayOutput, Ema, EmpiricalModeDecomposition, Engulfing, EveningDojiStar, Evwma,
|
||||
EwmaVolatility, Expectancy, FallingThreeMethods, Fama, FibArcs, FibArcsOutput, FibChannel,
|
||||
FibChannelOutput, FibConfluence, FibConfluenceOutput, FibExtension, FibExtensionOutput, FibFan,
|
||||
FibFanOutput, FibProjection, FibProjectionOutput, FibRetracement, FibRetracementOutput,
|
||||
@@ -102,27 +102,29 @@ pub use indicators::{
|
||||
LogReturn, LongLeggedDoji, LongLine, LongShortRatio, MaEnvelope, MaEnvelopeOutput, MacdExt,
|
||||
MacdFix, MacdHistogram, MacdIndicator, MacdOutput, Mama, MamaOutput, MarketFacilitationIndex,
|
||||
Marubozu, MassIndex, MatHold, MatchingLow, MaxDrawdown, McClellanOscillator,
|
||||
McClellanSummationIndex, McGinleyDynamic, MedianAbsoluteDeviation, MedianMa, MedianPrice, Mfi,
|
||||
Microprice, MidPoint, MidPrice, MinusDi, MinusDm, Mom, MorningDojiStar, MorningEveningStar,
|
||||
Natr, NewHighsNewLows, Nvi, OIPriceDivergence, OIWeighted, Obv, OmegaRatio, OnNeck,
|
||||
OpenInterestDelta, OpeningMarubozu, OpeningRange, OpeningRangeOutput, OrderBookImbalanceFull,
|
||||
OrderBookImbalanceTop1, OrderBookImbalanceTopN, OrderFlowImbalance, OuHalfLife, OvernightGap,
|
||||
OvernightIntradayReturn, OvernightIntradayReturnOutput, PainIndex, PairSpreadZScore,
|
||||
PairwiseBeta, ParkinsonVolatility, PearsonCorrelation, PercentAboveMa, PercentB,
|
||||
PercentageTrailingStop, Pgo, PiercingDarkCloud, PlusDi, PlusDm, Pmo, PointAndFigureBars,
|
||||
PolarizedFractalEfficiency, Ppo, PpoHistogram, ProfitFactor, Psar, Pvi, Qqe, QqeOutput, Qstick,
|
||||
QuotedSpread, RSquared, RealizedSpread, RealizedVolatility, RecoveryFactor, RectangleRange,
|
||||
RegimeLabel, RelativeStrengthAB, RelativeStrengthOutput, RenkoBars, RenkoTrailingStop,
|
||||
RickshawMan, RisingThreeMethods, Rmi, Roc, Rocp, Rocr, Rocr100, RogersSatchellVolatility,
|
||||
RollMeasure, RollingCorrelation, RollingCovariance, RollingIqr, RollingPercentileRank,
|
||||
RollingQuantile, RollingVwap, RoofingFilter, Rsi, Rsx, Rvi, RviVolatility, Rwi, RwiOutput,
|
||||
SarExt, SeasonalZScore, SeparatingLines, SessionHighLow, SessionHighLowOutput, SessionRange,
|
||||
SessionRangeOutput, SessionVwap, Shark, SharpeRatio, ShootingStar, ShortLine, SignedVolume,
|
||||
SineWave, SineWeightedMa, Skewness, Sma, Smi, Smma, SortinoRatio, SpearmanCorrelation,
|
||||
SpinningTop, SpreadAr1Coefficient, SpreadBollingerBands, SpreadBollingerBandsOutput,
|
||||
SpreadHurst, StalledPattern, StandardError, StandardErrorBands, StandardErrorBandsOutput,
|
||||
StarcBands, StarcBandsOutput, Stc, StdDev, StepTrailingStop, StickSandwich, StochRsi,
|
||||
Stochastic, StochasticCci, StochasticOutput, SuperSmoother, SuperTrend, SuperTrendOutput,
|
||||
McClellanSummationIndex, McGinleyDynamic, MedianAbsoluteDeviation, MedianChannel,
|
||||
MedianChannelOutput, MedianMa, MedianPrice, Mfi, Microprice, MidPoint, MidPrice, MinusDi,
|
||||
MinusDm, Mom, MorningDojiStar, MorningEveningStar, Natr, NewHighsNewLows, Nvi,
|
||||
OIPriceDivergence, OIWeighted, Obv, OmegaRatio, OnNeck, OpenInterestDelta, OpeningMarubozu,
|
||||
OpeningRange, OpeningRangeOutput, OrderBookImbalanceFull, OrderBookImbalanceTop1,
|
||||
OrderBookImbalanceTopN, OrderFlowImbalance, OuHalfLife, OvernightGap, OvernightIntradayReturn,
|
||||
OvernightIntradayReturnOutput, PainIndex, PairSpreadZScore, PairwiseBeta, ParkinsonVolatility,
|
||||
PearsonCorrelation, PercentAboveMa, PercentB, PercentageTrailingStop, Pgo, PiercingDarkCloud,
|
||||
PlusDi, PlusDm, Pmo, PointAndFigureBars, PolarizedFractalEfficiency, Ppo, PpoHistogram,
|
||||
ProfitFactor, ProjectionBands, ProjectionBandsOutput, ProjectionOscillator, Psar, Pvi, Qqe,
|
||||
QqeOutput, Qstick, QuartileBands, QuartileBandsOutput, QuotedSpread, RSquared, RealizedSpread,
|
||||
RealizedVolatility, RecoveryFactor, RectangleRange, RegimeLabel, RelativeStrengthAB,
|
||||
RelativeStrengthOutput, RenkoBars, RenkoTrailingStop, RickshawMan, RisingThreeMethods, Rmi,
|
||||
Roc, Rocp, Rocr, Rocr100, RogersSatchellVolatility, RollMeasure, RollingCorrelation,
|
||||
RollingCovariance, RollingIqr, RollingPercentileRank, RollingQuantile, RollingVwap,
|
||||
RoofingFilter, Rsi, Rsx, Rvi, RviVolatility, Rwi, RwiOutput, SarExt, SeasonalZScore,
|
||||
SeparatingLines, SessionHighLow, SessionHighLowOutput, SessionRange, SessionRangeOutput,
|
||||
SessionVwap, Shark, SharpeRatio, ShootingStar, ShortLine, SignedVolume, SineWave,
|
||||
SineWeightedMa, Skewness, Sma, Smi, Smma, SortinoRatio, SpearmanCorrelation, SpinningTop,
|
||||
SpreadAr1Coefficient, SpreadBollingerBands, SpreadBollingerBandsOutput, SpreadHurst,
|
||||
StalledPattern, StandardError, StandardErrorBands, StandardErrorBandsOutput, StarcBands,
|
||||
StarcBandsOutput, Stc, StdDev, StepTrailingStop, StickSandwich, StochRsi, Stochastic,
|
||||
StochasticCci, StochasticOutput, SuperSmoother, SuperTrend, SuperTrendOutput,
|
||||
TakerBuySellRatio, Takuri, TasukiGap, TdCombo, TdCountdown, TdDeMarker, TdDifferential,
|
||||
TdLines, TdLinesOutput, TdOpen, TdPressure, TdRangeProjection, TdRangeProjectionOutput, TdRei,
|
||||
TdRiskLevel, TdRiskLevelOutput, TdSequential, TdSequentialOutput, TdSetup, Tema,
|
||||
|
||||
Reference in New Issue
Block a user