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:
kingchenc
2026-05-25 19:15:22 +02:00
committed by GitHub
parent 6287bd48c1
commit 880a0e7430
23 changed files with 4155 additions and 38 deletions
+663
View File
@@ -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>()?;