Files
wickra/crates/wickra-core/src/indicators/adl.rs
T
kingchenc 81962485af F9: add Accumulation/Distribution Line and Volume-Price Trend
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.
2026-05-22 18:38:21 +02:00

186 lines
5.3 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! 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_{t1} + 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);
}
}