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.
This commit is contained in:
@@ -123,6 +123,16 @@ from ._wickra import (
|
||||
ChaikinMoneyFlow,
|
||||
ChaikinOscillator,
|
||||
ForceIndex,
|
||||
KVO,
|
||||
VolumeOscillator,
|
||||
NVI,
|
||||
PVI,
|
||||
WilliamsAD,
|
||||
AnchoredVWAP,
|
||||
DemandIndex,
|
||||
TSV,
|
||||
VZO,
|
||||
MarketFacilitationIndex,
|
||||
EaseOfMovement,
|
||||
# Statistics
|
||||
TypicalPrice,
|
||||
@@ -246,6 +256,16 @@ __all__ = [
|
||||
"ChaikinMoneyFlow",
|
||||
"ChaikinOscillator",
|
||||
"ForceIndex",
|
||||
"KVO",
|
||||
"VolumeOscillator",
|
||||
"NVI",
|
||||
"PVI",
|
||||
"WilliamsAD",
|
||||
"AnchoredVWAP",
|
||||
"DemandIndex",
|
||||
"TSV",
|
||||
"VZO",
|
||||
"MarketFacilitationIndex",
|
||||
"EaseOfMovement",
|
||||
# Statistics
|
||||
"TypicalPrice",
|
||||
|
||||
@@ -4718,6 +4718,659 @@ impl PyForceIndex {
|
||||
}
|
||||
}
|
||||
|
||||
// ============================== Negative Volume Index ==============================
|
||||
|
||||
#[pyclass(name = "NVI", module = "wickra._wickra", skip_from_py_object)]
|
||||
#[derive(Clone)]
|
||||
struct PyNvi {
|
||||
inner: wc::Nvi,
|
||||
}
|
||||
|
||||
#[pymethods]
|
||||
impl PyNvi {
|
||||
#[new]
|
||||
#[pyo3(signature = (baseline=1000.0))]
|
||||
fn new(baseline: f64) -> Self {
|
||||
Self {
|
||||
inner: wc::Nvi::with_baseline(baseline),
|
||||
}
|
||||
}
|
||||
fn update(&mut self, candle: &Bound<'_, PyAny>) -> PyResult<Option<f64>> {
|
||||
let c = extract_candle(candle)?;
|
||||
Ok(self.inner.update(c))
|
||||
}
|
||||
/// Batch over close + volume numpy arrays.
|
||||
fn batch<'py>(
|
||||
&mut self,
|
||||
py: Python<'py>,
|
||||
close: PyReadonlyArray1<'py, f64>,
|
||||
volume: PyReadonlyArray1<'py, f64>,
|
||||
) -> PyResult<Bound<'py, PyArray1<f64>>> {
|
||||
let c = close
|
||||
.as_slice()
|
||||
.map_err(|_| PyValueError::new_err(NON_CONTIGUOUS))?;
|
||||
let v = volume
|
||||
.as_slice()
|
||||
.map_err(|_| PyValueError::new_err(NON_CONTIGUOUS))?;
|
||||
if c.len() != v.len() {
|
||||
return Err(PyValueError::new_err(
|
||||
"close and volume must be equal length",
|
||||
));
|
||||
}
|
||||
let mut out = Vec::with_capacity(c.len());
|
||||
for i in 0..c.len() {
|
||||
let candle = wc::Candle::new(c[i], c[i], c[i], c[i], v[i], 0).map_err(map_err)?;
|
||||
out.push(self.inner.update(candle).unwrap_or(f64::NAN));
|
||||
}
|
||||
Ok(out.into_pyarray(py))
|
||||
}
|
||||
fn reset(&mut self) {
|
||||
self.inner.reset();
|
||||
}
|
||||
fn is_ready(&self) -> bool {
|
||||
self.inner.is_ready()
|
||||
}
|
||||
fn warmup_period(&self) -> usize {
|
||||
self.inner.warmup_period()
|
||||
}
|
||||
fn __repr__(&self) -> String {
|
||||
"NVI()".to_string()
|
||||
}
|
||||
}
|
||||
|
||||
// ============================== Positive Volume Index ==============================
|
||||
|
||||
#[pyclass(name = "PVI", module = "wickra._wickra", skip_from_py_object)]
|
||||
#[derive(Clone)]
|
||||
struct PyPvi {
|
||||
inner: wc::Pvi,
|
||||
}
|
||||
|
||||
#[pymethods]
|
||||
impl PyPvi {
|
||||
#[new]
|
||||
#[pyo3(signature = (baseline=1000.0))]
|
||||
fn new(baseline: f64) -> Self {
|
||||
Self {
|
||||
inner: wc::Pvi::with_baseline(baseline),
|
||||
}
|
||||
}
|
||||
fn update(&mut self, candle: &Bound<'_, PyAny>) -> PyResult<Option<f64>> {
|
||||
let c = extract_candle(candle)?;
|
||||
Ok(self.inner.update(c))
|
||||
}
|
||||
fn batch<'py>(
|
||||
&mut self,
|
||||
py: Python<'py>,
|
||||
close: PyReadonlyArray1<'py, f64>,
|
||||
volume: PyReadonlyArray1<'py, f64>,
|
||||
) -> PyResult<Bound<'py, PyArray1<f64>>> {
|
||||
let c = close
|
||||
.as_slice()
|
||||
.map_err(|_| PyValueError::new_err(NON_CONTIGUOUS))?;
|
||||
let v = volume
|
||||
.as_slice()
|
||||
.map_err(|_| PyValueError::new_err(NON_CONTIGUOUS))?;
|
||||
if c.len() != v.len() {
|
||||
return Err(PyValueError::new_err(
|
||||
"close and volume must be equal length",
|
||||
));
|
||||
}
|
||||
let mut out = Vec::with_capacity(c.len());
|
||||
for i in 0..c.len() {
|
||||
let candle = wc::Candle::new(c[i], c[i], c[i], c[i], v[i], 0).map_err(map_err)?;
|
||||
out.push(self.inner.update(candle).unwrap_or(f64::NAN));
|
||||
}
|
||||
Ok(out.into_pyarray(py))
|
||||
}
|
||||
fn reset(&mut self) {
|
||||
self.inner.reset();
|
||||
}
|
||||
fn is_ready(&self) -> bool {
|
||||
self.inner.is_ready()
|
||||
}
|
||||
fn warmup_period(&self) -> usize {
|
||||
self.inner.warmup_period()
|
||||
}
|
||||
fn __repr__(&self) -> String {
|
||||
"PVI()".to_string()
|
||||
}
|
||||
}
|
||||
|
||||
// ============================== Volume Oscillator ==============================
|
||||
|
||||
#[pyclass(
|
||||
name = "VolumeOscillator",
|
||||
module = "wickra._wickra",
|
||||
skip_from_py_object
|
||||
)]
|
||||
#[derive(Clone)]
|
||||
struct PyVolumeOscillator {
|
||||
inner: wc::VolumeOscillator,
|
||||
}
|
||||
|
||||
#[pymethods]
|
||||
impl PyVolumeOscillator {
|
||||
#[new]
|
||||
#[pyo3(signature = (fast=14, slow=28))]
|
||||
fn new(fast: usize, slow: usize) -> PyResult<Self> {
|
||||
Ok(Self {
|
||||
inner: wc::VolumeOscillator::new(fast, slow).map_err(map_err)?,
|
||||
})
|
||||
}
|
||||
fn update(&mut self, candle: &Bound<'_, PyAny>) -> PyResult<Option<f64>> {
|
||||
let c = extract_candle(candle)?;
|
||||
Ok(self.inner.update(c))
|
||||
}
|
||||
/// Batch over a 1-D numpy volume array.
|
||||
fn batch<'py>(
|
||||
&mut self,
|
||||
py: Python<'py>,
|
||||
volume: PyReadonlyArray1<'py, f64>,
|
||||
) -> PyResult<Bound<'py, PyArray1<f64>>> {
|
||||
let v = volume
|
||||
.as_slice()
|
||||
.map_err(|_| PyValueError::new_err(NON_CONTIGUOUS))?;
|
||||
let mut out = Vec::with_capacity(v.len());
|
||||
for &vol in v {
|
||||
let candle = wc::Candle::new(10.0, 10.0, 10.0, 10.0, vol, 0).map_err(map_err)?;
|
||||
out.push(self.inner.update(candle).unwrap_or(f64::NAN));
|
||||
}
|
||||
Ok(out.into_pyarray(py))
|
||||
}
|
||||
#[getter]
|
||||
fn periods(&self) -> (usize, usize) {
|
||||
self.inner.periods()
|
||||
}
|
||||
fn reset(&mut self) {
|
||||
self.inner.reset();
|
||||
}
|
||||
fn is_ready(&self) -> bool {
|
||||
self.inner.is_ready()
|
||||
}
|
||||
fn warmup_period(&self) -> usize {
|
||||
self.inner.warmup_period()
|
||||
}
|
||||
fn __repr__(&self) -> String {
|
||||
let (fast, slow) = self.inner.periods();
|
||||
format!("VolumeOscillator(fast={fast}, slow={slow})")
|
||||
}
|
||||
}
|
||||
|
||||
// ============================== Klinger Volume Oscillator ==============================
|
||||
|
||||
#[pyclass(name = "KVO", module = "wickra._wickra", skip_from_py_object)]
|
||||
#[derive(Clone)]
|
||||
struct PyKvo {
|
||||
inner: wc::Kvo,
|
||||
}
|
||||
|
||||
#[pymethods]
|
||||
impl PyKvo {
|
||||
#[new]
|
||||
#[pyo3(signature = (fast=34, slow=55))]
|
||||
fn new(fast: usize, slow: usize) -> PyResult<Self> {
|
||||
Ok(Self {
|
||||
inner: wc::Kvo::new(fast, slow).map_err(map_err)?,
|
||||
})
|
||||
}
|
||||
fn update(&mut self, candle: &Bound<'_, PyAny>) -> PyResult<Option<f64>> {
|
||||
let c = extract_candle(candle)?;
|
||||
Ok(self.inner.update(c))
|
||||
}
|
||||
/// Batch over high/low/close/volume numpy columns.
|
||||
fn batch<'py>(
|
||||
&mut self,
|
||||
py: Python<'py>,
|
||||
high: PyReadonlyArray1<'py, f64>,
|
||||
low: PyReadonlyArray1<'py, f64>,
|
||||
close: PyReadonlyArray1<'py, f64>,
|
||||
volume: PyReadonlyArray1<'py, f64>,
|
||||
) -> PyResult<Bound<'py, PyArray1<f64>>> {
|
||||
let h = high
|
||||
.as_slice()
|
||||
.map_err(|_| PyValueError::new_err(NON_CONTIGUOUS))?;
|
||||
let l = low
|
||||
.as_slice()
|
||||
.map_err(|_| PyValueError::new_err(NON_CONTIGUOUS))?;
|
||||
let c = close
|
||||
.as_slice()
|
||||
.map_err(|_| PyValueError::new_err(NON_CONTIGUOUS))?;
|
||||
let v = volume
|
||||
.as_slice()
|
||||
.map_err(|_| PyValueError::new_err(NON_CONTIGUOUS))?;
|
||||
if h.len() != l.len() || l.len() != c.len() || c.len() != v.len() {
|
||||
return Err(PyValueError::new_err(
|
||||
"high, low, close, volume must be equal length",
|
||||
));
|
||||
}
|
||||
let mut out = Vec::with_capacity(h.len());
|
||||
for i in 0..h.len() {
|
||||
let candle = wc::Candle::new(c[i], h[i], l[i], c[i], v[i], 0).map_err(map_err)?;
|
||||
out.push(self.inner.update(candle).unwrap_or(f64::NAN));
|
||||
}
|
||||
Ok(out.into_pyarray(py))
|
||||
}
|
||||
#[getter]
|
||||
fn periods(&self) -> (usize, usize) {
|
||||
self.inner.periods()
|
||||
}
|
||||
fn reset(&mut self) {
|
||||
self.inner.reset();
|
||||
}
|
||||
fn is_ready(&self) -> bool {
|
||||
self.inner.is_ready()
|
||||
}
|
||||
fn warmup_period(&self) -> usize {
|
||||
self.inner.warmup_period()
|
||||
}
|
||||
fn __repr__(&self) -> String {
|
||||
let (fast, slow) = self.inner.periods();
|
||||
format!("KVO(fast={fast}, slow={slow})")
|
||||
}
|
||||
}
|
||||
|
||||
// ============================== Williams A/D Oscillator ==============================
|
||||
|
||||
#[pyclass(name = "WilliamsAD", module = "wickra._wickra", skip_from_py_object)]
|
||||
#[derive(Clone)]
|
||||
struct PyAdOscillator {
|
||||
inner: wc::AdOscillator,
|
||||
}
|
||||
|
||||
#[pymethods]
|
||||
impl PyAdOscillator {
|
||||
#[new]
|
||||
fn new() -> Self {
|
||||
Self {
|
||||
inner: wc::AdOscillator::new(),
|
||||
}
|
||||
}
|
||||
fn update(&mut self, candle: &Bound<'_, PyAny>) -> PyResult<Option<f64>> {
|
||||
let c = extract_candle(candle)?;
|
||||
Ok(self.inner.update(c))
|
||||
}
|
||||
/// Batch over high/low/close numpy columns.
|
||||
fn batch<'py>(
|
||||
&mut self,
|
||||
py: Python<'py>,
|
||||
high: PyReadonlyArray1<'py, f64>,
|
||||
low: PyReadonlyArray1<'py, f64>,
|
||||
close: PyReadonlyArray1<'py, f64>,
|
||||
) -> PyResult<Bound<'py, PyArray1<f64>>> {
|
||||
let h = high
|
||||
.as_slice()
|
||||
.map_err(|_| PyValueError::new_err(NON_CONTIGUOUS))?;
|
||||
let l = low
|
||||
.as_slice()
|
||||
.map_err(|_| PyValueError::new_err(NON_CONTIGUOUS))?;
|
||||
let c = close
|
||||
.as_slice()
|
||||
.map_err(|_| PyValueError::new_err(NON_CONTIGUOUS))?;
|
||||
if h.len() != l.len() || l.len() != c.len() {
|
||||
return Err(PyValueError::new_err(
|
||||
"high, low, close must be equal length",
|
||||
));
|
||||
}
|
||||
let mut out = Vec::with_capacity(h.len());
|
||||
for i in 0..h.len() {
|
||||
let candle = wc::Candle::new(c[i], h[i], l[i], c[i], 0.0, 0).map_err(map_err)?;
|
||||
out.push(self.inner.update(candle).unwrap_or(f64::NAN));
|
||||
}
|
||||
Ok(out.into_pyarray(py))
|
||||
}
|
||||
fn reset(&mut self) {
|
||||
self.inner.reset();
|
||||
}
|
||||
fn is_ready(&self) -> bool {
|
||||
self.inner.is_ready()
|
||||
}
|
||||
fn warmup_period(&self) -> usize {
|
||||
self.inner.warmup_period()
|
||||
}
|
||||
fn __repr__(&self) -> String {
|
||||
"WilliamsAD()".to_string()
|
||||
}
|
||||
}
|
||||
|
||||
// ============================== Anchored VWAP ==============================
|
||||
|
||||
#[pyclass(name = "AnchoredVWAP", module = "wickra._wickra", skip_from_py_object)]
|
||||
#[derive(Clone)]
|
||||
struct PyAnchoredVwap {
|
||||
inner: wc::AnchoredVwap,
|
||||
}
|
||||
|
||||
#[pymethods]
|
||||
impl PyAnchoredVwap {
|
||||
#[new]
|
||||
fn new() -> Self {
|
||||
Self {
|
||||
inner: wc::AnchoredVwap::new(),
|
||||
}
|
||||
}
|
||||
fn update(&mut self, candle: &Bound<'_, PyAny>) -> PyResult<Option<f64>> {
|
||||
let c = extract_candle(candle)?;
|
||||
Ok(self.inner.update(c))
|
||||
}
|
||||
/// Re-anchor the cumulative window at the next bar that arrives.
|
||||
fn set_anchor(&mut self) {
|
||||
self.inner.set_anchor();
|
||||
}
|
||||
/// Batch over high/low/close/volume numpy columns.
|
||||
fn batch<'py>(
|
||||
&mut self,
|
||||
py: Python<'py>,
|
||||
high: PyReadonlyArray1<'py, f64>,
|
||||
low: PyReadonlyArray1<'py, f64>,
|
||||
close: PyReadonlyArray1<'py, f64>,
|
||||
volume: PyReadonlyArray1<'py, f64>,
|
||||
) -> PyResult<Bound<'py, PyArray1<f64>>> {
|
||||
let h = high
|
||||
.as_slice()
|
||||
.map_err(|_| PyValueError::new_err(NON_CONTIGUOUS))?;
|
||||
let l = low
|
||||
.as_slice()
|
||||
.map_err(|_| PyValueError::new_err(NON_CONTIGUOUS))?;
|
||||
let c = close
|
||||
.as_slice()
|
||||
.map_err(|_| PyValueError::new_err(NON_CONTIGUOUS))?;
|
||||
let v = volume
|
||||
.as_slice()
|
||||
.map_err(|_| PyValueError::new_err(NON_CONTIGUOUS))?;
|
||||
if h.len() != l.len() || l.len() != c.len() || c.len() != v.len() {
|
||||
return Err(PyValueError::new_err(
|
||||
"high, low, close, volume must be equal length",
|
||||
));
|
||||
}
|
||||
let mut out = Vec::with_capacity(h.len());
|
||||
for i in 0..h.len() {
|
||||
let candle = wc::Candle::new(c[i], h[i], l[i], c[i], v[i], 0).map_err(map_err)?;
|
||||
out.push(self.inner.update(candle).unwrap_or(f64::NAN));
|
||||
}
|
||||
Ok(out.into_pyarray(py))
|
||||
}
|
||||
fn reset(&mut self) {
|
||||
self.inner.reset();
|
||||
}
|
||||
fn is_ready(&self) -> bool {
|
||||
self.inner.is_ready()
|
||||
}
|
||||
fn warmup_period(&self) -> usize {
|
||||
self.inner.warmup_period()
|
||||
}
|
||||
fn __repr__(&self) -> String {
|
||||
"AnchoredVWAP()".to_string()
|
||||
}
|
||||
}
|
||||
|
||||
// ============================== Demand Index ==============================
|
||||
|
||||
#[pyclass(name = "DemandIndex", module = "wickra._wickra", skip_from_py_object)]
|
||||
#[derive(Clone)]
|
||||
struct PyDemandIndex {
|
||||
inner: wc::DemandIndex,
|
||||
}
|
||||
|
||||
#[pymethods]
|
||||
impl PyDemandIndex {
|
||||
#[new]
|
||||
#[pyo3(signature = (period=10))]
|
||||
fn new(period: usize) -> PyResult<Self> {
|
||||
Ok(Self {
|
||||
inner: wc::DemandIndex::new(period).map_err(map_err)?,
|
||||
})
|
||||
}
|
||||
fn update(&mut self, candle: &Bound<'_, PyAny>) -> PyResult<Option<f64>> {
|
||||
let c = extract_candle(candle)?;
|
||||
Ok(self.inner.update(c))
|
||||
}
|
||||
/// Batch over high/low/close/volume numpy columns.
|
||||
fn batch<'py>(
|
||||
&mut self,
|
||||
py: Python<'py>,
|
||||
high: PyReadonlyArray1<'py, f64>,
|
||||
low: PyReadonlyArray1<'py, f64>,
|
||||
close: PyReadonlyArray1<'py, f64>,
|
||||
volume: PyReadonlyArray1<'py, f64>,
|
||||
) -> PyResult<Bound<'py, PyArray1<f64>>> {
|
||||
let h = high
|
||||
.as_slice()
|
||||
.map_err(|_| PyValueError::new_err(NON_CONTIGUOUS))?;
|
||||
let l = low
|
||||
.as_slice()
|
||||
.map_err(|_| PyValueError::new_err(NON_CONTIGUOUS))?;
|
||||
let c = close
|
||||
.as_slice()
|
||||
.map_err(|_| PyValueError::new_err(NON_CONTIGUOUS))?;
|
||||
let v = volume
|
||||
.as_slice()
|
||||
.map_err(|_| PyValueError::new_err(NON_CONTIGUOUS))?;
|
||||
if h.len() != l.len() || l.len() != c.len() || c.len() != v.len() {
|
||||
return Err(PyValueError::new_err(
|
||||
"high, low, close, volume must be equal length",
|
||||
));
|
||||
}
|
||||
let mut out = Vec::with_capacity(h.len());
|
||||
for i in 0..h.len() {
|
||||
let candle = wc::Candle::new(c[i], h[i], l[i], c[i], v[i], 0).map_err(map_err)?;
|
||||
out.push(self.inner.update(candle).unwrap_or(f64::NAN));
|
||||
}
|
||||
Ok(out.into_pyarray(py))
|
||||
}
|
||||
#[getter]
|
||||
fn period(&self) -> usize {
|
||||
self.inner.period()
|
||||
}
|
||||
fn reset(&mut self) {
|
||||
self.inner.reset();
|
||||
}
|
||||
fn is_ready(&self) -> bool {
|
||||
self.inner.is_ready()
|
||||
}
|
||||
fn warmup_period(&self) -> usize {
|
||||
self.inner.warmup_period()
|
||||
}
|
||||
fn __repr__(&self) -> String {
|
||||
format!("DemandIndex(period={})", self.inner.period())
|
||||
}
|
||||
}
|
||||
|
||||
// ============================== Time Segmented Volume ==============================
|
||||
|
||||
#[pyclass(name = "TSV", module = "wickra._wickra", skip_from_py_object)]
|
||||
#[derive(Clone)]
|
||||
struct PyTsv {
|
||||
inner: wc::Tsv,
|
||||
}
|
||||
|
||||
#[pymethods]
|
||||
impl PyTsv {
|
||||
#[new]
|
||||
#[pyo3(signature = (period=18))]
|
||||
fn new(period: usize) -> PyResult<Self> {
|
||||
Ok(Self {
|
||||
inner: wc::Tsv::new(period).map_err(map_err)?,
|
||||
})
|
||||
}
|
||||
fn update(&mut self, candle: &Bound<'_, PyAny>) -> PyResult<Option<f64>> {
|
||||
let c = extract_candle(candle)?;
|
||||
Ok(self.inner.update(c))
|
||||
}
|
||||
/// Batch over close + volume numpy columns.
|
||||
fn batch<'py>(
|
||||
&mut self,
|
||||
py: Python<'py>,
|
||||
close: PyReadonlyArray1<'py, f64>,
|
||||
volume: PyReadonlyArray1<'py, f64>,
|
||||
) -> PyResult<Bound<'py, PyArray1<f64>>> {
|
||||
let c = close
|
||||
.as_slice()
|
||||
.map_err(|_| PyValueError::new_err(NON_CONTIGUOUS))?;
|
||||
let v = volume
|
||||
.as_slice()
|
||||
.map_err(|_| PyValueError::new_err(NON_CONTIGUOUS))?;
|
||||
if c.len() != v.len() {
|
||||
return Err(PyValueError::new_err(
|
||||
"close and volume must be equal length",
|
||||
));
|
||||
}
|
||||
let mut out = Vec::with_capacity(c.len());
|
||||
for i in 0..c.len() {
|
||||
let candle = wc::Candle::new(c[i], c[i], c[i], c[i], v[i], 0).map_err(map_err)?;
|
||||
out.push(self.inner.update(candle).unwrap_or(f64::NAN));
|
||||
}
|
||||
Ok(out.into_pyarray(py))
|
||||
}
|
||||
#[getter]
|
||||
fn period(&self) -> usize {
|
||||
self.inner.period()
|
||||
}
|
||||
fn reset(&mut self) {
|
||||
self.inner.reset();
|
||||
}
|
||||
fn is_ready(&self) -> bool {
|
||||
self.inner.is_ready()
|
||||
}
|
||||
fn warmup_period(&self) -> usize {
|
||||
self.inner.warmup_period()
|
||||
}
|
||||
fn __repr__(&self) -> String {
|
||||
format!("TSV(period={})", self.inner.period())
|
||||
}
|
||||
}
|
||||
|
||||
// ============================== Volume Zone Oscillator ==============================
|
||||
|
||||
#[pyclass(name = "VZO", module = "wickra._wickra", skip_from_py_object)]
|
||||
#[derive(Clone)]
|
||||
struct PyVzo {
|
||||
inner: wc::Vzo,
|
||||
}
|
||||
|
||||
#[pymethods]
|
||||
impl PyVzo {
|
||||
#[new]
|
||||
#[pyo3(signature = (period=14))]
|
||||
fn new(period: usize) -> PyResult<Self> {
|
||||
Ok(Self {
|
||||
inner: wc::Vzo::new(period).map_err(map_err)?,
|
||||
})
|
||||
}
|
||||
fn update(&mut self, candle: &Bound<'_, PyAny>) -> PyResult<Option<f64>> {
|
||||
let c = extract_candle(candle)?;
|
||||
Ok(self.inner.update(c))
|
||||
}
|
||||
/// Batch over close + volume numpy columns.
|
||||
fn batch<'py>(
|
||||
&mut self,
|
||||
py: Python<'py>,
|
||||
close: PyReadonlyArray1<'py, f64>,
|
||||
volume: PyReadonlyArray1<'py, f64>,
|
||||
) -> PyResult<Bound<'py, PyArray1<f64>>> {
|
||||
let c = close
|
||||
.as_slice()
|
||||
.map_err(|_| PyValueError::new_err(NON_CONTIGUOUS))?;
|
||||
let v = volume
|
||||
.as_slice()
|
||||
.map_err(|_| PyValueError::new_err(NON_CONTIGUOUS))?;
|
||||
if c.len() != v.len() {
|
||||
return Err(PyValueError::new_err(
|
||||
"close and volume must be equal length",
|
||||
));
|
||||
}
|
||||
let mut out = Vec::with_capacity(c.len());
|
||||
for i in 0..c.len() {
|
||||
let candle = wc::Candle::new(c[i], c[i], c[i], c[i], v[i], 0).map_err(map_err)?;
|
||||
out.push(self.inner.update(candle).unwrap_or(f64::NAN));
|
||||
}
|
||||
Ok(out.into_pyarray(py))
|
||||
}
|
||||
#[getter]
|
||||
fn period(&self) -> usize {
|
||||
self.inner.period()
|
||||
}
|
||||
fn reset(&mut self) {
|
||||
self.inner.reset();
|
||||
}
|
||||
fn is_ready(&self) -> bool {
|
||||
self.inner.is_ready()
|
||||
}
|
||||
fn warmup_period(&self) -> usize {
|
||||
self.inner.warmup_period()
|
||||
}
|
||||
fn __repr__(&self) -> String {
|
||||
format!("VZO(period={})", self.inner.period())
|
||||
}
|
||||
}
|
||||
|
||||
// ============================== Market Facilitation Index ==============================
|
||||
|
||||
#[pyclass(
|
||||
name = "MarketFacilitationIndex",
|
||||
module = "wickra._wickra",
|
||||
skip_from_py_object
|
||||
)]
|
||||
#[derive(Clone)]
|
||||
struct PyMarketFacilitationIndex {
|
||||
inner: wc::MarketFacilitationIndex,
|
||||
}
|
||||
|
||||
#[pymethods]
|
||||
impl PyMarketFacilitationIndex {
|
||||
#[new]
|
||||
fn new() -> Self {
|
||||
Self {
|
||||
inner: wc::MarketFacilitationIndex::new(),
|
||||
}
|
||||
}
|
||||
fn update(&mut self, candle: &Bound<'_, PyAny>) -> PyResult<Option<f64>> {
|
||||
let c = extract_candle(candle)?;
|
||||
Ok(self.inner.update(c))
|
||||
}
|
||||
/// Batch over high/low/volume numpy columns.
|
||||
fn batch<'py>(
|
||||
&mut self,
|
||||
py: Python<'py>,
|
||||
high: PyReadonlyArray1<'py, f64>,
|
||||
low: PyReadonlyArray1<'py, f64>,
|
||||
volume: PyReadonlyArray1<'py, f64>,
|
||||
) -> PyResult<Bound<'py, PyArray1<f64>>> {
|
||||
let h = high
|
||||
.as_slice()
|
||||
.map_err(|_| PyValueError::new_err(NON_CONTIGUOUS))?;
|
||||
let l = low
|
||||
.as_slice()
|
||||
.map_err(|_| PyValueError::new_err(NON_CONTIGUOUS))?;
|
||||
let v = volume
|
||||
.as_slice()
|
||||
.map_err(|_| PyValueError::new_err(NON_CONTIGUOUS))?;
|
||||
if h.len() != l.len() || l.len() != v.len() {
|
||||
return Err(PyValueError::new_err(
|
||||
"high, low, volume must be equal length",
|
||||
));
|
||||
}
|
||||
let mut out = Vec::with_capacity(h.len());
|
||||
for i in 0..h.len() {
|
||||
let candle = wc::Candle::new(l[i], h[i], l[i], l[i], v[i], 0).map_err(map_err)?;
|
||||
out.push(self.inner.update(candle).unwrap_or(f64::NAN));
|
||||
}
|
||||
Ok(out.into_pyarray(py))
|
||||
}
|
||||
fn reset(&mut self) {
|
||||
self.inner.reset();
|
||||
}
|
||||
fn is_ready(&self) -> bool {
|
||||
self.inner.is_ready()
|
||||
}
|
||||
fn warmup_period(&self) -> usize {
|
||||
self.inner.warmup_period()
|
||||
}
|
||||
fn __repr__(&self) -> String {
|
||||
"MarketFacilitationIndex()".to_string()
|
||||
}
|
||||
}
|
||||
|
||||
// ============================== Ease of Movement ==============================
|
||||
|
||||
#[pyclass(
|
||||
@@ -7036,6 +7689,16 @@ fn _wickra(_py: Python<'_>, m: &Bound<'_, PyModule>) -> PyResult<()> {
|
||||
m.add_class::<PyChaikinMoneyFlow>()?;
|
||||
m.add_class::<PyChaikinOscillator>()?;
|
||||
m.add_class::<PyForceIndex>()?;
|
||||
m.add_class::<PyKvo>()?;
|
||||
m.add_class::<PyVolumeOscillator>()?;
|
||||
m.add_class::<PyNvi>()?;
|
||||
m.add_class::<PyPvi>()?;
|
||||
m.add_class::<PyAdOscillator>()?;
|
||||
m.add_class::<PyAnchoredVwap>()?;
|
||||
m.add_class::<PyDemandIndex>()?;
|
||||
m.add_class::<PyTsv>()?;
|
||||
m.add_class::<PyVzo>()?;
|
||||
m.add_class::<PyMarketFacilitationIndex>()?;
|
||||
m.add_class::<PyEaseOfMovement>()?;
|
||||
m.add_class::<PySuperTrend>()?;
|
||||
m.add_class::<PyChandelierExit>()?;
|
||||
|
||||
@@ -155,6 +155,46 @@ CANDLE_SCALAR = {
|
||||
lambda: ta.EaseOfMovement(14),
|
||||
lambda ind, h, l, c, v: ind.batch(h, l, v),
|
||||
),
|
||||
"KVO": (
|
||||
lambda: ta.KVO(34, 55),
|
||||
lambda ind, h, l, c, v: ind.batch(h, l, c, v),
|
||||
),
|
||||
"VolumeOscillator": (
|
||||
lambda: ta.VolumeOscillator(14, 28),
|
||||
lambda ind, h, l, c, v: ind.batch(v),
|
||||
),
|
||||
"NVI": (
|
||||
lambda: ta.NVI(),
|
||||
lambda ind, h, l, c, v: ind.batch(c, v),
|
||||
),
|
||||
"PVI": (
|
||||
lambda: ta.PVI(),
|
||||
lambda ind, h, l, c, v: ind.batch(c, v),
|
||||
),
|
||||
"WilliamsAD": (
|
||||
lambda: ta.WilliamsAD(),
|
||||
lambda ind, h, l, c, v: ind.batch(h, l, c),
|
||||
),
|
||||
"AnchoredVWAP": (
|
||||
lambda: ta.AnchoredVWAP(),
|
||||
lambda ind, h, l, c, v: ind.batch(h, l, c, v),
|
||||
),
|
||||
"DemandIndex": (
|
||||
lambda: ta.DemandIndex(10),
|
||||
lambda ind, h, l, c, v: ind.batch(h, l, c, v),
|
||||
),
|
||||
"TSV": (
|
||||
lambda: ta.TSV(18),
|
||||
lambda ind, h, l, c, v: ind.batch(c, v),
|
||||
),
|
||||
"VZO": (
|
||||
lambda: ta.VZO(14),
|
||||
lambda ind, h, l, c, v: ind.batch(c, v),
|
||||
),
|
||||
"MarketFacilitationIndex": (
|
||||
lambda: ta.MarketFacilitationIndex(),
|
||||
lambda ind, h, l, c, v: ind.batch(h, l, v),
|
||||
),
|
||||
"AtrTrailingStop": (
|
||||
lambda: ta.AtrTrailingStop(14, 3.0),
|
||||
lambda ind, h, l, c, v: ind.batch(h, l, c),
|
||||
@@ -463,6 +503,138 @@ def test_weighted_close_reference():
|
||||
)
|
||||
|
||||
|
||||
def test_nvi_reference():
|
||||
# closes [10, 11], volumes [200, 100]: volume contracts -> NVI absorbs +10%.
|
||||
# 1000 * (1 + 0.1) = 1100.
|
||||
nvi = ta.NVI()
|
||||
out = nvi.batch(np.array([10.0, 11.0]), np.array([200.0, 100.0]))
|
||||
assert out[0] == pytest.approx(1000.0)
|
||||
assert out[1] == pytest.approx(1100.0)
|
||||
|
||||
|
||||
def test_pvi_reference():
|
||||
# closes [10, 11], volumes [100, 200]: volume expands -> PVI absorbs +10%.
|
||||
pvi = ta.PVI()
|
||||
out = pvi.batch(np.array([10.0, 11.0]), np.array([100.0, 200.0]))
|
||||
assert out[0] == pytest.approx(1000.0)
|
||||
assert out[1] == pytest.approx(1100.0)
|
||||
|
||||
|
||||
def test_volume_oscillator_reference():
|
||||
# fast=2, slow=4 over volumes [10, 20, 30, 40, 50]:
|
||||
# bar 4 -> fast=(30+40)/2=35, slow=(10+20+30+40)/4=25 -> VO = 100*(35-25)/25 = 40.
|
||||
vo = ta.VolumeOscillator(2, 4)
|
||||
out = vo.batch(np.array([10.0, 20.0, 30.0, 40.0, 50.0]))
|
||||
assert math.isnan(out[2])
|
||||
assert out[3] == pytest.approx(40.0)
|
||||
assert out[4] == pytest.approx(1000.0 / 35.0)
|
||||
|
||||
|
||||
def test_kvo_constant_series_is_zero():
|
||||
# A flat series produces dm with no sign change; vf collapses to 0 every
|
||||
# bar and both EMAs hold at 0, so the KVO line stays at 0.
|
||||
kvo = ta.KVO(3, 6)
|
||||
high = np.full(60, 10.0)
|
||||
low = np.full(60, 10.0)
|
||||
close = np.full(60, 10.0)
|
||||
volume = np.full(60, 100.0)
|
||||
out = kvo.batch(high, low, close, volume)
|
||||
for v in out[~np.isnan(out)]:
|
||||
assert v == pytest.approx(0.0, abs=1e-12)
|
||||
|
||||
|
||||
def test_williams_ad_reference():
|
||||
# bar 0 seeds prev_close = 10.
|
||||
# bar 1: prev=10, today high=13, low=8, close=12 (up day).
|
||||
# TR_l = min(10, 8) = 8 -> delta = 12 - 8 = 4. AD = 4.
|
||||
# bar 2: prev=12, today high=11, low=7, close=7 (down day).
|
||||
# TR_h = max(12, 11) = 12 -> delta = 7 - 12 = -5. AD = 4 - 5 = -1.
|
||||
ad = ta.WilliamsAD()
|
||||
high = np.array([11.0, 13.0, 11.0])
|
||||
low = np.array([9.0, 8.0, 7.0])
|
||||
close = np.array([10.0, 12.0, 7.0])
|
||||
out = ad.batch(high, low, close)
|
||||
assert math.isnan(out[0])
|
||||
assert out[1] == pytest.approx(4.0)
|
||||
assert out[2] == pytest.approx(-1.0)
|
||||
|
||||
|
||||
def test_anchored_vwap_reference():
|
||||
# Three flat-OHLC bars: typical_price equals price.
|
||||
# 10@1, 20@1, 30@1 -> mean = 20.
|
||||
avwap = ta.AnchoredVWAP()
|
||||
high = np.array([10.0, 20.0, 30.0])
|
||||
low = np.array([10.0, 20.0, 30.0])
|
||||
close = np.array([10.0, 20.0, 30.0])
|
||||
volume = np.array([1.0, 1.0, 1.0])
|
||||
out = avwap.batch(high, low, close, volume)
|
||||
assert out[2] == pytest.approx(20.0)
|
||||
|
||||
|
||||
def test_anchored_vwap_set_anchor_clears_window():
|
||||
# Drive a few flat bars, re-anchor, then drive a high-priced bar:
|
||||
# the new running mean must equal the new bar's typical price.
|
||||
avwap = ta.AnchoredVWAP()
|
||||
for _ in range(3):
|
||||
avwap.update((10.0, 10.0, 10.0, 10.0, 1.0, 0))
|
||||
assert avwap.is_ready()
|
||||
avwap.set_anchor()
|
||||
v = avwap.update((100.0, 100.0, 100.0, 100.0, 5.0, 1))
|
||||
assert v == pytest.approx(100.0)
|
||||
|
||||
|
||||
def test_tsv_reference():
|
||||
# closes = [10, 11, 13, 12, 14, 15]
|
||||
# volumes = [50, 100, 200, 150, 50, 200]
|
||||
# flows = [None, 1*100=100, 2*200=400, -1*150=-150, 2*50=100, 1*200=200]
|
||||
# period=3: first emission at index 3.
|
||||
# bar 3 window=[100,400,-150] -> 350
|
||||
# bar 4 window=[400,-150,100] -> 350
|
||||
# bar 5 window=[-150,100,200] -> 150
|
||||
tsv = ta.TSV(3)
|
||||
close = np.array([10.0, 11.0, 13.0, 12.0, 14.0, 15.0])
|
||||
volume = np.array([50.0, 100.0, 200.0, 150.0, 50.0, 200.0])
|
||||
out = tsv.batch(close, volume)
|
||||
assert math.isnan(out[0]) and math.isnan(out[1]) and math.isnan(out[2])
|
||||
assert out[3] == pytest.approx(350.0)
|
||||
assert out[4] == pytest.approx(350.0)
|
||||
assert out[5] == pytest.approx(150.0)
|
||||
|
||||
|
||||
def test_vzo_strictly_rising_saturates_to_plus_100():
|
||||
# Every bar is an up-day with identical volume -> signed_volume == volume,
|
||||
# so the smoothed signed-volume EMA equals the smoothed total-volume EMA,
|
||||
# giving a ratio of 1 -> VZO = +100.
|
||||
vzo = ta.VZO(5)
|
||||
close = np.array([10.0 + i for i in range(60)])
|
||||
volume = np.full(60, 100.0)
|
||||
out = vzo.batch(close, volume)
|
||||
last = out[~np.isnan(out)][-1]
|
||||
assert last == pytest.approx(100.0)
|
||||
|
||||
|
||||
def test_market_facilitation_index_reference():
|
||||
# (high - low) / volume = (12 - 8) / 200 = 0.02.
|
||||
mfi_bw = ta.MarketFacilitationIndex()
|
||||
high = np.array([12.0])
|
||||
low = np.array([8.0])
|
||||
volume = np.array([200.0])
|
||||
out = mfi_bw.batch(high, low, volume)
|
||||
assert out[0] == pytest.approx(0.02)
|
||||
|
||||
|
||||
def test_demand_index_constant_series_is_zero():
|
||||
# Flat close -> pressure = 0 every bar -> EMA stays at 0.
|
||||
di = ta.DemandIndex(5)
|
||||
high = np.full(60, 10.0)
|
||||
low = np.full(60, 10.0)
|
||||
close = np.full(60, 10.0)
|
||||
volume = np.full(60, 100.0)
|
||||
out = di.batch(high, low, close, volume)
|
||||
for v in out[~np.isnan(out)]:
|
||||
assert v == pytest.approx(0.0, abs=1e-12)
|
||||
|
||||
|
||||
def test_chaikin_money_flow_reference():
|
||||
cmf = ta.ChaikinMoneyFlow(2)
|
||||
assert cmf.update((8.0, 10.0, 8.0, 10.0, 100.0, 0)) is None
|
||||
|
||||
Reference in New Issue
Block a user