Add B8 Volume family deepening (7 indicators) (#195)

Deepens the **Volume** family (B8) with seven indicators (440 -> 447):

- **VolumeRsi** — Wilder RSI computed on signed volume flow.
- **WilliamsAd** — Williams Accumulation/Distribution cumulative line (distinct from Chaikin A/D).
- **TwiggsMoneyFlow** — true-range volume accumulation with Wilder smoothing (distinct from CMF).
- **TradeVolumeIndex** — tick-direction volume accumulation past a min-tick threshold (distinct from TSV).
- **IntradayIntensity** — volume weighted by close position within the bar range.
- **BetterVolume** — VSA volume-vs-spread effort/result classifier.
- **VolumeWeightedMacd** — MACD computed on VWMA with signal line and histogram (struct output).

("Up/Down Volume Ratio" already ships from A2.) All Candle input; the six scalar stops emit f64, VolumeWeightedMacd a {macd, signal, histogram} struct. Hand-written Python/Node/WASM bindings for the volume signature. Verified locally: 3620 core lib + 405 doc tests, clippy clean, 522 node tests, 865 pytest, counter 447.
This commit is contained in:
kingchenc
2026-06-07 02:30:56 +02:00
committed by GitHub
parent fc6f3d80c2
commit c78b84e186
21 changed files with 3254 additions and 32 deletions
@@ -0,0 +1,254 @@
//! Better Volume (VSA) — a streaming effort-versus-result oscillator.
use std::collections::VecDeque;
use crate::error::{Error, Result};
use crate::ohlcv::Candle;
use crate::traits::Indicator;
/// Better Volume — a Volume-Spread-Analysis (VSA) "effort versus result"
/// oscillator: how much volume (effort) a bar spent relative to the price range
/// (result) it achieved, both normalised against their own recent averages.
///
/// ```text
/// range_t = high_t low_t
/// rel_vol = volume_t / SMA(volume, period)
/// rel_range = range_t / SMA(range, period)
/// BetterVol = rel_vol rel_range
/// ```
///
/// Volume-Spread Analysis (Wyckoff, popularised by Tom Williams) reads markets
/// through the relationship between **effort** (volume) and **result** (the bar's
/// spread). A bar with heavy volume but a narrow range — `rel_vol` high while
/// `rel_range` low, so the oscillator is **positive** — is *churn*: large effort
/// produced little movement, the hallmark of absorption (supply meeting demand at
/// a top, or vice versa at a bottom). A bar that travels far on light volume —
/// negative oscillator — shows *ease of movement*, a trend meeting no resistance.
///
/// Both legs are normalised by their `period` simple moving averages (including
/// the current bar), so the output is centred near `0` and self-scales to the
/// instrument. A degenerate average of `0` makes its leg `0` rather than dividing
/// by zero. The first value lands after `period` inputs. Each `update` is O(1).
///
/// # Example
///
/// ```
/// use wickra_core::{Candle, Indicator, BetterVolume};
///
/// let mut indicator = BetterVolume::new(20).unwrap();
/// let mut last = None;
/// for i in 0..60 {
/// let base = 100.0 + f64::from(i);
/// let c = Candle::new(base, base + 2.0, base - 2.0, base + 0.5, 1_000.0, 0).unwrap();
/// last = indicator.update(c);
/// }
/// assert!(last.is_some());
/// ```
#[derive(Debug, Clone)]
pub struct BetterVolume {
period: usize,
volumes: VecDeque<f64>,
ranges: VecDeque<f64>,
vol_sum: f64,
range_sum: f64,
last: Option<f64>,
}
impl BetterVolume {
/// Construct a new Better Volume oscillator with the given averaging `period`.
///
/// # Errors
///
/// Returns [`Error::PeriodZero`] if `period == 0`.
pub fn new(period: usize) -> Result<Self> {
if period == 0 {
return Err(Error::PeriodZero);
}
Ok(Self {
period,
volumes: VecDeque::with_capacity(period),
ranges: VecDeque::with_capacity(period),
vol_sum: 0.0,
range_sum: 0.0,
last: None,
})
}
/// Configured averaging period.
pub const fn period(&self) -> usize {
self.period
}
/// Current value if available.
pub const fn value(&self) -> Option<f64> {
self.last
}
}
impl Indicator for BetterVolume {
type Input = Candle;
type Output = f64;
fn update(&mut self, candle: Candle) -> Option<f64> {
let range = candle.high - candle.low;
if self.volumes.len() == self.period {
self.vol_sum -= self.volumes.pop_front().expect("non-empty");
self.range_sum -= self.ranges.pop_front().expect("non-empty");
}
self.volumes.push_back(candle.volume);
self.ranges.push_back(range);
self.vol_sum += candle.volume;
self.range_sum += range;
if self.volumes.len() < self.period {
return None;
}
let n = self.period as f64;
let sma_vol = self.vol_sum / n;
let sma_range = self.range_sum / n;
let rel_vol = if sma_vol > 0.0 {
candle.volume / sma_vol
} else {
0.0
};
let rel_range = if sma_range > 0.0 {
range / sma_range
} else {
0.0
};
let out = rel_vol - rel_range;
self.last = Some(out);
Some(out)
}
fn reset(&mut self) {
self.volumes.clear();
self.ranges.clear();
self.vol_sum = 0.0;
self.range_sum = 0.0;
self.last = None;
}
fn warmup_period(&self) -> usize {
self.period
}
fn is_ready(&self) -> bool {
self.last.is_some()
}
fn name(&self) -> &'static str {
"BetterVolume"
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::traits::BatchExt;
use approx::assert_relative_eq;
fn candle(high: f64, low: f64, volume: f64) -> Candle {
Candle::new_unchecked(low, high, low, high, volume, 0)
}
#[test]
fn rejects_zero_period() {
assert!(matches!(BetterVolume::new(0), Err(Error::PeriodZero)));
}
#[test]
fn accessors_and_metadata() {
let bv = BetterVolume::new(20).unwrap();
assert_eq!(bv.period(), 20);
assert_eq!(bv.warmup_period(), 20);
assert_eq!(bv.name(), "BetterVolume");
assert!(!bv.is_ready());
assert_eq!(bv.value(), None);
}
#[test]
fn first_emission_at_warmup_period() {
let mut bv = BetterVolume::new(3).unwrap();
let candles: Vec<Candle> = (0..6).map(|_| candle(102.0, 100.0, 1_000.0)).collect();
let out = bv.batch(&candles);
for v in out.iter().take(2) {
assert!(v.is_none());
}
assert!(out[2].is_some());
}
#[test]
fn steady_bars_are_neutral() {
// Identical volume and range every bar -> rel_vol = rel_range = 1 -> 0.
let mut bv = BetterVolume::new(4).unwrap();
let candles: Vec<Candle> = (0..10).map(|_| candle(102.0, 100.0, 1_000.0)).collect();
let last = bv.batch(&candles).into_iter().flatten().last().unwrap();
assert_relative_eq!(last, 0.0, epsilon = 1e-9);
}
#[test]
fn churn_bar_is_positive() {
// Three normal bars, then a high-volume narrow-range bar -> positive.
let mut bv = BetterVolume::new(4).unwrap();
let mut candles: Vec<Candle> = (0..3).map(|_| candle(105.0, 100.0, 1_000.0)).collect();
candles.push(candle(100.5, 100.0, 5_000.0)); // huge volume, tiny range
let last = bv.batch(&candles).into_iter().flatten().last().unwrap();
assert!(last > 0.0, "churn bar should be positive, got {last}");
}
#[test]
fn ease_of_movement_bar_is_negative() {
// Three normal bars, then a wide-range light-volume bar -> negative.
let mut bv = BetterVolume::new(4).unwrap();
let mut candles: Vec<Candle> = (0..3).map(|_| candle(101.0, 100.0, 5_000.0)).collect();
candles.push(candle(115.0, 100.0, 500.0)); // wide range, tiny volume
let last = bv.batch(&candles).into_iter().flatten().last().unwrap();
assert!(
last < 0.0,
"ease-of-movement bar should be negative, got {last}"
);
}
#[test]
fn zero_everything_is_zero() {
// Zero volume and zero range -> both legs guarded to 0.
let mut bv = BetterVolume::new(3).unwrap();
let candles: Vec<Candle> = (0..6).map(|_| candle(100.0, 100.0, 0.0)).collect();
for v in bv.batch(&candles).into_iter().flatten() {
assert_relative_eq!(v, 0.0, epsilon = 1e-12);
}
}
#[test]
fn reset_clears_state() {
let mut bv = BetterVolume::new(3).unwrap();
bv.batch(
&(0..6)
.map(|_| candle(102.0, 100.0, 1_000.0))
.collect::<Vec<_>>(),
);
assert!(bv.is_ready());
bv.reset();
assert!(!bv.is_ready());
assert_eq!(bv.value(), None);
assert_eq!(bv.update(candle(102.0, 100.0, 1_000.0)), None);
}
#[test]
fn batch_equals_streaming() {
let candles: Vec<Candle> = (0..120)
.map(|i| {
let base = 100.0 + (f64::from(i) * 0.25).sin() * 9.0;
candle(
base + 2.0,
base - 1.5,
1_000.0 + (f64::from(i) * 0.5).cos() * 400.0,
)
})
.collect();
let batch = BetterVolume::new(20).unwrap().batch(&candles);
let mut b = BetterVolume::new(20).unwrap();
let streamed: Vec<_> = candles.iter().map(|c| b.update(*c)).collect();
assert_eq!(batch, streamed);
}
}
@@ -0,0 +1,185 @@
//! Intraday Intensity Index (Bostian) — a cumulative volume-weighted close-location line.
use crate::ohlcv::Candle;
use crate::traits::Indicator;
/// Intraday Intensity Index — David Bostian's cumulative line that weights each
/// bar's volume by where the close lands inside the bar's range.
///
/// ```text
/// II_t = volume * (2*close high low) / (high low) (0 if high == low)
/// III_t = III_{t1} + II_t
/// ```
///
/// The fraction `(2*close high low) / (high low)` is `+1` when the bar
/// closes on its high, `1` when it closes on its low, and `0` at the midpoint.
/// Scaling it by volume and accumulating produces a running measure of how
/// aggressively the close is being pushed toward the extremes — Bostian's proxy
/// for institutional accumulation (rising line) or distribution (falling line).
///
/// This is the **cumulative** Intraday Intensity (the original index), not the
/// normalized "Intraday Intensity %" — the latter divides a windowed sum of `II`
/// by a windowed sum of volume and is mathematically identical to
/// [`Cmf`](crate::Cmf), so it is not duplicated here. The level of this line is
/// arbitrary; only its slope and divergences against price matter. A doji whose
/// `high == low` contributes nothing. Each `update` is O(1) and the first bar
/// already emits a value.
///
/// # Example
///
/// ```
/// use wickra_core::{Candle, Indicator, IntradayIntensity};
///
/// let mut indicator = IntradayIntensity::new();
/// let mut last = None;
/// for i in 0..20 {
/// let base = 100.0 + f64::from(i);
/// let c = Candle::new(base, base + 1.0, base - 1.0, base + 0.9, 1_000.0, 0).unwrap();
/// last = indicator.update(c);
/// }
/// assert!(last.is_some());
/// ```
#[derive(Debug, Clone, Default)]
pub struct IntradayIntensity {
iii: f64,
last: Option<f64>,
}
impl IntradayIntensity {
/// Construct a new Intraday Intensity Index. The line is parameter-free.
#[must_use]
pub fn new() -> Self {
Self::default()
}
/// Current value if available.
pub const fn value(&self) -> Option<f64> {
self.last
}
}
impl Indicator for IntradayIntensity {
type Input = Candle;
type Output = f64;
fn update(&mut self, candle: Candle) -> Option<f64> {
let range = candle.high - candle.low;
let ii = if range > 0.0 {
candle.volume * (2.0 * candle.close - candle.high - candle.low) / range
} else {
0.0
};
self.iii += ii;
self.last = Some(self.iii);
Some(self.iii)
}
fn reset(&mut self) {
self.iii = 0.0;
self.last = None;
}
fn warmup_period(&self) -> usize {
1
}
fn is_ready(&self) -> bool {
self.last.is_some()
}
fn name(&self) -> &'static str {
"IntradayIntensity"
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::traits::BatchExt;
use approx::assert_relative_eq;
fn candle(high: f64, low: f64, close: f64, volume: f64) -> Candle {
Candle::new_unchecked(low, high, low, close, volume, 0)
}
#[test]
fn accessors_and_metadata() {
let iii = IntradayIntensity::new();
assert_eq!(iii.warmup_period(), 1);
assert_eq!(iii.name(), "IntradayIntensity");
assert!(!iii.is_ready());
assert_eq!(iii.value(), None);
}
#[test]
fn first_bar_emits() {
// close at the high: (2*101 - 102 - 100)/(2) = 0/... wait, high=102 low=100 close=101 -> 0.
let mut iii = IntradayIntensity::new();
// close on the high -> +1 * volume.
let v = iii.update(candle(102.0, 100.0, 102.0, 500.0)).unwrap();
assert_relative_eq!(v, 500.0, epsilon = 1e-9);
}
#[test]
fn close_on_high_adds_full_volume() {
let mut iii = IntradayIntensity::new();
let v = iii.update(candle(110.0, 100.0, 110.0, 1_000.0)).unwrap();
assert_relative_eq!(v, 1_000.0, epsilon = 1e-9);
}
#[test]
fn close_on_low_subtracts_full_volume() {
let mut iii = IntradayIntensity::new();
let v = iii.update(candle(110.0, 100.0, 100.0, 1_000.0)).unwrap();
assert_relative_eq!(v, -1_000.0, epsilon = 1e-9);
}
#[test]
fn close_at_midpoint_adds_nothing() {
let mut iii = IntradayIntensity::new();
let v = iii.update(candle(110.0, 100.0, 105.0, 1_000.0)).unwrap();
assert_relative_eq!(v, 0.0, epsilon = 1e-12);
}
#[test]
fn zero_range_adds_nothing() {
let mut iii = IntradayIntensity::new();
let v = iii.update(candle(100.0, 100.0, 100.0, 1_000.0)).unwrap();
assert_relative_eq!(v, 0.0, epsilon = 1e-12);
}
#[test]
fn accumulates_across_bars() {
let mut iii = IntradayIntensity::new();
iii.update(candle(110.0, 100.0, 110.0, 1_000.0)); // +1000
let v = iii.update(candle(110.0, 100.0, 100.0, 400.0)).unwrap(); // -400 -> 600
assert_relative_eq!(v, 600.0, epsilon = 1e-9);
}
#[test]
fn reset_clears_state() {
let mut iii = IntradayIntensity::new();
iii.batch(&[
candle(110.0, 100.0, 108.0, 1.0),
candle(110.0, 100.0, 102.0, 1.0),
]);
assert!(iii.is_ready());
iii.reset();
assert!(!iii.is_ready());
assert_eq!(iii.value(), None);
}
#[test]
fn batch_equals_streaming() {
let candles: Vec<Candle> = (0..80)
.map(|i| {
let base = 100.0 + (f64::from(i) * 0.3).sin() * 6.0;
candle(base + 2.0, base - 2.0, base + 0.7, 1_000.0 + f64::from(i))
})
.collect();
let batch = IntradayIntensity::new().batch(&candles);
let mut b = IntradayIntensity::new();
let streamed: Vec<_> = candles.iter().map(|c| b.update(*c)).collect();
assert_eq!(batch, streamed);
}
}
+22 -1
View File
@@ -49,6 +49,7 @@ mod bat;
mod belt_hold;
mod beta;
mod beta_neutral_spread;
mod better_volume;
mod bipower_variation;
mod body_size_pct;
mod bollinger;
@@ -185,6 +186,7 @@ mod inertia;
mod information_ratio;
mod initial_balance;
mod instantaneous_trendline;
mod intraday_intensity;
mod intraday_momentum_index;
mod intraday_volatility_profile;
mod inverse_fisher_transform;
@@ -387,6 +389,7 @@ mod time_based_stop;
mod time_of_day_return_profile;
mod tpo_profile;
mod trade_imbalance;
mod trade_volume_index;
mod trend_label;
mod trend_strength_index;
mod treynor_ratio;
@@ -404,6 +407,7 @@ mod ttm_squeeze;
mod ttm_trend;
mod turn_of_month;
mod tweezer;
mod twiggs_money_flow;
mod two_crows;
mod typical_price;
mod ulcer_index;
@@ -425,6 +429,8 @@ mod volty_stop;
mod volume_by_time_profile;
mod volume_oscillator;
mod volume_profile;
mod volume_rsi;
mod volume_weighted_macd;
mod vortex;
mod vpin;
mod vpt;
@@ -432,6 +438,7 @@ mod vwap;
mod vwap_stddev_bands;
mod vwma;
mod vzo;
mod wad;
mod wave_pm;
mod wave_trend;
mod wedge;
@@ -489,6 +496,7 @@ pub use bat::Bat;
pub use belt_hold::BeltHold;
pub use beta::Beta;
pub use beta_neutral_spread::BetaNeutralSpread;
pub use better_volume::BetterVolume;
pub use bipower_variation::BipowerVariation;
pub use body_size_pct::BodySizePct;
pub use bollinger::{BollingerBands, BollingerOutput};
@@ -625,6 +633,7 @@ pub use inertia::Inertia;
pub use information_ratio::InformationRatio;
pub use initial_balance::{InitialBalance, InitialBalanceOutput};
pub use instantaneous_trendline::InstantaneousTrendline;
pub use intraday_intensity::IntradayIntensity;
pub use intraday_momentum_index::IntradayMomentumIndex;
pub use intraday_volatility_profile::{IntradayVolatilityProfile, IntradayVolatilityProfileOutput};
pub use inverse_fisher_transform::InverseFisherTransform;
@@ -827,6 +836,7 @@ pub use time_based_stop::TimeBasedStop;
pub use time_of_day_return_profile::{TimeOfDayReturnProfile, TimeOfDayReturnProfileOutput};
pub use tpo_profile::{TpoProfile, TpoProfileOutput};
pub use trade_imbalance::TradeImbalance;
pub use trade_volume_index::TradeVolumeIndex;
pub use trend_label::TrendLabel;
pub use trend_strength_index::TrendStrengthIndex;
pub use treynor_ratio::TreynorRatio;
@@ -844,6 +854,7 @@ pub use ttm_squeeze::{TtmSqueeze, TtmSqueezeOutput};
pub use ttm_trend::TtmTrend;
pub use turn_of_month::TurnOfMonth;
pub use tweezer::Tweezer;
pub use twiggs_money_flow::TwiggsMoneyFlow;
pub use two_crows::TwoCrows;
pub use typical_price::TypicalPrice;
pub use ulcer_index::UlcerIndex;
@@ -865,6 +876,8 @@ pub use volty_stop::VoltyStop;
pub use volume_by_time_profile::{VolumeByTimeProfile, VolumeByTimeProfileOutput};
pub use volume_oscillator::VolumeOscillator;
pub use volume_profile::{VolumeProfile, VolumeProfileOutput};
pub use volume_rsi::VolumeRsi;
pub use volume_weighted_macd::{VolumeWeightedMacd, VolumeWeightedMacdOutput};
pub use vortex::{Vortex, VortexOutput};
pub use vpin::Vpin;
pub use vpt::VolumePriceTrend;
@@ -872,6 +885,7 @@ pub use vwap::{RollingVwap, Vwap};
pub use vwap_stddev_bands::{VwapStdDevBands, VwapStdDevBandsOutput};
pub use vwma::Vwma;
pub use vzo::Vzo;
pub use wad::Wad;
pub use wave_pm::WavePm;
pub use wave_trend::{WaveTrend, WaveTrendOutput};
pub use wedge::Wedge;
@@ -1115,6 +1129,13 @@ pub const FAMILIES: &[(&str, &[&str])] = &[
"Tsv",
"Vzo",
"MarketFacilitationIndex",
"VolumeRsi",
"Wad",
"TwiggsMoneyFlow",
"TradeVolumeIndex",
"IntradayIntensity",
"BetterVolume",
"VolumeWeightedMacd",
],
),
(
@@ -1474,6 +1495,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, 440, "FAMILIES total drifted from indicator count");
assert_eq!(total, 447, "FAMILIES total drifted from indicator count");
}
}
@@ -0,0 +1,235 @@
//! Trade Volume Index (TVI) — cumulative volume signed by a minimum-tick rule.
use crate::error::{Error, Result};
use crate::ohlcv::Candle;
use crate::traits::Indicator;
/// Trade Volume Index — a cumulative line that adds volume while price ticks up
/// and subtracts it while price ticks down, where "up" and "down" are decided by
/// a **minimum tick value** rather than any change.
///
/// ```text
/// change = close prev_close
/// if change > min_tick: direction = +1
/// if change < min_tick: direction = 1
/// else: direction unchanged (price is "churning")
/// TVI_t = TVI_{t1} + direction * volume
/// ```
///
/// The minimum tick value (MTV) is a dead-band: only moves larger than `min_tick`
/// flip the accumulation direction, so a price drifting within the spread keeps
/// adding volume in the last established direction instead of whipsawing. This is
/// the cumulative-volume analogue of [`Obv`](crate::Obv), but with a noise filter
/// and applied to close-to-close moves. Like all cumulative lines, only its slope
/// and divergences against price carry meaning — the absolute level is arbitrary.
///
/// The first candle seeds the reference close and emits nothing; thereafter each
/// bar emits the running total. Each `update` is O(1).
///
/// # Example
///
/// ```
/// use wickra_core::{Candle, Indicator, TradeVolumeIndex};
///
/// let mut indicator = TradeVolumeIndex::new(0.5).unwrap();
/// let mut last = None;
/// for i in 0..20 {
/// let close = 100.0 + f64::from(i);
/// let c = Candle::new(close, close + 0.5, close - 0.5, close, 1_000.0, 0).unwrap();
/// last = indicator.update(c);
/// }
/// assert!(last.is_some());
/// ```
#[derive(Debug, Clone)]
pub struct TradeVolumeIndex {
min_tick: f64,
prev_close: Option<f64>,
direction: f64,
tvi: f64,
last: Option<f64>,
}
impl TradeVolumeIndex {
/// Construct a new Trade Volume Index with the given minimum tick value.
///
/// # Errors
///
/// Returns [`Error::InvalidParameter`] if `min_tick` is not finite or is
/// negative. A `min_tick` of `0` is allowed and makes every non-zero move
/// flip the direction.
pub fn new(min_tick: f64) -> Result<Self> {
if !min_tick.is_finite() || min_tick < 0.0 {
return Err(Error::InvalidParameter {
message: "trade volume index min_tick must be finite and non-negative",
});
}
Ok(Self {
min_tick,
prev_close: None,
direction: 0.0,
tvi: 0.0,
last: None,
})
}
/// Configured minimum tick value.
pub const fn min_tick(&self) -> f64 {
self.min_tick
}
/// Current value if available.
pub const fn value(&self) -> Option<f64> {
self.last
}
}
impl Indicator for TradeVolumeIndex {
type Input = Candle;
type Output = f64;
fn update(&mut self, candle: Candle) -> Option<f64> {
let Some(prev_close) = self.prev_close else {
self.prev_close = Some(candle.close);
return None;
};
let change = candle.close - prev_close;
if change > self.min_tick {
self.direction = 1.0;
} else if change < -self.min_tick {
self.direction = -1.0;
}
// Otherwise the direction is held from the previous bar (or 0 before the
// first decisive move), so a churning price keeps its last lean.
self.tvi += self.direction * candle.volume;
self.prev_close = Some(candle.close);
self.last = Some(self.tvi);
Some(self.tvi)
}
fn reset(&mut self) {
self.prev_close = None;
self.direction = 0.0;
self.tvi = 0.0;
self.last = None;
}
fn warmup_period(&self) -> usize {
2
}
fn is_ready(&self) -> bool {
self.last.is_some()
}
fn name(&self) -> &'static str {
"TradeVolumeIndex"
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::traits::BatchExt;
use approx::assert_relative_eq;
fn candle(close: f64, volume: f64) -> Candle {
Candle::new_unchecked(close, close, close, close, volume, 0)
}
#[test]
fn rejects_invalid_min_tick() {
assert!(matches!(
TradeVolumeIndex::new(-1.0),
Err(Error::InvalidParameter { .. })
));
assert!(matches!(
TradeVolumeIndex::new(f64::NAN),
Err(Error::InvalidParameter { .. })
));
assert!(TradeVolumeIndex::new(0.0).is_ok());
}
#[test]
fn accessors_and_metadata() {
let tvi = TradeVolumeIndex::new(0.25).unwrap();
assert_relative_eq!(tvi.min_tick(), 0.25, epsilon = 1e-12);
assert_eq!(tvi.warmup_period(), 2);
assert_eq!(tvi.name(), "TradeVolumeIndex");
assert!(!tvi.is_ready());
assert_eq!(tvi.value(), None);
}
#[test]
fn first_bar_seeds_without_output() {
let mut tvi = TradeVolumeIndex::new(0.5).unwrap();
assert_eq!(tvi.update(candle(100.0, 1_000.0)), None);
assert!(tvi.update(candle(101.0, 1_000.0)).is_some());
}
#[test]
fn uptrend_accumulates_volume() {
// Each step of +1 exceeds the 0.5 tick -> direction +1 -> add volume.
let mut tvi = TradeVolumeIndex::new(0.5).unwrap();
let candles = [
candle(100.0, 1_000.0), // seed
candle(101.0, 500.0), // +1 -> +500
candle(102.0, 300.0), // +1 -> +300
];
let out = tvi.batch(&candles);
assert_relative_eq!(out[1].unwrap(), 500.0, epsilon = 1e-9);
assert_relative_eq!(out[2].unwrap(), 800.0, epsilon = 1e-9);
}
#[test]
fn small_move_holds_last_direction() {
// After an up-move, a sub-tick wobble keeps adding in the up direction.
let mut tvi = TradeVolumeIndex::new(1.0).unwrap();
let candles = [
candle(100.0, 1_000.0), // seed
candle(102.0, 400.0), // +2 > tick -> dir +1, +400
candle(102.2, 100.0), // +0.2 < tick -> hold dir +1, +100
];
let out = tvi.batch(&candles);
assert_relative_eq!(out[1].unwrap(), 400.0, epsilon = 1e-9);
assert_relative_eq!(out[2].unwrap(), 500.0, epsilon = 1e-9);
}
#[test]
fn downtrend_distributes_volume() {
let mut tvi = TradeVolumeIndex::new(0.5).unwrap();
let candles = [
candle(100.0, 1_000.0),
candle(99.0, 200.0), // -1 -> -200
candle(98.0, 300.0), // -1 -> -300
];
let out = tvi.batch(&candles);
assert_relative_eq!(out[2].unwrap(), -500.0, epsilon = 1e-9);
}
#[test]
fn reset_clears_state() {
let mut tvi = TradeVolumeIndex::new(0.5).unwrap();
tvi.batch(&[candle(100.0, 1.0), candle(101.0, 1.0), candle(102.0, 1.0)]);
assert!(tvi.is_ready());
tvi.reset();
assert!(!tvi.is_ready());
assert_eq!(tvi.value(), None);
assert_eq!(tvi.update(candle(100.0, 1.0)), None);
}
#[test]
fn batch_equals_streaming() {
let candles: Vec<Candle> = (0..80)
.map(|i| {
candle(
100.0 + (f64::from(i) * 0.3).sin() * 5.0,
1_000.0 + f64::from(i),
)
})
.collect();
let batch = TradeVolumeIndex::new(0.5).unwrap().batch(&candles);
let mut b = TradeVolumeIndex::new(0.5).unwrap();
let streamed: Vec<_> = candles.iter().map(|c| b.update(*c)).collect();
assert_eq!(batch, streamed);
}
}
@@ -0,0 +1,314 @@
//! Twiggs Money Flow (TMF) — Colin Twiggs' Wilder-smoothed money-flow oscillator.
use crate::error::{Error, Result};
use crate::ohlcv::Candle;
use crate::traits::Indicator;
/// Twiggs Money Flow — a refinement of Chaikin Money Flow that uses **true range**
/// boundaries and **Wilder (exponential) smoothing** instead of a simple sum.
///
/// ```text
/// TRH = max(high, prev_close) (true high)
/// TRL = min(low, prev_close) (true low)
/// ad = volume * (2*close TRH TRL) / (TRH TRL) (0 if TRH == TRL)
/// TMF = WilderEMA(ad, period) / WilderEMA(volume, period)
/// ```
///
/// Colin Twiggs' money flow fixes two issues with [`Cmf`](crate::Cmf): it replaces
/// the bar's raw high/low with the *true* high/low (folding in the prior close so
/// gaps count), and it smooths the accumulated money flow and the volume with a
/// Wilder exponential average rather than a flat `period`-sum, so the oscillator
/// reacts faster and never jumps when a large bar drops out of a window. The
/// output is bounded in roughly `[1, +1]`: positive means buying pressure
/// (closes biased toward the true high), negative means selling pressure.
///
/// The first candle seeds the reference close; the next `period` bars seed both
/// Wilder averages, so the first value lands after `period + 1` inputs. A stretch
/// of zero volume makes the denominator average `0`, in which case the oscillator
/// reports `0` rather than `0 / 0`. Each `update` is O(1).
///
/// # Example
///
/// ```
/// use wickra_core::{Candle, Indicator, TwiggsMoneyFlow};
///
/// let mut indicator = TwiggsMoneyFlow::new(21).unwrap();
/// let mut last = None;
/// for i in 0..60 {
/// let base = 100.0 + (f64::from(i) * 0.2).sin() * 5.0;
/// let c = Candle::new(base, base + 1.0, base - 1.0, base + 0.5, 1_000.0, 0).unwrap();
/// last = indicator.update(c);
/// }
/// assert!(last.is_some());
/// ```
#[derive(Debug, Clone)]
pub struct TwiggsMoneyFlow {
period: usize,
prev_close: Option<f64>,
seed_ad: f64,
seed_vol: f64,
seed_count: usize,
ad_ema: Option<f64>,
vol_ema: Option<f64>,
last: Option<f64>,
}
impl TwiggsMoneyFlow {
/// Construct a new Twiggs Money Flow with the given smoothing `period`.
///
/// # Errors
///
/// Returns [`Error::PeriodZero`] if `period == 0`.
pub fn new(period: usize) -> Result<Self> {
if period == 0 {
return Err(Error::PeriodZero);
}
Ok(Self {
period,
prev_close: None,
seed_ad: 0.0,
seed_vol: 0.0,
seed_count: 0,
ad_ema: None,
vol_ema: None,
last: None,
})
}
/// Configured smoothing period.
pub const fn period(&self) -> usize {
self.period
}
/// Current value if available.
pub const fn value(&self) -> Option<f64> {
self.last
}
fn ratio(ad_ema: f64, vol_ema: f64) -> f64 {
if vol_ema == 0.0 {
0.0
} else {
ad_ema / vol_ema
}
}
}
impl Indicator for TwiggsMoneyFlow {
type Input = Candle;
type Output = f64;
fn update(&mut self, candle: Candle) -> Option<f64> {
let Some(prev_close) = self.prev_close else {
self.prev_close = Some(candle.close);
return None;
};
let trh = candle.high.max(prev_close);
let trl = candle.low.min(prev_close);
let range = trh - trl;
let ad = if range > 0.0 {
candle.volume * (2.0 * candle.close - trh - trl) / range
} else {
0.0
};
self.prev_close = Some(candle.close);
if let (Some(ad_ema), Some(vol_ema)) = (self.ad_ema, self.vol_ema) {
let n = self.period as f64;
let new_ad = ad_ema + (ad - ad_ema) / n;
let new_vol = vol_ema + (candle.volume - vol_ema) / n;
self.ad_ema = Some(new_ad);
self.vol_ema = Some(new_vol);
let v = Self::ratio(new_ad, new_vol);
self.last = Some(v);
return Some(v);
}
self.seed_ad += ad;
self.seed_vol += candle.volume;
self.seed_count += 1;
if self.seed_count == self.period {
let n = self.period as f64;
let ad_ema = self.seed_ad / n;
let vol_ema = self.seed_vol / n;
self.ad_ema = Some(ad_ema);
self.vol_ema = Some(vol_ema);
let v = Self::ratio(ad_ema, vol_ema);
self.last = Some(v);
return Some(v);
}
None
}
fn reset(&mut self) {
self.prev_close = None;
self.seed_ad = 0.0;
self.seed_vol = 0.0;
self.seed_count = 0;
self.ad_ema = None;
self.vol_ema = None;
self.last = None;
}
fn warmup_period(&self) -> usize {
self.period + 1
}
fn is_ready(&self) -> bool {
self.last.is_some()
}
fn name(&self) -> &'static str {
"TwiggsMoneyFlow"
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::traits::BatchExt;
use approx::assert_relative_eq;
fn candle(high: f64, low: f64, close: f64, volume: f64) -> Candle {
Candle::new_unchecked(low, high, low, close, volume, 0)
}
#[test]
fn rejects_zero_period() {
assert!(matches!(TwiggsMoneyFlow::new(0), Err(Error::PeriodZero)));
}
#[test]
fn flat_bars_drive_tmf_to_zero() {
// A flat bar (high == low == close == prior close) gives a zero two-bar
// range, so the accumulation term falls back to 0.0 and TMF settles at
// zero. Exercises the `range == 0` guard.
let mut tmf = TwiggsMoneyFlow::new(2).unwrap();
let flat: Vec<Candle> = (0..6)
.map(|_| candle(100.0, 100.0, 100.0, 1_000.0))
.collect();
let last = tmf.batch(&flat).into_iter().flatten().last().unwrap();
assert_relative_eq!(last, 0.0, epsilon = 1e-12);
}
#[test]
fn accessors_and_metadata() {
let tmf = TwiggsMoneyFlow::new(21).unwrap();
assert_eq!(tmf.period(), 21);
assert_eq!(tmf.warmup_period(), 22);
assert_eq!(tmf.name(), "TwiggsMoneyFlow");
assert!(!tmf.is_ready());
assert_eq!(tmf.value(), None);
}
#[test]
fn first_emission_at_warmup_period() {
let mut tmf = TwiggsMoneyFlow::new(3).unwrap();
let candles: Vec<Candle> = (0..8)
.map(|i| {
let base = 100.0 + f64::from(i);
candle(base + 1.0, base - 1.0, base, 1_000.0)
})
.collect();
let out = tmf.batch(&candles);
// warmup_period == period + 1 == 4: first emission at index 3.
for o in out.iter().take(3) {
assert!(o.is_none());
}
assert!(out[3].is_some());
}
#[test]
fn closes_at_true_high_is_positive() {
// Every bar closes at its high -> strong buying pressure -> TMF -> +1.
let mut tmf = TwiggsMoneyFlow::new(3).unwrap();
let candles: Vec<Candle> = (0..12)
.map(|i| {
let base = 100.0 + f64::from(i);
// open=low=base-1, high=close=base+1 -> closes at the top.
Candle::new_unchecked(base - 1.0, base + 1.0, base - 1.0, base + 1.0, 1_000.0, 0)
})
.collect();
let last = tmf.batch(&candles).into_iter().flatten().last().unwrap();
assert!(
last > 0.9,
"closing at the high should drive TMF near +1, got {last}"
);
}
#[test]
fn closes_at_true_low_is_negative() {
let mut tmf = TwiggsMoneyFlow::new(3).unwrap();
let candles: Vec<Candle> = (0..12)
.map(|i| {
let base = 100.0 - f64::from(i);
// closes at the low.
Candle::new_unchecked(base + 1.0, base + 1.0, base - 1.0, base - 1.0, 1_000.0, 0)
})
.collect();
let last = tmf.batch(&candles).into_iter().flatten().last().unwrap();
assert!(
last < -0.5,
"closing at the low should drive TMF negative, got {last}"
);
}
#[test]
fn zero_volume_yields_zero() {
let mut tmf = TwiggsMoneyFlow::new(3).unwrap();
let candles: Vec<Candle> = (0..10)
.map(|i| {
let base = 100.0 + f64::from(i);
candle(base + 1.0, base - 1.0, base, 0.0)
})
.collect();
for v in tmf.batch(&candles).into_iter().flatten() {
assert_relative_eq!(v, 0.0, epsilon = 1e-12);
}
}
#[test]
fn output_in_range() {
let mut tmf = TwiggsMoneyFlow::new(21).unwrap();
let candles: Vec<Candle> = (0..200)
.map(|i| {
let base = 100.0 + (f64::from(i) * 0.3).sin() * 12.0;
candle(base + 2.0, base - 2.0, base + 0.5, 1_000.0)
})
.collect();
for v in tmf.batch(&candles).into_iter().flatten() {
assert!((-1.0..=1.0).contains(&v), "TMF out of range: {v}");
}
}
#[test]
fn reset_clears_state() {
let mut tmf = TwiggsMoneyFlow::new(3).unwrap();
let candles: Vec<Candle> = (0..12)
.map(|i| {
let base = 100.0 + f64::from(i);
candle(base + 1.0, base - 1.0, base, 1_000.0)
})
.collect();
tmf.batch(&candles);
assert!(tmf.is_ready());
tmf.reset();
assert!(!tmf.is_ready());
assert_eq!(tmf.value(), None);
assert_eq!(tmf.update(candle(101.0, 99.0, 100.0, 1_000.0)), None);
}
#[test]
fn batch_equals_streaming() {
let candles: Vec<Candle> = (0..120)
.map(|i| {
let base = 100.0 + (f64::from(i) * 0.25).sin() * 9.0;
candle(base + 2.0, base - 1.5, base + 0.5, 1_000.0 + f64::from(i))
})
.collect();
let batch = TwiggsMoneyFlow::new(21).unwrap().batch(&candles);
let mut b = TwiggsMoneyFlow::new(21).unwrap();
let streamed: Vec<_> = candles.iter().map(|c| b.update(*c)).collect();
assert_eq!(batch, streamed);
}
}
@@ -0,0 +1,265 @@
//! Volume RSI — Wilder's RSI applied to the volume stream.
use crate::error::{Error, Result};
use crate::ohlcv::Candle;
use crate::traits::Indicator;
/// Volume RSI — the Relative Strength Index computed on **volume** changes
/// instead of price changes.
///
/// Wilder's [`Rsi`](crate::Rsi) measures the balance of up- versus down-*price*
/// moves; the Volume RSI applies the identical accumulator to the bar-over-bar
/// change in volume:
///
/// ```text
/// change_t = volume_t volume_{t1}
/// gain = max(change, 0), loss = max(change, 0)
/// avg_gain, avg_loss = Wilder-smoothed over `period`
/// VolumeRSI = 100 * avg_gain / (avg_gain + avg_loss)
/// ```
///
/// Readings above `50` mean volume is expanding (more was added than removed over
/// the smoothing window) and tend to confirm the prevailing move; readings below
/// `50` mark contracting participation. Output is bounded in `[0, 100]`; a stretch
/// of unchanged volume drives both averages to `0` and the indicator reports the
/// neutral `50` rather than an undefined `0 / 0`.
///
/// Only the candle's **volume** is used. The first bar sets the previous volume,
/// then `period` changes seed Wilder's averages, so the first value lands after
/// `period + 1` inputs. Each `update` is O(1).
///
/// # Example
///
/// ```
/// use wickra_core::{Candle, Indicator, VolumeRsi};
///
/// let mut indicator = VolumeRsi::new(14).unwrap();
/// let mut last = None;
/// for i in 0..40 {
/// let v = 1_000.0 + (f64::from(i) * 0.3).sin() * 400.0;
/// let c = Candle::new(100.0, 101.0, 99.0, 100.5, v, 0).unwrap();
/// last = indicator.update(c);
/// }
/// assert!(last.is_some());
/// ```
#[derive(Debug, Clone)]
pub struct VolumeRsi {
period: usize,
prev_volume: Option<f64>,
seed_gains: f64,
seed_losses: f64,
seed_count: usize,
avg_gain: Option<f64>,
avg_loss: Option<f64>,
last: Option<f64>,
}
impl VolumeRsi {
/// Construct a Volume RSI with the given Wilder smoothing `period`.
///
/// # Errors
///
/// Returns [`Error::PeriodZero`] if `period == 0`.
pub fn new(period: usize) -> Result<Self> {
if period == 0 {
return Err(Error::PeriodZero);
}
Ok(Self {
period,
prev_volume: None,
seed_gains: 0.0,
seed_losses: 0.0,
seed_count: 0,
avg_gain: None,
avg_loss: None,
last: None,
})
}
/// Configured smoothing period.
pub const fn period(&self) -> usize {
self.period
}
/// Current value if available.
pub const fn value(&self) -> Option<f64> {
self.last
}
fn rsi_from_avgs(avg_gain: f64, avg_loss: f64) -> f64 {
let denom = avg_gain + avg_loss;
if denom == 0.0 {
50.0
} else {
100.0 * (avg_gain / denom)
}
}
}
impl Indicator for VolumeRsi {
type Input = Candle;
type Output = f64;
fn update(&mut self, candle: Candle) -> Option<f64> {
let volume = candle.volume;
let Some(prev) = self.prev_volume else {
self.prev_volume = Some(volume);
return None;
};
let change = volume - prev;
self.prev_volume = Some(volume);
let gain = if change > 0.0 { change } else { 0.0 };
let loss = if change < 0.0 { -change } else { 0.0 };
if let (Some(ag), Some(al)) = (self.avg_gain, self.avg_loss) {
let n = self.period as f64;
let new_ag = (ag * (n - 1.0) + gain) / n;
let new_al = (al * (n - 1.0) + loss) / n;
self.avg_gain = Some(new_ag);
self.avg_loss = Some(new_al);
let v = Self::rsi_from_avgs(new_ag, new_al);
self.last = Some(v);
return Some(v);
}
self.seed_gains += gain;
self.seed_losses += loss;
self.seed_count += 1;
if self.seed_count == self.period {
let n = self.period as f64;
let ag = self.seed_gains / n;
let al = self.seed_losses / n;
self.avg_gain = Some(ag);
self.avg_loss = Some(al);
let v = Self::rsi_from_avgs(ag, al);
self.last = Some(v);
return Some(v);
}
None
}
fn reset(&mut self) {
self.prev_volume = None;
self.seed_gains = 0.0;
self.seed_losses = 0.0;
self.seed_count = 0;
self.avg_gain = None;
self.avg_loss = None;
self.last = None;
}
fn warmup_period(&self) -> usize {
self.period + 1
}
fn is_ready(&self) -> bool {
self.last.is_some()
}
fn name(&self) -> &'static str {
"VolumeRsi"
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::traits::BatchExt;
use approx::assert_relative_eq;
/// Candle whose only material field here is `volume`.
fn vol_candle(volume: f64) -> Candle {
Candle::new_unchecked(100.0, 101.0, 99.0, 100.5, volume, 0)
}
#[test]
fn rejects_zero_period() {
assert!(matches!(VolumeRsi::new(0), Err(Error::PeriodZero)));
}
#[test]
fn accessors_and_metadata() {
let v = VolumeRsi::new(14).unwrap();
assert_eq!(v.period(), 14);
assert_eq!(v.warmup_period(), 15);
assert_eq!(v.name(), "VolumeRsi");
assert!(!v.is_ready());
assert_eq!(v.value(), None);
}
#[test]
fn first_emission_at_warmup_period() {
let mut v = VolumeRsi::new(3).unwrap();
let candles: Vec<Candle> = (0..6).map(|i| vol_candle(1_000.0 + f64::from(i))).collect();
let out = v.batch(&candles);
// warmup_period == period + 1 == 4: first emission at index 3.
for o in out.iter().take(3) {
assert!(o.is_none());
}
assert!(out[3].is_some());
}
#[test]
fn rising_volume_is_one_hundred() {
// Every change positive -> avg_loss 0 -> RSI 100.
let mut v = VolumeRsi::new(5).unwrap();
let candles: Vec<Candle> = (1..=40).map(|i| vol_candle(f64::from(i) * 100.0)).collect();
let last = v.batch(&candles).into_iter().flatten().last().unwrap();
assert_relative_eq!(last, 100.0, epsilon = 1e-9);
}
#[test]
fn falling_volume_is_zero() {
let mut v = VolumeRsi::new(5).unwrap();
let candles: Vec<Candle> = (1..=40)
.map(|i| vol_candle(5_000.0 - f64::from(i) * 100.0))
.collect();
let last = v.batch(&candles).into_iter().flatten().last().unwrap();
assert_relative_eq!(last, 0.0, epsilon = 1e-9);
}
#[test]
fn flat_volume_is_neutral() {
// Unchanged volume -> no gains and no losses -> neutral 50.
let mut v = VolumeRsi::new(3).unwrap();
let candles: Vec<Candle> = (0..20).map(|_| vol_candle(2_000.0)).collect();
let last = v.batch(&candles).into_iter().flatten().last().unwrap();
assert_relative_eq!(last, 50.0, epsilon = 1e-12);
}
#[test]
fn output_in_range() {
let mut v = VolumeRsi::new(14).unwrap();
let candles: Vec<Candle> = (0..200)
.map(|i| vol_candle(1_000.0 + (f64::from(i) * 0.3).sin() * 600.0))
.collect();
for o in v.batch(&candles).into_iter().flatten() {
assert!((0.0..=100.0).contains(&o));
}
}
#[test]
fn reset_clears_state() {
let mut v = VolumeRsi::new(3).unwrap();
let candles: Vec<Candle> = (0..20)
.map(|i| vol_candle(1_000.0 + f64::from(i)))
.collect();
v.batch(&candles);
assert!(v.is_ready());
v.reset();
assert!(!v.is_ready());
assert_eq!(v.value(), None);
assert_eq!(v.update(vol_candle(1_000.0)), None);
}
#[test]
fn batch_equals_streaming() {
let candles: Vec<Candle> = (0..120)
.map(|i| vol_candle(1_000.0 + (f64::from(i) * 0.25).sin() * 500.0))
.collect();
let batch = VolumeRsi::new(14).unwrap().batch(&candles);
let mut b = VolumeRsi::new(14).unwrap();
let streamed: Vec<_> = candles.iter().map(|c| b.update(*c)).collect();
assert_eq!(batch, streamed);
}
}
@@ -0,0 +1,270 @@
//! Volume-Weighted MACD — MACD built on volume-weighted moving averages.
use crate::error::{Error, Result};
use crate::indicators::ema::Ema;
use crate::indicators::vwma::Vwma;
use crate::ohlcv::Candle;
use crate::traits::Indicator;
/// Output of [`VolumeWeightedMacd`]: the three classic MACD series, but with the
/// fast and slow averages volume-weighted.
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct VolumeWeightedMacdOutput {
/// Fast VWMA slow VWMA.
pub macd: f64,
/// EMA of `macd` over the signal period.
pub signal: f64,
/// `macd signal`.
pub histogram: f64,
}
/// Volume-Weighted MACD — the MACD oscillator computed from **volume-weighted**
/// moving averages instead of plain EMAs.
///
/// ```text
/// macd = VWMA(close, fast) VWMA(close, slow)
/// signal = EMA(macd, signal_period)
/// histogram = macd signal
/// ```
///
/// Standard [`MacdIndicator`](crate::MacdIndicator) smooths price with exponential
/// averages that ignore volume. The volume-weighted variant (Buff Dormeier and
/// others) replaces each average with a [`Vwma`], so heavy-volume bars dominate
/// the trend estimate and the oscillator leans toward where real participation
/// occurred. Crossovers backed by volume therefore appear sooner and noise from
/// thin bars is damped. The signal line keeps a standard EMA, matching the
/// classic histogram construction.
///
/// `fast` must be strictly smaller than `slow`. The first output lands after
/// `slow + signal 1` inputs: `slow` to seed the slow VWMA, then `signal 1`
/// more to seed the signal EMA. Each `update` is O(1).
///
/// # Example
///
/// ```
/// use wickra_core::{Candle, Indicator, VolumeWeightedMacd};
///
/// let mut indicator = VolumeWeightedMacd::new(12, 26, 9).unwrap();
/// let mut last = None;
/// for i in 0..80 {
/// let base = 100.0 + f64::from(i);
/// let c = Candle::new(base, base + 1.0, base - 1.0, base + 0.5, 1_000.0, 0).unwrap();
/// last = indicator.update(c);
/// }
/// assert!(last.is_some());
/// ```
#[derive(Debug, Clone)]
pub struct VolumeWeightedMacd {
fast: Vwma,
slow: Vwma,
signal_ema: Ema,
fast_period: usize,
slow_period: usize,
signal_period: usize,
last: Option<VolumeWeightedMacdOutput>,
}
impl VolumeWeightedMacd {
/// Construct a volume-weighted MACD with the given periods.
///
/// # Errors
///
/// Returns [`Error::PeriodZero`] if any period is zero, and
/// [`Error::InvalidPeriod`] if `fast >= slow`.
pub fn new(fast: usize, slow: usize, signal: usize) -> Result<Self> {
if fast == 0 || slow == 0 || signal == 0 {
return Err(Error::PeriodZero);
}
if fast >= slow {
return Err(Error::InvalidPeriod {
message: "fast period must be strictly less than slow period",
});
}
Ok(Self {
fast: Vwma::new(fast)?,
slow: Vwma::new(slow)?,
signal_ema: Ema::new(signal)?,
fast_period: fast,
slow_period: slow,
signal_period: signal,
last: None,
})
}
/// Configured periods as `(fast, slow, signal)`.
pub const fn periods(&self) -> (usize, usize, usize) {
(self.fast_period, self.slow_period, self.signal_period)
}
/// Most recent fully-computed output if available.
pub const fn value(&self) -> Option<VolumeWeightedMacdOutput> {
self.last
}
}
impl Indicator for VolumeWeightedMacd {
type Input = Candle;
type Output = VolumeWeightedMacdOutput;
fn update(&mut self, candle: Candle) -> Option<VolumeWeightedMacdOutput> {
let fast = self.fast.update(candle);
let slow = self.slow.update(candle);
if let (Some(f), Some(s)) = (fast, slow) {
let macd = f - s;
let signal = self.signal_ema.update(macd)?;
let out = VolumeWeightedMacdOutput {
macd,
signal,
histogram: macd - signal,
};
self.last = Some(out);
return Some(out);
}
None
}
fn reset(&mut self) {
self.fast.reset();
self.slow.reset();
self.signal_ema.reset();
self.last = None;
}
fn warmup_period(&self) -> usize {
self.slow_period + self.signal_period - 1
}
fn is_ready(&self) -> bool {
self.last.is_some()
}
fn name(&self) -> &'static str {
"VolumeWeightedMacd"
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::traits::BatchExt;
use approx::assert_relative_eq;
fn candle(close: f64, volume: f64) -> Candle {
Candle::new_unchecked(close, close, close, close, volume, 0)
}
#[test]
fn rejects_invalid_periods() {
assert!(matches!(
VolumeWeightedMacd::new(0, 26, 9),
Err(Error::PeriodZero)
));
assert!(matches!(
VolumeWeightedMacd::new(26, 12, 9),
Err(Error::InvalidPeriod { .. })
));
assert!(matches!(
VolumeWeightedMacd::new(12, 12, 9),
Err(Error::InvalidPeriod { .. })
));
}
#[test]
fn accessors_and_metadata() {
let m = VolumeWeightedMacd::new(12, 26, 9).unwrap();
assert_eq!(m.periods(), (12, 26, 9));
assert_eq!(m.warmup_period(), 34);
assert_eq!(m.name(), "VolumeWeightedMacd");
assert!(!m.is_ready());
assert_eq!(m.value(), None);
}
#[test]
fn first_emission_at_warmup_period() {
let mut m = VolumeWeightedMacd::new(2, 4, 3).unwrap();
let candles: Vec<Candle> = (0..20)
.map(|i| candle(100.0 + f64::from(i), 1_000.0))
.collect();
let out = m.batch(&candles);
let warmup = m.warmup_period(); // 4 + 3 - 1 = 6
assert_eq!(warmup, 6);
for v in out.iter().take(warmup - 1) {
assert!(v.is_none());
}
assert!(out[warmup - 1].is_some());
}
#[test]
fn uptrend_has_positive_macd() {
// A steady advance with equal volume -> fast VWMA leads slow -> macd > 0.
let mut m = VolumeWeightedMacd::new(3, 6, 3).unwrap();
let candles: Vec<Candle> = (0..60)
.map(|i| candle(100.0 + f64::from(i), 1_000.0))
.collect();
let last = m.batch(&candles).into_iter().flatten().last().unwrap();
assert!(
last.macd > 0.0,
"uptrend should give positive macd, got {}",
last.macd
);
}
#[test]
fn histogram_is_macd_minus_signal() {
let mut m = VolumeWeightedMacd::new(3, 6, 3).unwrap();
let candles: Vec<Candle> = (0..60)
.map(|i| {
candle(
100.0 + (f64::from(i) * 0.3).sin() * 5.0,
1_000.0 + f64::from(i),
)
})
.collect();
for o in m.batch(&candles).into_iter().flatten() {
assert_relative_eq!(o.histogram, o.macd - o.signal, epsilon = 1e-9);
}
}
#[test]
fn equal_volume_matches_plain_macd() {
// With constant volume, VWMA reduces to SMA, so volume-weighted MACD uses
// SMA-based lines; it should still be a well-defined finite series.
let mut m = VolumeWeightedMacd::new(3, 6, 3).unwrap();
let candles: Vec<Candle> = (0..60)
.map(|i| candle(100.0 + (f64::from(i) * 0.2).sin() * 4.0, 2_000.0))
.collect();
for o in m.batch(&candles).into_iter().flatten() {
assert!(o.macd.is_finite() && o.signal.is_finite());
}
}
#[test]
fn reset_clears_state() {
let mut m = VolumeWeightedMacd::new(3, 6, 3).unwrap();
let candles: Vec<Candle> = (0..40)
.map(|i| candle(100.0 + f64::from(i), 1_000.0))
.collect();
m.batch(&candles);
assert!(m.is_ready());
m.reset();
assert!(!m.is_ready());
assert_eq!(m.value(), None);
assert_eq!(m.update(candle(100.0, 1_000.0)), None);
}
#[test]
fn batch_equals_streaming() {
let candles: Vec<Candle> = (0..120)
.map(|i| {
candle(
100.0 + (f64::from(i) * 0.25).sin() * 9.0,
1_000.0 + f64::from(i),
)
})
.collect();
let batch = VolumeWeightedMacd::new(12, 26, 9).unwrap().batch(&candles);
let mut b = VolumeWeightedMacd::new(12, 26, 9).unwrap();
let streamed: Vec<_> = candles.iter().map(|c| b.update(*c)).collect();
assert_eq!(batch, streamed);
}
}
+206
View File
@@ -0,0 +1,206 @@
//! Williams Accumulation/Distribution (WAD) — Larry Williams' cumulative line.
use crate::ohlcv::Candle;
use crate::traits::Indicator;
/// Williams Accumulation/Distribution — a cumulative price-only line that adds
/// the day's accumulation on up-closes and subtracts the day's distribution on
/// down-closes.
///
/// ```text
/// if close > prev_close: AD = close min(low, prev_close) (true low)
/// if close < prev_close: AD = close max(high, prev_close) (true high)
/// if close = prev_close: AD = 0
/// WAD_t = WAD_{t1} + AD
/// ```
///
/// Larry Williams' A/D line (distinct from Chaikin's volume-based
/// [`Adl`](crate::Adl)) uses **no volume at all** — it measures accumulation as
/// how far price closed above the *true low* on up-days and distribution as how
/// far it closed below the *true high* on down-days, then accumulates the result.
/// A rising WAD that diverges from a flat or falling price is the classic
/// accumulation signal; a falling WAD under a rising price warns of distribution.
///
/// The line is unbounded and its absolute level is meaningless — only its slope
/// and divergences against price matter. The first candle has no previous close,
/// so it seeds the reference and emits nothing; thereafter every bar emits the
/// running total. Each `update` is O(1).
///
/// # Example
///
/// ```
/// use wickra_core::{Candle, Indicator, Wad};
///
/// let mut indicator = Wad::new();
/// let mut last = None;
/// for i in 0..20 {
/// let base = 100.0 + f64::from(i);
/// let c = Candle::new(base, base + 1.0, base - 1.0, base + 0.5, 1_000.0, 0).unwrap();
/// last = indicator.update(c);
/// }
/// assert!(last.is_some());
/// ```
#[derive(Debug, Clone, Default)]
pub struct Wad {
prev_close: Option<f64>,
line: f64,
last: Option<f64>,
}
impl Wad {
/// Construct a new Williams A/D line. The line is parameter-free.
#[must_use]
pub fn new() -> Self {
Self::default()
}
/// Current value if available.
pub const fn value(&self) -> Option<f64> {
self.last
}
}
impl Indicator for Wad {
type Input = Candle;
type Output = f64;
fn update(&mut self, candle: Candle) -> Option<f64> {
let Some(prev_close) = self.prev_close else {
self.prev_close = Some(candle.close);
return None;
};
let ad = if candle.close > prev_close {
candle.close - candle.low.min(prev_close)
} else if candle.close < prev_close {
candle.close - candle.high.max(prev_close)
} else {
0.0
};
self.line += ad;
self.prev_close = Some(candle.close);
self.last = Some(self.line);
Some(self.line)
}
fn reset(&mut self) {
self.prev_close = None;
self.line = 0.0;
self.last = None;
}
fn warmup_period(&self) -> usize {
// The first bar only seeds the reference close; the first value lands on
// the second bar.
2
}
fn is_ready(&self) -> bool {
self.last.is_some()
}
fn name(&self) -> &'static str {
"Wad"
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::traits::BatchExt;
use approx::assert_relative_eq;
fn candle(high: f64, low: f64, close: f64) -> Candle {
Candle::new_unchecked(low, high, low, close, 1_000.0, 0)
}
#[test]
fn accessors_and_metadata() {
let wad = Wad::new();
assert_eq!(wad.warmup_period(), 2);
assert_eq!(wad.name(), "Wad");
assert!(!wad.is_ready());
assert_eq!(wad.value(), None);
}
#[test]
fn first_bar_seeds_without_output() {
let mut wad = Wad::new();
assert_eq!(wad.update(candle(101.0, 99.0, 100.0)), None);
assert!(wad.update(candle(102.0, 100.0, 101.0)).is_some());
}
#[test]
fn up_close_accumulates() {
// close rises from 100 -> 101; true low = min(low, prev_close) = min(100,100)=100;
// AD = 101 - 100 = 1.
let mut wad = Wad::new();
wad.update(candle(101.0, 99.0, 100.0));
let v = wad.update(candle(102.0, 100.0, 101.0)).unwrap();
assert_relative_eq!(v, 1.0, epsilon = 1e-9);
}
#[test]
fn down_close_distributes() {
// close falls 100 -> 99; true high = max(high, prev_close) = max(101,100)=101;
// AD = 99 - 101 = -2.
let mut wad = Wad::new();
wad.update(candle(102.0, 100.0, 100.0));
let v = wad.update(candle(101.0, 98.0, 99.0)).unwrap();
assert_relative_eq!(v, -2.0, epsilon = 1e-9);
}
#[test]
fn unchanged_close_adds_nothing() {
let mut wad = Wad::new();
wad.update(candle(101.0, 99.0, 100.0));
let v = wad.update(candle(105.0, 95.0, 100.0)).unwrap();
assert_relative_eq!(v, 0.0, epsilon = 1e-12);
}
#[test]
fn pure_uptrend_is_monotone() {
let mut wad = Wad::new();
let candles: Vec<Candle> = (0..30)
.map(|i| {
let base = 100.0 + f64::from(i);
candle(base + 1.0, base - 1.0, base)
})
.collect();
let mut prev = f64::NEG_INFINITY;
for v in wad.batch(&candles).into_iter().flatten() {
assert!(v >= prev, "WAD must rise in an uptrend");
prev = v;
}
}
#[test]
fn reset_clears_state() {
let mut wad = Wad::new();
let candles: Vec<Candle> = (0..10)
.map(|i| {
let base = 100.0 + f64::from(i);
candle(base + 1.0, base - 1.0, base)
})
.collect();
wad.batch(&candles);
assert!(wad.is_ready());
wad.reset();
assert!(!wad.is_ready());
assert_eq!(wad.value(), None);
assert_eq!(wad.update(candle(101.0, 99.0, 100.0)), None);
}
#[test]
fn batch_equals_streaming() {
let candles: Vec<Candle> = (0..80)
.map(|i| {
let base = 100.0 + (f64::from(i) * 0.3).sin() * 8.0;
candle(base + 2.0, base - 2.0, base + 0.5)
})
.collect();
let batch = Wad::new().batch(&candles);
let mut b = Wad::new();
let streamed: Vec<_> = candles.iter().map(|c| b.update(*c)).collect();
assert_eq!(batch, streamed);
}
}
+22 -21
View File
@@ -63,14 +63,14 @@ pub use indicators::{
AroonOscillator, AroonOutput, Atr, AtrBands, AtrBandsOutput, AtrRatchet, AtrRatchetOutput,
AtrTrailingStop, AutoFib, AutoFibOutput, Autocorrelation, AverageDailyRange, AverageDrawdown,
AvgPrice, AwesomeOscillator, AwesomeOscillatorHistogram, BalanceOfPower, Bat, BeltHold, Beta,
BetaNeutralSpread, BipowerVariation, BodySizePct, BollingerBands, BollingerBandwidth,
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,
BetaNeutralSpread, BetterVolume, BipowerVariation, BodySizePct, BollingerBands,
BollingerBandwidth, 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,
@@ -92,7 +92,7 @@ pub use indicators::{
HistoricalVolatility, Hma, HoltWinters, HomingPigeon, HtDcPhase, HtPhasor, HtPhasorOutput,
HtTrendMode, HurstChannel, HurstChannelOutput, HurstExponent, Ichimoku, IchimokuOutput,
IdenticalThreeCrows, InNeck, Inertia, InformationRatio, InitialBalance, InitialBalanceOutput,
InstantaneousTrendline, IntradayMomentumIndex, IntradayVolatilityProfile,
InstantaneousTrendline, IntradayIntensity, IntradayMomentumIndex, IntradayVolatilityProfile,
IntradayVolatilityProfileOutput, InverseFisherTransform, InvertedHammer, Jma, JumpIndicator,
KagiBars, KalmanHedgeRatio, KalmanHedgeRatioOutput, Kama, KaseDevStop, KaseDevStopOutput,
KasePermissionStochastic, KasePermissionStochasticOutput, KellyCriterion, Keltner,
@@ -132,18 +132,19 @@ pub use indicators::{
TermStructureBasis, ThreeDrives, ThreeInside, ThreeLineStrike, ThreeOutside,
ThreeSoldiersOrCrows, ThreeStarsInSouth, Thrusting, TickIndex, Tii, TimeBasedStop,
TimeOfDayReturnProfile, TimeOfDayReturnProfileOutput, TpoProfile, TpoProfileOutput,
TradeImbalance, TrendLabel, TrendStrengthIndex, TreynorRatio, Triangle, Trima, Trin,
TripleTopBottom, Trix, TrueRange, Tsf, TsfOscillator, Tsi, Tsv, TtmSqueeze, TtmSqueezeOutput,
TtmTrend, TurnOfMonth, Tweezer, TwoCrows, TypicalPrice, UlcerIndex, UltimateOscillator,
UniqueThreeRiver, UpDownVolumeRatio, UpsideGapThreeMethods, UpsideGapTwoCrows, ValueArea,
ValueAreaOutput, ValueAtRisk, Variance, VarianceRatio, VerticalHorizontalFilter, Vidya,
VolatilityCone, VolatilityConeOutput, VolatilityOfVolatility, VolatilityRatio, VoltyStop,
VolumeByTimeProfile, VolumeByTimeProfileOutput, VolumeOscillator, VolumePriceTrend,
VolumeProfile, VolumeProfileOutput, Vortex, VortexOutput, Vpin, Vwap, VwapStdDevBands,
VwapStdDevBandsOutput, Vwma, Vzo, WavePm, WaveTrend, WaveTrendOutput, Wedge, WeightedClose,
WickRatio, WilliamsFractals, WilliamsFractalsOutput, WilliamsR, WinRate, Wma, WoodiePivots,
WoodiePivotsOutput, YangZhangVolatility, YoyoExit, ZScore, ZeroLagMacd, ZeroLagMacdOutput,
ZigZag, ZigZagOutput, Zlema, FAMILIES, T3,
TradeImbalance, TradeVolumeIndex, TrendLabel, TrendStrengthIndex, TreynorRatio, Triangle,
Trima, Trin, TripleTopBottom, Trix, TrueRange, Tsf, TsfOscillator, Tsi, Tsv, TtmSqueeze,
TtmSqueezeOutput, TtmTrend, TurnOfMonth, Tweezer, TwiggsMoneyFlow, TwoCrows, TypicalPrice,
UlcerIndex, UltimateOscillator, UniqueThreeRiver, UpDownVolumeRatio, UpsideGapThreeMethods,
UpsideGapTwoCrows, ValueArea, ValueAreaOutput, ValueAtRisk, Variance, VarianceRatio,
VerticalHorizontalFilter, Vidya, VolatilityCone, VolatilityConeOutput, VolatilityOfVolatility,
VolatilityRatio, VoltyStop, VolumeByTimeProfile, VolumeByTimeProfileOutput, VolumeOscillator,
VolumePriceTrend, VolumeProfile, VolumeProfileOutput, VolumeRsi, VolumeWeightedMacd,
VolumeWeightedMacdOutput, Vortex, VortexOutput, Vpin, Vwap, VwapStdDevBands,
VwapStdDevBandsOutput, Vwma, Vzo, Wad, WavePm, WaveTrend, WaveTrendOutput, Wedge,
WeightedClose, WickRatio, WilliamsFractals, WilliamsFractalsOutput, WilliamsR, WinRate, Wma,
WoodiePivots, WoodiePivotsOutput, YangZhangVolatility, YoyoExit, ZScore, ZeroLagMacd,
ZeroLagMacdOutput, ZigZag, ZigZagOutput, Zlema, FAMILIES, T3,
};
// `FootprintLevel` is a row element of `FootprintOutput`, re-exported on its own
// line so the indicator-count tooling (which scans the braced block above and