feat: Family 02 Momentum Oscillators — RVI / PGO / KST / SMI / Laguerre / Connors / Inertia (#40)

* feat(rvi): add Relative Vigor Index

Dorsey's RVI = SMA(close - open, period) / SMA(high - low, period) over
a rolling window of period candles. Candle input, single parameter
period (default 10). Positive on average-bullish windows, negative on
average-bearish. Holds the previous value if the entire window has
zero range (denominator undefined).

Reference: Donald Dorsey, also pandas-ta rvi.

Touchpoints: rvi.rs + mod.rs + lib.rs re-export, PyRvi + __init__.py
+ test_new_indicators CANDLE_SCALAR + test_known_values reference,
RviNode (4-column OHLC batch) + index.d.ts/index.js + indicators.test
.js factory + reference, WasmRvi + make_candle_ohlc helper, candle-fuzz
target + criterion bench, README + CHANGELOG.

* feat(pgo): add Pretty Good Oscillator

Mark Johnson's PGO = (close - SMA(close, period)) / EMA(TR, period).
Counts roughly how many ATR-equivalents the close sits from its
period-bar mean. Candle input, single parameter period (default 14).
Johnson's heuristic uses +3/-3 crossings as entry signals.

Touchpoints: pgo.rs + mod.rs + lib.rs re-export, PyPgo + __init__.py
+ test_new_indicators CANDLE_SCALAR + test_known_values flat-close
reference, PgoNode (h/l/c) + index.d.ts/index.js + indicators.test.js
factory + reference, WasmPgo, candle-fuzz target + bench, README +
CHANGELOG.

* feat(kst): add Know Sure Thing (Pring)

Pring's long-horizon momentum oscillator: weighted sum of four
SMA-smoothed ROC series with fixed weights 1, 2, 3, 4, plus an SMA
signal line. Nine parameters (four ROC periods, four SMA periods, one
signal period); classic() applies Pring's recommended defaults.
Multi-output indicator emitting KstOutput { kst, signal }.

Touchpoints: kst.rs + mod.rs + lib.rs re-export, PyKst + __init__.py
+ test_new_indicators MULTI + test_known_values flat-input reference,
KstNode + KstValue + index.d.ts/index.js + indicators.test.js multi
factory + reference, WasmKst (manual JsValue object), scalar-fuzz
target (handled outside the f64-output drive helper), README +
CHANGELOG.

* feat(smi): add Stochastic Momentum Index (Blau)

Blau's doubly-EMA-smoothed bounded oscillator: measures the close's
displacement from the centre of the recent high-low range, scaled by
the smoothed range. Candle input, three parameters (period, d_period,
d2_period) with defaults 5 / 3 / 3.

Internally feeds both the displacement-EMA stack and the range-EMA
stack on every candle so they warm up in parallel (gating either
behind the other starves the second by one input).

Touchpoints: smi.rs + mod.rs + lib.rs re-export, PySmi + __init__.py
+ test_new_indicators CANDLE_SCALAR + test_known_values flat-input
reference, SmiNode + index.d.ts/index.js + indicators.test.js factory
+ reference, WasmSmi, candle-fuzz target, README + CHANGELOG.

* feat(laguerre-rsi): add Ehlers Laguerre RSI

Four-stage Laguerre polynomial filter wrapped in an RSI-style up/down
accumulator. Single gamma in [0, 1] (default 0.5) trades lag for
smoothness. State is seeded by setting all four L_i to the first input
so a constant series stays at the neutral 50. Output clamped to
[0, 100] to absorb floating-point rounding.

Reference: Ehlers, Time Warp - Without Space Travel, 2002.

Touchpoints: laguerre_rsi.rs + mod.rs + lib.rs re-export, PyLaguerreRsi
+ __init__.py + test_new_indicators SCALAR + test_known_values neutral
reference, LaguerreRsiNode + index.d.ts/index.js + indicators.test.js
factory + reference, WasmLaguerreRsi via scalar macro, scalar-fuzz
target, README + CHANGELOG.

* feat(connors-rsi): add Connors RSI (CRSI)

Larry Connors' 3-component aggregate: RSI(close), RSI(streak), and
PercentRank of the 1-period return over the last period_rank returns.
Each component is bounded in [0, 100] so the aggregate is too.
Three parameters (period_rsi, period_streak, period_rank) with
defaults 3 / 2 / 100. Streak tracks consecutive up/down runs (resets
to 0 on unchanged close).

Touchpoints: connors_rsi.rs + mod.rs + lib.rs re-export, PyConnorsRsi
+ __init__.py + test_new_indicators SCALAR + test_known_values bounded
reference, ConnorsRsiNode + index.d.ts/index.js + indicators.test.js
factory + reference, WasmConnorsRsi via scalar macro, scalar-fuzz
target, README + CHANGELOG.

* feat(inertia): add Dorsey Inertia (RVI + LinReg)

Donald Dorsey's Inertia — a LinearRegression smoothing of the RVI
series. Endpoint of an n-bar least-squares fit of RVI is the indicator
reading. Preserves trend direction while damping the ratio. Candle
input, two parameters (rvi_period, linreg_period) with defaults 14 / 20.

Touchpoints: inertia.rs + mod.rs + lib.rs re-export, PyInertia +
__init__.py + test_new_indicators CANDLE_SCALAR + test_known_values
constant reference, InertiaNode (4-column OHLC batch) + index.d.ts /
index.js + indicators.test.js factory + reference, WasmInertia,
candle-fuzz target, README + CHANGELOG.

* test(kst): Move KST out of MULTI dict (it is scalar-input)

KST sits in the MULTI dict (candle-input, multi-output) but its
update() takes a single f64, not a candle tuple. The shared streaming
loop in test_multi_streaming_matches_batch fed the OHLCV tuple in,
which crashed with `TypeError: argument 'value': must be real number,
not tuple` on every Python matrix entry.

Split into a new MULTI_SCALAR_INPUT dict with its own test function
that feeds the close-price stream as floats. KST is currently the
only such indicator; structure is ready for future scalar-input
multi-output additions (e.g. some MACD-shaped indicators).

* test(coverage): Cover SMI zero-range and ConnorsRsi zero-prev cold paths

codecov/patch on PR 40 flagged two uncovered defensive branches:
- SMI returns self.current early when the smoothed range collapses to
  zero (`r2 <= 0.0`) so the formula stays defined. Exercised by feeding
  bars where high == low.
- ConnorsRsi skips the ROC ring-buffer update when the previous price
  is exactly zero so the divide-by-zero in `(input - prev) / prev` is
  impossible. Exercised by seeding the first bar at 0.0.
This commit is contained in:
kingchenc
2026-05-25 15:28:56 +02:00
committed by GitHub
parent a39adb9dae
commit 24e723fa7d
22 changed files with 3185 additions and 25 deletions
+14
View File
@@ -63,6 +63,13 @@ from ._wickra import (
PMO,
StochRSI,
UltimateOscillator,
RVI,
PGO,
KST,
SMI,
LaguerreRSI,
ConnorsRSI,
Inertia,
PPO,
DPO,
Coppock,
@@ -151,6 +158,13 @@ __all__ = [
"PMO",
"StochRSI",
"UltimateOscillator",
"RVI",
"PGO",
"KST",
"SMI",
"LaguerreRSI",
"ConnorsRSI",
"Inertia",
"PPO",
"DPO",
"Coppock",
+443 -7
View File
@@ -812,6 +812,435 @@ impl PyKama {
}
}
// ============================== Inertia ==============================
#[pyclass(name = "Inertia", module = "wickra._wickra", skip_from_py_object)]
#[derive(Clone)]
struct PyInertia {
inner: wc::Inertia,
}
#[pymethods]
impl PyInertia {
#[new]
#[pyo3(signature = (rvi_period=14, linreg_period=20))]
fn new(rvi_period: usize, linreg_period: usize) -> PyResult<Self> {
Ok(Self {
inner: wc::Inertia::new(rvi_period, linreg_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))
}
fn batch<'py>(
&mut self,
py: Python<'py>,
open: PyReadonlyArray1<'py, f64>,
high: PyReadonlyArray1<'py, f64>,
low: PyReadonlyArray1<'py, f64>,
close: PyReadonlyArray1<'py, f64>,
) -> PyResult<Bound<'py, PyArray1<f64>>> {
let o = open
.as_slice()
.map_err(|_| PyValueError::new_err(NON_CONTIGUOUS))?;
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 !(o.len() == h.len() && h.len() == l.len() && l.len() == c.len()) {
return Err(PyValueError::new_err(
"open, high, low and close must be equal length",
));
}
let mut out = Vec::with_capacity(c.len());
for i in 0..c.len() {
let candle = wc::Candle::new(o[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 {
let (r, l) = self.inner.periods();
format!("Inertia(rvi_period={r}, linreg_period={l})")
}
}
// ============================== Connors RSI ==============================
#[pyclass(name = "ConnorsRSI", module = "wickra._wickra", skip_from_py_object)]
#[derive(Clone)]
struct PyConnorsRsi {
inner: wc::ConnorsRsi,
}
#[pymethods]
impl PyConnorsRsi {
#[new]
#[pyo3(signature = (period_rsi=3, period_streak=2, period_rank=100))]
fn new(period_rsi: usize, period_streak: usize, period_rank: usize) -> PyResult<Self> {
Ok(Self {
inner: wc::ConnorsRsi::new(period_rsi, period_streak, period_rank).map_err(map_err)?,
})
}
fn update(&mut self, value: f64) -> Option<f64> {
self.inner.update(value)
}
fn batch<'py>(
&mut self,
py: Python<'py>,
prices: PyReadonlyArray1<'py, f64>,
) -> PyResult<Bound<'py, PyArray1<f64>>> {
let s = prices
.as_slice()
.map_err(|_| PyValueError::new_err(NON_CONTIGUOUS))?;
Ok(flatten(self.inner.batch(s)).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 {
let (r, s, k) = self.inner.periods();
format!("ConnorsRSI(period_rsi={r}, period_streak={s}, period_rank={k})")
}
}
// ============================== Laguerre RSI ==============================
#[pyclass(name = "LaguerreRSI", module = "wickra._wickra", skip_from_py_object)]
#[derive(Clone)]
struct PyLaguerreRsi {
inner: wc::LaguerreRsi,
}
#[pymethods]
impl PyLaguerreRsi {
#[new]
#[pyo3(signature = (gamma=0.5))]
fn new(gamma: f64) -> PyResult<Self> {
Ok(Self {
inner: wc::LaguerreRsi::new(gamma).map_err(map_err)?,
})
}
fn update(&mut self, value: f64) -> Option<f64> {
self.inner.update(value)
}
fn batch<'py>(
&mut self,
py: Python<'py>,
prices: PyReadonlyArray1<'py, f64>,
) -> PyResult<Bound<'py, PyArray1<f64>>> {
let s = prices
.as_slice()
.map_err(|_| PyValueError::new_err(NON_CONTIGUOUS))?;
Ok(flatten(self.inner.batch(s)).into_pyarray(py))
}
#[getter]
fn gamma(&self) -> f64 {
self.inner.gamma()
}
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!("LaguerreRSI(gamma={})", self.inner.gamma())
}
}
// ============================== SMI ==============================
#[pyclass(name = "SMI", module = "wickra._wickra", skip_from_py_object)]
#[derive(Clone)]
struct PySmi {
inner: wc::Smi,
}
#[pymethods]
impl PySmi {
#[new]
#[pyo3(signature = (period=5, d_period=3, d2_period=3))]
fn new(period: usize, d_period: usize, d2_period: usize) -> PyResult<Self> {
Ok(Self {
inner: wc::Smi::new(period, d_period, d2_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))
}
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 and close 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], 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 {
let (p, d, d2) = self.inner.periods();
format!("SMI(period={p}, d_period={d}, d2_period={d2})")
}
}
// ============================== KST ==============================
#[pyclass(name = "KST", module = "wickra._wickra", skip_from_py_object)]
#[derive(Clone)]
struct PyKst {
inner: wc::Kst,
}
#[pymethods]
impl PyKst {
#[new]
#[pyo3(signature = (roc1=10, roc2=15, roc3=20, roc4=30, sma1=10, sma2=10, sma3=10, sma4=15, signal=9))]
#[allow(clippy::too_many_arguments)]
fn new(
roc1: usize,
roc2: usize,
roc3: usize,
roc4: usize,
sma1: usize,
sma2: usize,
sma3: usize,
sma4: usize,
signal: usize,
) -> PyResult<Self> {
Ok(Self {
inner: wc::Kst::new(roc1, roc2, roc3, roc4, sma1, sma2, sma3, sma4, signal)
.map_err(map_err)?,
})
}
fn update(&mut self, value: f64) -> Option<(f64, f64)> {
self.inner.update(value).map(|o| (o.kst, o.signal))
}
fn batch<'py>(
&mut self,
py: Python<'py>,
prices: PyReadonlyArray1<'py, f64>,
) -> PyResult<Bound<'py, PyArray2<f64>>> {
let slice = prices
.as_slice()
.map_err(|_| PyValueError::new_err(NON_CONTIGUOUS))?;
let n = slice.len();
let mut out = vec![f64::NAN; n * 2];
for (i, p) in slice.iter().enumerate() {
if let Some(o) = self.inner.update(*p) {
out[i * 2] = o.kst;
out[i * 2 + 1] = o.signal;
}
}
Ok(numpy::ndarray::Array2::from_shape_vec((n, 2), out)
.expect("shape consistent")
.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 {
"KST".to_string()
}
}
// ============================== PGO ==============================
#[pyclass(name = "PGO", module = "wickra._wickra", skip_from_py_object)]
#[derive(Clone)]
struct PyPgo {
inner: wc::Pgo,
}
#[pymethods]
impl PyPgo {
#[new]
#[pyo3(signature = (period=14))]
fn new(period: usize) -> PyResult<Self> {
Ok(Self {
inner: wc::Pgo::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))
}
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 and close 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], 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))
}
#[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!("PGO(period={})", self.inner.period())
}
}
// ============================== RVI ==============================
#[pyclass(name = "RVI", module = "wickra._wickra", skip_from_py_object)]
#[derive(Clone)]
struct PyRvi {
inner: wc::Rvi,
}
#[pymethods]
impl PyRvi {
#[new]
#[pyo3(signature = (period=10))]
fn new(period: usize) -> PyResult<Self> {
Ok(Self {
inner: wc::Rvi::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))
}
fn batch<'py>(
&mut self,
py: Python<'py>,
open: PyReadonlyArray1<'py, f64>,
high: PyReadonlyArray1<'py, f64>,
low: PyReadonlyArray1<'py, f64>,
close: PyReadonlyArray1<'py, f64>,
) -> PyResult<Bound<'py, PyArray1<f64>>> {
let o = open
.as_slice()
.map_err(|_| PyValueError::new_err(NON_CONTIGUOUS))?;
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 !(o.len() == h.len() && h.len() == l.len() && l.len() == c.len()) {
return Err(PyValueError::new_err(
"open, high, low and close must be equal length",
));
}
let mut out = Vec::with_capacity(c.len());
for i in 0..c.len() {
let candle = wc::Candle::new(o[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))
}
#[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!("RVI(period={})", self.inner.period())
}
}
// ============================== FRAMA ==============================
#[pyclass(name = "FRAMA", module = "wickra._wickra", skip_from_py_object)]
@@ -4873,13 +5302,13 @@ fn _wickra(_py: Python<'_>, m: &Bound<'_, PyModule>) -> PyResult<()> {
m.add_class::<PyTema>()?;
m.add_class::<PyHma>()?;
m.add_class::<PyKama>()?;
m.add_class::<PyAlma>()?;
m.add_class::<PyMcGinleyDynamic>()?;
m.add_class::<PyFrama>()?;
m.add_class::<PyVidya>()?;
m.add_class::<PyJma>()?;
m.add_class::<PyAlligator>()?;
m.add_class::<PyEvwma>()?;
m.add_class::<PyRvi>()?;
m.add_class::<PyPgo>()?;
m.add_class::<PyKst>()?;
m.add_class::<PySmi>()?;
m.add_class::<PyLaguerreRsi>()?;
m.add_class::<PyConnorsRsi>()?;
m.add_class::<PyInertia>()?;
m.add_class::<PyCci>()?;
m.add_class::<PyRoc>()?;
m.add_class::<PyWilliamsR>()?;
@@ -4939,5 +5368,12 @@ fn _wickra(_py: Python<'_>, m: &Bound<'_, PyModule>) -> PyResult<()> {
m.add_class::<PyChaikinVolatility>()?;
m.add_class::<PyZScore>()?;
m.add_class::<PyLinRegAngle>()?;
m.add_class::<PyAlma>()?;
m.add_class::<PyFrama>()?;
m.add_class::<PyMcGinleyDynamic>()?;
m.add_class::<PyVidya>()?;
m.add_class::<PyJma>()?;
m.add_class::<PyAlligator>()?;
m.add_class::<PyEvwma>()?;
Ok(())
}
@@ -66,6 +66,83 @@ def test_rsi_wilder_textbook_first_value():
assert math.isclose(out[14], 70.464, abs_tol=0.05)
def test_inertia_constant_rvi_passes_through_linreg():
# Every bar identical (open, high, low, close) = (10, 11, 9, 10.5):
# RVI = (c-o) / (h-l) = 0.5 / 2 = 0.25 every bar. LinReg of a constant
# series equals that constant after warmup.
n = 60
out = ta.Inertia(3, 4).batch(
np.full(n, 10.0), np.full(n, 11.0), np.full(n, 9.0), np.full(n, 10.5)
)
# warmup_period = 3 + 4 - 1 = 6.
np.testing.assert_allclose(out[5:], 0.25, atol=1e-12)
def test_connors_rsi_output_is_bounded():
# CRSI is the average of three [0, 100] components, so the aggregate must
# also sit in [0, 100] after warmup.
prices = 100.0 + 20.0 * np.sin(np.linspace(0, 30, 250))
out = ta.ConnorsRSI(3, 2, 100).batch(prices.astype(np.float64))
ready = out[~np.isnan(out)]
assert ready.size > 0
assert ready.min() >= 0.0
assert ready.max() <= 100.0
def test_laguerre_rsi_constant_series_stays_at_mid_band():
# All four Laguerre stages seed to the first input, so subsequent flat
# inputs keep them equal and the up/down accumulator is 0 — Wickra maps
# that to the neutral 50.
out = ta.LaguerreRSI(0.5).batch(np.full(40, 42.0, dtype=np.float64))
np.testing.assert_allclose(out, 50.0, atol=1e-12)
def test_smi_close_at_centre_yields_zero():
# Close at the midpoint of a flat high/low range -> displacement is
# always zero -> SMI converges to 0.
n = 60
out = ta.SMI(5, 3, 3).batch(np.full(n, 11.0), np.full(n, 9.0), np.full(n, 10.0))
# warmup_period = 5 + 3 + 3 - 2 = 9.
np.testing.assert_allclose(out[8:], 0.0, atol=1e-12)
def test_kst_constant_series_yields_zero():
# ROC is zero on a flat input, so every RCMA is zero, so KST and its
# signal SMA are both zero after warmup.
kst = ta.KST(10, 15, 20, 30, 10, 10, 10, 15, 9)
out = kst.batch(np.full(80, 42.0, dtype=np.float64))
warmup = kst.warmup_period()
# Use NaN-safe comparison on the post-warmup tail.
tail = out[warmup - 1 :]
assert np.all(np.isfinite(tail))
np.testing.assert_allclose(tail, 0.0, atol=1e-12)
def test_pgo_flat_close_yields_zero():
# On a constant close the numerator (close SMA) is zero, so PGO emits 0
# regardless of the TR-EMA in the denominator.
n = 20
high = np.full(n, 11.0)
low = np.full(n, 9.0)
close = np.full(n, 10.0)
out = ta.PGO(5).batch(high, low, close)
assert np.all(np.isnan(out[:4]))
np.testing.assert_allclose(out[4:], 0.0, atol=1e-12)
def test_rvi_reference_value_period_2():
# Two bars: (open, high, low, close) = (10, 11, 9, 10.5), (10.5, 11.5, 10, 11).
# num = (0.5 + 0.5) = 1.0; den = (2.0 + 1.5) = 3.5; RVI = 1 / 3.5.
out = ta.RVI(2).batch(
np.array([10.0, 10.5]),
np.array([11.0, 11.5]),
np.array([9.0, 10.0]),
np.array([10.5, 11.0]),
)
assert math.isnan(out[0])
assert math.isclose(out[1], 1.0 / 3.5, abs_tol=1e-12)
def test_alma_constant_series_yields_the_constant():
# ALMA's Gaussian weights are normalised, so any constant series is
# reproduced exactly after warmup.
@@ -68,6 +68,8 @@ SCALAR = [
(ta.VerticalHorizontalFilter, (28,)),
(ta.ZScore, (20,)),
(ta.LinRegAngle, (14,)),
(ta.LaguerreRSI, (0.5,)),
(ta.ConnorsRSI, (3, 2, 100)),
]
@@ -92,6 +94,19 @@ def test_scalar_streaming_matches_batch(cls, args, sine_prices):
CANDLE_SCALAR = {
"VWMA": (lambda: ta.VWMA(20), lambda ind, h, l, c, v: ind.batch(c, v)),
"RVI": (
# extract_candle pulls the open price from index 0 of the tuple; the
# streaming test below already builds candles with open == close, so
# match that here by passing close as the open column.
lambda: ta.RVI(10),
lambda ind, h, l, c, v: ind.batch(c, h, l, c),
),
"Inertia": (
lambda: ta.Inertia(14, 20),
lambda ind, h, l, c, v: ind.batch(c, h, l, c),
),
"PGO": (lambda: ta.PGO(14), lambda ind, h, l, c, v: ind.batch(h, l, c)),
"SMI": (lambda: ta.SMI(5, 3, 3), lambda ind, h, l, c, v: ind.batch(h, l, c)),
"EVWMA": (lambda: ta.EVWMA(20), lambda ind, h, l, c, v: ind.batch(c, v)),
"UltimateOscillator": (
lambda: ta.UltimateOscillator(7, 14, 28),
@@ -207,6 +222,18 @@ MULTI = {
),
}
# --- Scalar-input, multi-output indicators --------------------------------
#
# Same shape contract as MULTI (batch returns (n, 2)) but streaming feeds a
# single float instead of a candle tuple.
MULTI_SCALAR_INPUT = {
"KST": (
lambda: ta.KST(10, 15, 20, 30, 10, 10, 10, 15, 9),
lambda ind, c: ind.batch(c),
),
}
@pytest.mark.parametrize("name", list(MULTI))
def test_multi_streaming_matches_batch(name, ohlcv):
@@ -232,6 +259,22 @@ def test_multi_streaming_matches_batch(name, ohlcv):
assert _eq_nan(batch, np.array(rows, dtype=np.float64)), f"{name} mismatch"
@pytest.mark.parametrize("name", list(MULTI_SCALAR_INPUT))
def test_multi_scalar_streaming_matches_batch(name, ohlcv):
_, _, close, _ = ohlcv
make, batch_call = MULTI_SCALAR_INPUT[name]
batch = batch_call(make(), close)
assert batch.shape == (close.size, 2)
streamer = make()
rows = []
for p in close:
v = streamer.update(float(p))
rows.append([math.nan, math.nan] if v is None else list(v))
assert _eq_nan(batch, np.array(rows, dtype=np.float64)), f"{name} mismatch"
# --- Alligator (3-tuple output) -------------------------------------------