Files
wickra/crates/wickra-core/src/indicators/pvi.rs
T
kingchencandGitHub 880a0e7430 feat: Family 07 Volume - 6 new volume-flow indicators (#45)
* feat(kvo): add Klinger Volume Oscillator

Stephen J. Klinger's trend-aware volume-force MACD. Each bar produces a 'volume force' (vf) signed by the local trend (+1 / -1 / carry) and scaled by the ratio of the current accumulation horizon to its previous trend. KVO = EMA(vf, fast) - EMA(vf, slow), classic (34, 55).

Rust core (Kvo) with 7 unit tests (rejects zero / fast>=slow, accessors, constant series collapses to 0, warmup lands at slow+1, batch == streaming, reset clears state), plus Python (PyKvo + KVO export), Node (KvoNode), and WASM (WasmKvo) bindings. Fuzz target adds Kvo to the candle-input sweep, bench adds the candle-input KVO benchmark, README counter 71 -> 72 + family table row, CHANGELOG [Unreleased].

* feat(volume-oscillator): add Volume Oscillator (VO)

Percent difference between a fast and a slow SMA of the bar volume: 100 * (SMA(vol, fast) - SMA(vol, slow)) / SMA(vol, slow). Default (14, 28). The line stays near zero in stable conditions; positive readings show rising short-term participation, negative readings show waning interest.

Rust core (VolumeOscillator) with 8 unit tests (period validation, accessors, constant volume == 0, zero-volume window defensive branch, two reference values verified algebraically, batch == streaming, reset), plus Python (PyVolumeOscillator + VolumeOscillator export), Node (VolumeOscillatorNode), and WASM (WasmVolumeOscillator) bindings. Fuzz target adds VolumeOscillator to the candle-input sweep, bench adds the volume_oscillator benchmark, README counter 72 -> 73 + family table row, CHANGELOG [Unreleased].

* feat(nvi-pvi): add Negative & Positive Volume Index

Paul Dysart's cumulative volume-flow indices, popularised by Norman Fosback in 'Stock Market Logic'. Both run from a 1000.0 baseline and only update on a specific direction of volume change:

- NVI updates on volume-contraction bars (volume_t < volume_{t-1}), absorbing the percent close change. Tracks the 'smart money' leg per Fosback.
- PVI updates on volume-expansion bars (volume_t > volume_{t-1}). Tracks the 'crowd' leg.

Both expose with_baseline(f64) for custom starting indexes. The NVI/PVI pair is listed as a single line in indicator-ideas/families/07-volume.md and shares the same lifecycle/test/binding surface, so they ship as one commit.

Rust core (Nvi, Pvi) with 9 unit tests each (accessors, baseline seed, volume direction branches, zero-prev-close guard, custom baseline, batch == streaming, reset), plus Python (PyNvi/PyPvi + NVI/PVI exports), Node (NviNode/PviNode), and WASM (WasmNvi/WasmPvi) bindings. Fuzz target adds Nvi+Pvi to the candle-input sweep, bench adds nvi+pvi entries, README counter 73 -> 75 + family table row, CHANGELOG [Unreleased].

* feat(family-07): add Williams A/D, Anchored VWAP, Demand Index, TSV, VZO, Market Facilitation Index

Finishes the volume-flow family with the remaining (new) entries from
indicator-ideas/families/07-volume.md.

Indicators added:

- Williams A/D (`WilliamsAD`): Larry Williams' volume-less cumulative
  accumulation/distribution line. Anchors each bar's contribution to
  the previous close via true-high/true-low (gap-aware).
- Anchored VWAP (`AnchoredVwap`): cumulative VWAP whose accumulation
  starts at a user-chosen anchor bar. Exposes `set_anchor()` (queued
  to the next `update`) for click-to-anchor workflows. Reset clears
  both state and pending-anchor flag.
- Demand Index (`DemandIndex`): James Sibbet's smoothed buying-vs-
  selling pressure, in the streaming-friendly textbook form
  `EMA(volume * close-return * (1 + range/close), period)`.
- Time Segmented Volume (`Tsv`): Don Worden's rolling window-sum of
  `(close_t - close_{t-1}) * volume_t`. Default `period = 18`.
- Volume Zone Oscillator (`Vzo`): Walid Khalil's normalised volume-flow
  oscillator bounded in `[-100, +100]`, defined as
  `100 * EMA(signed_volume) / EMA(volume)`.
- Market Facilitation Index (`MarketFacilitationIndex`): Bill Williams'
  per-bar `(high - low) / volume`. Returns `None` on zero-volume bars.

All six indicators ship with unit tests (`rejects_zero_period` where
applicable, `accessors_and_metadata`, constant-series behaviour,
batch == streaming equivalence, reset semantics, and reference-value
or saturation-extreme tests), Python / Node / WASM bindings, fuzz
coverage in `indicator_update_candle`, a `bench_candle_input` line per
indicator, README + CHANGELOG entries, and Python reference-value
tests in `test_new_indicators.py`.

The README indicator counter advances 75 -> 81.

* test(family-07): cover defensive cold paths + Default impls

- ad_oscillator: exercise `value()` after first emission.
- kvo: cover the `cm == 0.0` zero-OHLC defensive branch.
- nvi / pvi: exercise the Default impls.
2026-05-25 19:15:22 +02:00

229 lines
6.1 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.
//! Positive Volume Index.
use crate::ohlcv::Candle;
use crate::traits::Indicator;
/// Default starting value; matches Norman Fosback's textbook convention.
const STARTING_INDEX: f64 = 1000.0;
/// Positive Volume Index (Paul Dysart, popularised by Norman Fosback).
///
/// The PVI only updates when **volume expands** — Fosback's interpretation is
/// that the crowd ("uninformed money") trades on volume spikes, so the PVI
/// tracks the crowd-driven leg of price action. When today's volume is at or
/// below yesterday's, the PVI is left unchanged.
///
/// ```text
/// PVI_t = PVI_{t1} · (1 + (close_t close_{t1}) / close_{t1}) if volume_t > volume_{t1}
/// PVI_t = PVI_{t1} otherwise
/// ```
///
/// The first bar establishes the baseline at `1000.0`. A bar whose previous
/// close is zero contributes no return.
///
/// # Example
///
/// ```
/// use wickra_core::{Candle, Indicator, Pvi};
///
/// let mut indicator = Pvi::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)]
pub struct Pvi {
prev_close: Option<f64>,
prev_volume: Option<f64>,
index: f64,
has_emitted: bool,
}
impl Pvi {
/// Construct a new PVI starting at `1000.0`.
pub const fn new() -> Self {
Self {
prev_close: None,
prev_volume: None,
index: STARTING_INDEX,
has_emitted: false,
}
}
/// Construct a new PVI with a custom starting baseline.
pub const fn with_baseline(baseline: f64) -> Self {
Self {
prev_close: None,
prev_volume: None,
index: baseline,
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.index)
} else {
None
}
}
}
impl Default for Pvi {
fn default() -> Self {
Self::new()
}
}
impl Indicator for Pvi {
type Input = Candle;
type Output = f64;
fn update(&mut self, candle: Candle) -> Option<f64> {
if let (Some(pc), Some(pv)) = (self.prev_close, self.prev_volume) {
if candle.volume > pv && pc != 0.0 {
let ret = (candle.close - pc) / pc;
self.index += self.index * ret;
}
}
self.prev_close = Some(candle.close);
self.prev_volume = Some(candle.volume);
self.has_emitted = true;
Some(self.index)
}
fn reset(&mut self) {
self.prev_close = None;
self.prev_volume = None;
self.index = STARTING_INDEX;
self.has_emitted = false;
}
fn warmup_period(&self) -> usize {
1
}
fn is_ready(&self) -> bool {
self.has_emitted
}
fn name(&self) -> &'static str {
"PVI"
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::traits::BatchExt;
use approx::assert_relative_eq;
fn c(close: f64, volume: f64, ts: i64) -> Candle {
Candle::new(close, close, close, close, volume, ts).unwrap()
}
#[test]
fn accessors_and_metadata() {
let mut p = Pvi::new();
assert_eq!(p.warmup_period(), 1);
assert_eq!(p.name(), "PVI");
assert_eq!(p.value(), None);
p.update(c(10.0, 100.0, 0));
assert_eq!(p.value(), Some(1000.0));
}
#[test]
fn default_matches_new() {
let a = Pvi::default();
let b = Pvi::new();
assert_eq!(a.warmup_period(), b.warmup_period());
assert_eq!(a.value(), b.value());
assert_eq!(a.is_ready(), b.is_ready());
}
#[test]
fn first_bar_seeds_baseline() {
let mut p = Pvi::new();
assert_relative_eq!(
p.update(c(10.0, 100.0, 0)).unwrap(),
1000.0,
epsilon = 1e-12
);
}
#[test]
fn volume_rise_applies_percent_change() {
// 1000 * (1 + (11 - 10)/10) = 1100.
let mut p = Pvi::new();
p.update(c(10.0, 100.0, 0));
let v = p.update(c(11.0, 200.0, 1)).unwrap();
assert_relative_eq!(v, 1100.0, epsilon = 1e-12);
}
#[test]
fn volume_fall_leaves_index_unchanged() {
let mut p = Pvi::new();
p.update(c(10.0, 200.0, 0));
let v = p.update(c(11.0, 100.0, 1)).unwrap();
assert_relative_eq!(v, 1000.0, epsilon = 1e-12);
}
#[test]
fn equal_volume_leaves_index_unchanged() {
let mut p = Pvi::new();
p.update(c(10.0, 100.0, 0));
let v = p.update(c(11.0, 100.0, 1)).unwrap();
assert_relative_eq!(v, 1000.0, epsilon = 1e-12);
}
#[test]
fn zero_previous_close_contributes_no_return() {
let mut p = Pvi::new();
p.update(c(0.0, 100.0, 0));
let v = p.update(c(5.0, 200.0, 1)).unwrap();
assert_relative_eq!(v, 1000.0, epsilon = 1e-12);
}
#[test]
fn custom_baseline() {
let mut p = Pvi::with_baseline(100.0);
assert_relative_eq!(p.update(c(10.0, 100.0, 0)).unwrap(), 100.0, epsilon = 1e-12);
}
#[test]
fn batch_equals_streaming() {
let candles: Vec<Candle> = (0..80i64)
.map(|i| {
let f = i as f64;
c(
100.0 + (f * 0.3).sin() * 5.0,
50.0 + ((i % 7) as f64) * 10.0,
i,
)
})
.collect();
let mut a = Pvi::new();
let mut b = Pvi::new();
assert_eq!(
a.batch(&candles),
candles.iter().map(|x| b.update(*x)).collect::<Vec<_>>()
);
}
#[test]
fn reset_clears_state() {
let mut p = Pvi::new();
p.batch(&[c(10.0, 100.0, 0), c(11.0, 200.0, 1)]);
assert!(p.is_ready());
p.reset();
assert!(!p.is_ready());
assert_eq!(p.value(), None);
}
}