feat(core): B4 price oscillators (TsfOscillator, MacdHistogram, PpoHistogram) (#184)

Adds three **Price Oscillators** family indicators (420 → 423).

## Indicators

- **TsfOscillator** — `100·(close − TSF)/close`, the percentage gap of the close to the **one-bar-ahead** time-series forecast. Close-relative companion to `Cfo`, which measures the same gap against the regression value at the *current* bar; the two differ by exactly the slope term `100·b/close`.
- **MacdHistogram** — the standalone `macd − signal` bar of MACD exposed as a plain `f64` series.
- **PpoHistogram** — the Percentage Price Oscillator with its 9-period signal EMA and the resulting scale-free, zero-centered histogram (PPO itself only emits the line).

All three are scalar `f64` indicators wrapping existing, already-tested building blocks (`MacdIndicator`, `Ppo` + `Ema`, `Tsf`).

## Scope notes (VORAB-CHECK)

The B4 roadmap listed six items; three were dropped to avoid duplicates:
- *Forecast Oscillator* already ships as `Cfo`.
- *Derivative Oscillator* already ships (`DerivativeOscillator`, B2).
- *Detrended Synthetic Price* deferred — no citable formula distinct from the existing `Apo`/`Dpo`.

## Touchpoints

Core (`tsf_oscillator.rs`, `macd_histogram.rs`, `ppo_histogram.rs`) with full per-branch unit tests, `mod.rs`/`lib.rs`, python/node/wasm bindings (wasm via typed-arg macro, python/node hand-written for the multi-arg histograms), fuzz drivers, python reference + streaming-vs-batch tests, node factories, README family row + counter, CHANGELOG.

Local verify: `cargo test --workspace` green, `clippy -D warnings` clean, node 498 tests, full python suite green.
This commit is contained in:
kingchenc
2026-06-04 19:36:43 +02:00
committed by GitHub
parent d36d514f56
commit 1f4bf9e3a6
17 changed files with 942 additions and 19 deletions
+141
View File
@@ -3295,6 +3295,144 @@ impl PyKasePermissionStochastic {
}
}
// ============================== TsfOscillator ==============================
#[pyclass(name = "TsfOscillator", module = "wickra._wickra", skip_from_py_object)]
#[derive(Clone)]
struct PyTsfOscillator {
inner: wc::TsfOscillator,
}
#[pymethods]
impl PyTsfOscillator {
#[new]
#[pyo3(signature = (period=14))]
fn new(period: usize) -> PyResult<Self> {
Ok(Self {
inner: wc::TsfOscillator::new(period).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 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!("TsfOscillator(period={})", self.inner.period())
}
}
// ============================== MacdHistogram ==============================
#[pyclass(name = "MacdHistogram", module = "wickra._wickra", skip_from_py_object)]
#[derive(Clone)]
struct PyMacdHistogram {
inner: wc::MacdHistogram,
}
#[pymethods]
impl PyMacdHistogram {
#[new]
#[pyo3(signature = (fast=12, slow=26, signal=9))]
fn new(fast: usize, slow: usize, signal: usize) -> PyResult<Self> {
Ok(Self {
inner: wc::MacdHistogram::new(fast, slow, signal).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 (fast, slow, signal) = self.inner.periods();
format!("MacdHistogram(fast={fast}, slow={slow}, signal={signal})")
}
}
// ============================== PpoHistogram ==============================
#[pyclass(name = "PpoHistogram", module = "wickra._wickra", skip_from_py_object)]
#[derive(Clone)]
struct PyPpoHistogram {
inner: wc::PpoHistogram,
}
#[pymethods]
impl PyPpoHistogram {
#[new]
#[pyo3(signature = (fast=12, slow=26, signal=9))]
fn new(fast: usize, slow: usize, signal: usize) -> PyResult<Self> {
Ok(Self {
inner: wc::PpoHistogram::new(fast, slow, signal).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 (fast, slow, signal) = self.inner.periods();
format!("PpoHistogram(fast={fast}, slow={slow}, signal={signal})")
}
}
// ============================== Stochastic ==============================
#[pyclass(name = "IMI", module = "wickra._wickra", skip_from_py_object)]
@@ -21422,5 +21560,8 @@ fn _wickra(_py: Python<'_>, m: &Bound<'_, PyModule>) -> PyResult<()> {
m.add_class::<PyWavePm>()?;
m.add_class::<PyGatorOscillator>()?;
m.add_class::<PyKasePermissionStochastic>()?;
m.add_class::<PyTsfOscillator>()?;
m.add_class::<PyMacdHistogram>()?;
m.add_class::<PyPpoHistogram>()?;
Ok(())
}