feat: add Anchored RSI to the momentum oscillators family (#144)
Cumulative Relative Strength Index whose averaging begins at a runtime-chosen anchor bar (set_anchor), the momentum counterpart to Anchored VWAP. Scalar f64 input, 0..=100 output; wired through core, Python, Node and WASM bindings, fuzz, benches, tests and docs. Indicator count 289 -> 290.
This commit is contained in:
@@ -47,6 +47,7 @@ from ._wickra import (
|
||||
EVWMA,
|
||||
# Momentum
|
||||
RSI,
|
||||
AnchoredRSI,
|
||||
MACD,
|
||||
Stochastic,
|
||||
CCI,
|
||||
@@ -359,6 +360,7 @@ __all__ = [
|
||||
"EVWMA",
|
||||
# Momentum
|
||||
"RSI",
|
||||
"AnchoredRSI",
|
||||
"MACD",
|
||||
"Stochastic",
|
||||
"CCI",
|
||||
|
||||
@@ -5052,6 +5052,58 @@ impl PyAdOscillator {
|
||||
}
|
||||
}
|
||||
|
||||
// ============================== Anchored RSI ==============================
|
||||
|
||||
#[pyclass(name = "AnchoredRSI", module = "wickra._wickra", skip_from_py_object)]
|
||||
#[derive(Clone)]
|
||||
struct PyAnchoredRsi {
|
||||
inner: wc::AnchoredRsi,
|
||||
}
|
||||
|
||||
#[pymethods]
|
||||
impl PyAnchoredRsi {
|
||||
#[new]
|
||||
fn new() -> Self {
|
||||
Self {
|
||||
inner: wc::AnchoredRsi::new(),
|
||||
}
|
||||
}
|
||||
fn update(&mut self, value: f64) -> Option<f64> {
|
||||
self.inner.update(value)
|
||||
}
|
||||
/// Re-anchor the cumulative window at the next bar that arrives.
|
||||
fn set_anchor(&mut self) {
|
||||
self.inner.set_anchor();
|
||||
}
|
||||
/// Batch over a close-price numpy column.
|
||||
fn batch<'py>(
|
||||
&mut self,
|
||||
py: Python<'py>,
|
||||
prices: PyReadonlyArray1<'py, f64>,
|
||||
) -> PyResult<Bound<'py, PyArray1<f64>>> {
|
||||
let slice = prices
|
||||
.as_slice()
|
||||
.map_err(|_| PyValueError::new_err(NON_CONTIGUOUS))?;
|
||||
Ok(flatten(self.inner.batch(slice)).into_pyarray(py))
|
||||
}
|
||||
#[getter]
|
||||
fn value(&self) -> Option<f64> {
|
||||
self.inner.value()
|
||||
}
|
||||
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 {
|
||||
"AnchoredRSI()".to_string()
|
||||
}
|
||||
}
|
||||
|
||||
// ============================== Anchored VWAP ==============================
|
||||
|
||||
#[pyclass(name = "AnchoredVWAP", module = "wickra._wickra", skip_from_py_object)]
|
||||
@@ -14058,6 +14110,7 @@ fn _wickra(_py: Python<'_>, m: &Bound<'_, PyModule>) -> PyResult<()> {
|
||||
m.add_class::<PyNvi>()?;
|
||||
m.add_class::<PyPvi>()?;
|
||||
m.add_class::<PyAdOscillator>()?;
|
||||
m.add_class::<PyAnchoredRsi>()?;
|
||||
m.add_class::<PyAnchoredVwap>()?;
|
||||
m.add_class::<PyDemandIndex>()?;
|
||||
m.add_class::<PyTsv>()?;
|
||||
|
||||
@@ -66,6 +66,14 @@ def test_rsi_wilder_textbook_first_value():
|
||||
assert math.isclose(out[14], 70.464, abs_tol=0.05)
|
||||
|
||||
|
||||
def test_anchored_rsi_cumulative_reference():
|
||||
"""Cumulative anchored RSI: 10 -> 11 (+1) -> 9 (-2) -> 12 (+3)."""
|
||||
out = ta.AnchoredRSI().batch(np.array([10.0, 11.0, 9.0, 12.0]))
|
||||
assert math.isclose(out[1], 100.0, abs_tol=1e-9)
|
||||
assert math.isclose(out[2], 100.0 - 100.0 / 1.5, abs_tol=1e-6)
|
||||
assert math.isclose(out[3], 100.0 - 100.0 / 3.0, abs_tol=1e-6)
|
||||
|
||||
|
||||
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
|
||||
|
||||
@@ -12,6 +12,7 @@ SCALAR_INDICATORS = [
|
||||
(ta.EMA, (14,)),
|
||||
(ta.WMA, (14,)),
|
||||
(ta.RSI, (14,)),
|
||||
(ta.AnchoredRSI, ()),
|
||||
(ta.MACD, ()),
|
||||
(ta.BollingerBands, ()),
|
||||
]
|
||||
@@ -42,6 +43,7 @@ def test_reset_returns_to_initial_state(cls, args):
|
||||
(ta.EMA, (14,), 14),
|
||||
(ta.WMA, (14,), 14),
|
||||
(ta.RSI, (14,), 15),
|
||||
(ta.AnchoredRSI, (), 2),
|
||||
(ta.BollingerBands, (20, 2.0), 20),
|
||||
],
|
||||
)
|
||||
|
||||
@@ -1193,6 +1193,42 @@ def test_anchored_vwap_set_anchor_clears_window():
|
||||
assert v == pytest.approx(100.0)
|
||||
|
||||
|
||||
def test_anchored_rsi_reference():
|
||||
# prices 10 -> 11 (+1) -> 9 (-2) -> 12 (+3); cumulative anchored RSI.
|
||||
# bar2: sum_gain=1, sum_loss=2 -> rs=0.5 -> 100 - 100/1.5 = 33.3333
|
||||
# bar3: sum_gain=4, sum_loss=2 -> rs=2.0 -> 100 - 100/3 = 66.6667
|
||||
rsi = ta.AnchoredRSI()
|
||||
out = rsi.batch(np.array([10.0, 11.0, 9.0, 12.0]))
|
||||
assert np.isnan(out[0])
|
||||
assert out[1] == pytest.approx(100.0)
|
||||
assert out[2] == pytest.approx(33.333333, abs=1e-4)
|
||||
assert out[3] == pytest.approx(66.666666, abs=1e-4)
|
||||
|
||||
|
||||
def test_anchored_rsi_set_anchor_clears_window():
|
||||
# Downtrend reads 0; after re-anchor an uptrend must read a fresh 100.
|
||||
rsi = ta.AnchoredRSI()
|
||||
for p in (20.0, 19.0, 18.0, 17.0):
|
||||
rsi.update(p)
|
||||
assert rsi.is_ready()
|
||||
assert rsi.value == pytest.approx(0.0)
|
||||
rsi.set_anchor()
|
||||
assert rsi.update(50.0) is None
|
||||
assert rsi.update(51.0) == pytest.approx(100.0)
|
||||
|
||||
|
||||
def test_anchored_rsi_streaming_matches_batch():
|
||||
prices = np.array([100.0 + np.sin(i * 0.4) * 8.0 for i in range(60)])
|
||||
batched = ta.AnchoredRSI().batch(prices)
|
||||
streamer = ta.AnchoredRSI()
|
||||
streamed = [streamer.update(float(p)) for p in prices]
|
||||
for b, s in zip(batched, streamed):
|
||||
if np.isnan(b):
|
||||
assert s is None
|
||||
else:
|
||||
assert s == pytest.approx(b)
|
||||
|
||||
|
||||
def test_tsv_reference():
|
||||
# closes = [10, 11, 13, 12, 14, 15]
|
||||
# volumes = [50, 100, 200, 150, 50, 200]
|
||||
|
||||
Reference in New Issue
Block a user