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:
@@ -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_{t−1} + 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);
|
||||
}
|
||||
}
|
||||
@@ -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_{t−1} + 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_{t−1}
|
||||
/// 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);
|
||||
}
|
||||
}
|
||||
@@ -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_{t−1} + 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);
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user