Completes the F9 family (Cumulative volume) end to end: - Rust core: adl.rs (Accumulation/Distribution Line — cumulative range-weighted volume) and vpt.rs (Volume-Price Trend — cumulative volume scaled by percentage price change). Each with a full Indicator impl, runnable doctest and reference / cumulative-property / warmup / reset / batch==streaming tests. - Python: PyAdl / PyVolumePriceTrend PyO3 classes + module registration + .pyi stubs (no parameters, like OBV/VWAP). - Node: explicit AdlNode and VolumePriceTrendNode; index.d.ts and index.js updated. - WASM: WasmAdl and WasmVolumePriceTrend. - Wiki: Indicator-Adl.md and Indicator-VolumePriceTrend.md plus rows in Indicators-Overview.md and entries in Home.md. cargo fmt + clippy (core/wickra/data/wasm/node) clean; 373 core tests, 25 data tests and 53 doctests green.
186 lines
5.3 KiB
Rust
186 lines
5.3 KiB
Rust
//! Accumulation/Distribution Line.
|
||
|
||
use crate::ohlcv::Candle;
|
||
use crate::traits::Indicator;
|
||
|
||
/// Accumulation/Distribution Line — Marc Chaikin's cumulative volume-flow
|
||
/// indicator.
|
||
///
|
||
/// Each bar contributes a *money-flow volume*: the bar's volume weighted by
|
||
/// where the close fell within the bar's range.
|
||
///
|
||
/// ```text
|
||
/// MFM_t = ((close − low) − (high − close)) / (high − low) (the money-flow multiplier, −1..+1)
|
||
/// MFV_t = MFM_t · volume_t
|
||
/// ADL_t = ADL_{t−1} + MFV_t
|
||
/// ```
|
||
///
|
||
/// A close near the high makes the multiplier near `+1` (accumulation), near
|
||
/// the low near `−1` (distribution). The running total is unbounded and drifts
|
||
/// with cumulative volume — what matters is its slope and its divergence from
|
||
/// price. A bar with `high == low` contributes `0`.
|
||
///
|
||
/// # Example
|
||
///
|
||
/// ```
|
||
/// use wickra_core::{Candle, Indicator, Adl};
|
||
///
|
||
/// let mut indicator = Adl::new();
|
||
/// let mut last = None;
|
||
/// for i in 0..80 {
|
||
/// let base = 100.0 + f64::from(i);
|
||
/// let candle =
|
||
/// Candle::new(base, base + 2.0, base - 2.0, base + 1.0, 10.0, i64::from(i)).unwrap();
|
||
/// last = indicator.update(candle);
|
||
/// }
|
||
/// assert!(last.is_some());
|
||
/// ```
|
||
#[derive(Debug, Clone, Default)]
|
||
pub struct Adl {
|
||
total: f64,
|
||
has_emitted: bool,
|
||
}
|
||
|
||
impl Adl {
|
||
/// Construct a new Accumulation/Distribution Line starting at zero.
|
||
pub const fn new() -> Self {
|
||
Self {
|
||
total: 0.0,
|
||
has_emitted: false,
|
||
}
|
||
}
|
||
|
||
/// Current cumulative value if at least one candle has been ingested.
|
||
pub const fn value(&self) -> Option<f64> {
|
||
if self.has_emitted {
|
||
Some(self.total)
|
||
} else {
|
||
None
|
||
}
|
||
}
|
||
}
|
||
|
||
impl Indicator for Adl {
|
||
type Input = Candle;
|
||
type Output = f64;
|
||
|
||
fn update(&mut self, candle: Candle) -> Option<f64> {
|
||
let range = candle.high - candle.low;
|
||
let mfv = if range == 0.0 {
|
||
// A zero-range bar carries no positional information.
|
||
0.0
|
||
} else {
|
||
let mfm = ((candle.close - candle.low) - (candle.high - candle.close)) / range;
|
||
mfm * candle.volume
|
||
};
|
||
self.total += mfv;
|
||
self.has_emitted = true;
|
||
Some(self.total)
|
||
}
|
||
|
||
fn reset(&mut self) {
|
||
self.total = 0.0;
|
||
self.has_emitted = false;
|
||
}
|
||
|
||
fn warmup_period(&self) -> usize {
|
||
1
|
||
}
|
||
|
||
fn is_ready(&self) -> bool {
|
||
self.has_emitted
|
||
}
|
||
|
||
fn name(&self) -> &'static str {
|
||
"ADL"
|
||
}
|
||
}
|
||
|
||
#[cfg(test)]
|
||
mod tests {
|
||
use super::*;
|
||
use crate::traits::BatchExt;
|
||
use approx::assert_relative_eq;
|
||
|
||
fn candle(open: f64, high: f64, low: f64, close: f64, volume: f64, ts: i64) -> Candle {
|
||
Candle::new(open, high, low, close, volume, ts).unwrap()
|
||
}
|
||
|
||
#[test]
|
||
fn reference_values() {
|
||
// bar 1: close at high -> MFM = +1 -> MFV = +100; ADL = 100.
|
||
// bar 2: h=12 l=8 c=9 -> MFM = ((9-8)-(12-9))/4 = -0.5 -> MFV = -100;
|
||
// ADL = 100 - 100 = 0.
|
||
let mut adl = Adl::new();
|
||
let out = adl.batch(&[
|
||
candle(8.0, 10.0, 8.0, 10.0, 100.0, 0),
|
||
candle(10.0, 12.0, 8.0, 9.0, 200.0, 1),
|
||
]);
|
||
assert_relative_eq!(out[0].unwrap(), 100.0, epsilon = 1e-12);
|
||
assert_relative_eq!(out[1].unwrap(), 0.0, epsilon = 1e-12);
|
||
}
|
||
|
||
#[test]
|
||
fn emits_from_first_candle() {
|
||
let mut adl = Adl::new();
|
||
assert_eq!(adl.warmup_period(), 1);
|
||
assert!(adl.update(candle(8.0, 10.0, 8.0, 9.0, 50.0, 0)).is_some());
|
||
}
|
||
|
||
#[test]
|
||
fn close_at_high_accumulates_full_volume() {
|
||
// Every bar closes at its high: MFM = +1, so ADL grows by `volume`.
|
||
let mut adl = Adl::new();
|
||
let mut expected = 0.0;
|
||
for i in 0..10 {
|
||
let c = candle(8.0, 10.0, 8.0, 10.0, 25.0, i);
|
||
expected += 25.0;
|
||
assert_relative_eq!(adl.update(c).unwrap(), expected, epsilon = 1e-9);
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn zero_range_bar_contributes_nothing() {
|
||
let mut adl = Adl::new();
|
||
adl.update(candle(8.0, 10.0, 8.0, 10.0, 100.0, 0));
|
||
let before = adl.value().unwrap();
|
||
// A flat candle (high == low) adds zero.
|
||
let after = adl.update(candle(9.0, 9.0, 9.0, 9.0, 999.0, 1)).unwrap();
|
||
assert_relative_eq!(after, before, epsilon = 1e-12);
|
||
}
|
||
|
||
#[test]
|
||
fn reset_clears_state() {
|
||
let mut adl = Adl::new();
|
||
adl.batch(&[
|
||
candle(8.0, 10.0, 8.0, 9.0, 100.0, 0),
|
||
candle(9.0, 11.0, 9.0, 10.0, 100.0, 1),
|
||
]);
|
||
assert!(adl.is_ready());
|
||
adl.reset();
|
||
assert!(!adl.is_ready());
|
||
assert_eq!(adl.value(), None);
|
||
}
|
||
|
||
#[test]
|
||
fn batch_equals_streaming() {
|
||
let candles: Vec<Candle> = (0..60)
|
||
.map(|i| {
|
||
let mid = 100.0 + (i as f64 * 0.3).sin() * 8.0;
|
||
candle(
|
||
mid,
|
||
mid + 2.0,
|
||
mid - 2.0,
|
||
mid + 0.5,
|
||
10.0 + (i % 5) as f64,
|
||
i,
|
||
)
|
||
})
|
||
.collect();
|
||
let batch = Adl::new().batch(&candles);
|
||
let mut b = Adl::new();
|
||
let streamed: Vec<_> = candles.iter().map(|c| b.update(*c)).collect();
|
||
assert_eq!(batch, streamed);
|
||
}
|
||
}
|