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:
@@ -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
@@ -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) -------------------------------------------
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user