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
@@ -0,0 +1,184 @@
//! MACD Histogram (standalone).
use crate::error::Result;
use crate::indicators::macd::MacdIndicator;
use crate::traits::Indicator;
/// MACD Histogram — the `macd signal` bar of [`MacdIndicator`] as a
/// standalone scalar indicator.
///
/// ```text
/// macd = EMA(fast) EMA(slow)
/// signal = EMA(macd, signal)
/// histogram = macd signal
/// ```
///
/// The histogram is the most actively traded part of MACD: it crosses zero
/// exactly when the MACD line crosses its signal, and its slope measures
/// whether that momentum is accelerating or fading. This wrapper exposes just
/// that series for pipelines that want a plain `f64` stream rather than the
/// full [`MacdOutput`](crate::MacdOutput); for the line and signal alongside
/// it, use [`MacdIndicator`](crate::MacdIndicator) directly.
///
/// Standard parameters are `fast = 12`, `slow = 26`, `signal = 9`, so the
/// first value lands after `slow + signal 1` inputs — exactly when
/// [`MacdIndicator`] emits its first full output.
///
/// # Example
///
/// ```
/// use wickra_core::{Indicator, MacdHistogram};
///
/// let mut indicator = MacdHistogram::new(12, 26, 9).unwrap();
/// let mut last = None;
/// for i in 0..80 {
/// last = indicator.update(100.0 + f64::from(i));
/// }
/// assert!(last.is_some());
/// ```
#[derive(Debug, Clone)]
pub struct MacdHistogram {
macd: MacdIndicator,
}
impl MacdHistogram {
/// Construct a MACD histogram with the given periods.
///
/// # Errors
///
/// Returns [`Error::PeriodZero`] if any period is zero, and
/// [`Error::InvalidPeriod`] if `fast >= slow`.
pub fn new(fast: usize, slow: usize, signal: usize) -> Result<Self> {
Ok(Self {
macd: MacdIndicator::new(fast, slow, signal)?,
})
}
/// Default `(12, 26, 9)` configuration, matching every classical chart package.
pub fn classic() -> Self {
Self::new(12, 26, 9).expect("classic MACD periods are valid")
}
/// Configured periods as `(fast, slow, signal)`.
pub const fn periods(&self) -> (usize, usize, usize) {
self.macd.periods()
}
}
impl Indicator for MacdHistogram {
type Input = f64;
type Output = f64;
fn update(&mut self, input: f64) -> Option<f64> {
self.macd.update(input).map(|out| out.histogram)
}
fn reset(&mut self) {
self.macd.reset();
}
fn warmup_period(&self) -> usize {
self.macd.warmup_period()
}
fn is_ready(&self) -> bool {
self.macd.is_ready()
}
fn name(&self) -> &'static str {
"MacdHistogram"
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::error::Error;
use crate::traits::BatchExt;
use approx::assert_relative_eq;
#[test]
fn rejects_invalid_periods() {
assert!(matches!(
MacdHistogram::new(0, 26, 9),
Err(Error::PeriodZero)
));
assert!(matches!(
MacdHistogram::new(12, 26, 0),
Err(Error::PeriodZero)
));
assert!(matches!(
MacdHistogram::new(26, 12, 9),
Err(Error::InvalidPeriod { .. })
));
}
#[test]
fn accessors_and_metadata() {
let osc = MacdHistogram::classic();
assert_eq!(osc.periods(), (12, 26, 9));
assert_eq!(osc.name(), "MacdHistogram");
assert_eq!(osc.warmup_period(), 26 + 9 - 1);
assert!(!osc.is_ready());
}
#[test]
fn equals_macd_histogram_field() {
// The standalone series must be exactly MacdIndicator's histogram bar.
let prices: Vec<f64> = (1..=120)
.map(|i| 100.0 + (f64::from(i) * 0.25).sin() * 8.0)
.collect();
let hist = MacdHistogram::classic().batch(&prices);
let full = MacdIndicator::classic().batch(&prices);
assert_eq!(hist.len(), full.len());
for (h, m) in hist.iter().zip(full.iter()) {
assert_eq!(h.is_some(), m.is_some());
if let (Some(h), Some(m)) = (h, m) {
assert_relative_eq!(*h, m.histogram, epsilon = 1e-12);
}
}
}
#[test]
fn warmup_emits_first_value_at_warmup_period() {
let mut osc = MacdHistogram::new(3, 6, 3).unwrap();
let warmup = osc.warmup_period();
assert_eq!(warmup, 6 + 3 - 1);
for i in 1..warmup {
assert!(osc.update(100.0 + i as f64).is_none());
}
assert!(osc.update(100.0 + warmup as f64).is_some());
assert!(osc.is_ready());
}
#[test]
fn constant_series_converges_to_zero() {
let mut osc = MacdHistogram::classic();
let out = osc.batch(&[100.0_f64; 200]);
let last = out.iter().rev().flatten().next().expect("emits a value");
assert_relative_eq!(*last, 0.0, epsilon = 1e-9);
}
#[test]
fn batch_equals_streaming() {
let prices: Vec<f64> = (1..=100)
.map(|i| (f64::from(i) * 0.4).cos() * 10.0)
.collect();
let mut a = MacdHistogram::classic();
let mut b = MacdHistogram::classic();
assert_eq!(
a.batch(&prices),
prices.iter().map(|p| b.update(*p)).collect::<Vec<_>>()
);
}
#[test]
fn reset_clears_state() {
let mut osc = MacdHistogram::classic();
osc.batch(&(1..=80).map(f64::from).collect::<Vec<_>>());
assert!(osc.is_ready());
osc.reset();
assert!(!osc.is_ready());
assert_eq!(osc.update(1.0), None);
}
}
+10 -1
View File
@@ -214,6 +214,7 @@ mod ma_envelope;
mod macd;
mod macd_ext;
mod macd_fix;
mod macd_histogram;
mod mama;
mod market_facilitation_index;
mod marubozu;
@@ -270,6 +271,7 @@ mod pmo;
mod point_and_figure_bars;
mod polarized_fractal_efficiency;
mod ppo;
mod ppo_histogram;
mod profit_factor;
mod psar;
mod pvi;
@@ -381,6 +383,7 @@ mod triple_top_bottom;
mod trix;
mod true_range;
mod tsf;
mod tsf_oscillator;
mod tsi;
mod tsv;
mod ttm_squeeze;
@@ -634,6 +637,7 @@ pub use ma_envelope::{MaEnvelope, MaEnvelopeOutput};
pub use macd::{MacdIndicator, MacdOutput};
pub use macd_ext::{MaType, MacdExt};
pub use macd_fix::MacdFix;
pub use macd_histogram::MacdHistogram;
pub use mama::{Mama, MamaOutput};
pub use market_facilitation_index::MarketFacilitationIndex;
pub use marubozu::Marubozu;
@@ -690,6 +694,7 @@ pub use pmo::Pmo;
pub use point_and_figure_bars::{PnfColumn, PointAndFigureBars};
pub use polarized_fractal_efficiency::PolarizedFractalEfficiency;
pub use ppo::Ppo;
pub use ppo_histogram::PpoHistogram;
pub use profit_factor::ProfitFactor;
pub use psar::Psar;
pub use pvi::Pvi;
@@ -801,6 +806,7 @@ pub use triple_top_bottom::TripleTopBottom;
pub use trix::Trix;
pub use true_range::TrueRange;
pub use tsf::Tsf;
pub use tsf_oscillator::TsfOscillator;
pub use tsi::Tsi;
pub use tsv::Tsv;
pub use ttm_squeeze::{TtmSqueeze, TtmSqueezeOutput};
@@ -973,6 +979,9 @@ pub const FAMILIES: &[(&str, &[&str])] = &[
"ZeroLagMacd",
"ElderImpulse",
"Stc",
"TsfOscillator",
"MacdHistogram",
"PpoHistogram",
],
),
(
@@ -1414,6 +1423,6 @@ mod family_tests {
// the actual indicator count is the early-warning signal that an
// indicator was added without being assigned a family.
let total: usize = FAMILIES.iter().map(|(_, ns)| ns.len()).sum();
assert_eq!(total, 420, "FAMILIES total drifted from indicator count");
assert_eq!(total, 423, "FAMILIES total drifted from indicator count");
}
}
@@ -0,0 +1,230 @@
//! Percentage Price Oscillator Histogram.
use crate::error::{Error, Result};
use crate::indicators::ema::Ema;
use crate::indicators::ppo::Ppo;
use crate::traits::Indicator;
/// PPO Histogram — the `ppo signal` bar of the Percentage Price Oscillator.
///
/// ```text
/// ppo = 100 · (EMA_fast EMA_slow) / EMA_slow
/// signal = EMA(ppo, signal_period)
/// histogram = ppo signal
/// ```
///
/// [`Ppo`](crate::Ppo) itself only emits the percentage line; this indicator
/// adds the classic 9-period signal EMA on top and reports the resulting
/// zero-centered histogram. Because PPO is scale-free (the EMA gap is divided
/// by the slow EMA), the histogram is **comparable across instruments** — a
/// PPO histogram of `0.4` means the same relative momentum on any asset, unlike
/// the price-unit [`MacdHistogram`](crate::MacdHistogram).
///
/// With Appel's defaults `fast = 12`, `slow = 26`, `signal = 9`, the first
/// value lands after `slow + signal 1` inputs — the point at which the slow
/// EMA and then the signal EMA are both seeded.
///
/// # Example
///
/// ```
/// use wickra_core::{Indicator, PpoHistogram};
///
/// let mut indicator = PpoHistogram::new(12, 26, 9).unwrap();
/// let mut last = None;
/// for i in 0..80 {
/// last = indicator.update(100.0 + f64::from(i));
/// }
/// assert!(last.is_some());
/// ```
#[derive(Debug, Clone)]
pub struct PpoHistogram {
ppo: Ppo,
signal_ema: Ema,
signal_period: usize,
current: Option<f64>,
}
impl PpoHistogram {
/// Construct a PPO histogram with the `fast`/`slow` EMA periods and the
/// `signal` EMA period.
///
/// # Errors
///
/// Returns [`Error::PeriodZero`] if any period is `0`, or
/// [`Error::InvalidPeriod`] if `fast >= slow`.
pub fn new(fast: usize, slow: usize, signal: usize) -> Result<Self> {
if signal == 0 {
return Err(Error::PeriodZero);
}
Ok(Self {
ppo: Ppo::new(fast, slow)?,
signal_ema: Ema::new(signal)?,
signal_period: signal,
current: None,
})
}
/// Default `(12, 26, 9)` configuration.
pub fn classic() -> Self {
Self::new(12, 26, 9).expect("classic PPO periods are valid")
}
/// Configured periods as `(fast, slow, signal)`.
pub const fn periods(&self) -> (usize, usize, usize) {
let (fast, slow) = self.ppo.periods();
(fast, slow, self.signal_period)
}
/// Current value if available.
pub const fn value(&self) -> Option<f64> {
self.current
}
}
impl Indicator for PpoHistogram {
type Input = f64;
type Output = f64;
fn update(&mut self, input: f64) -> Option<f64> {
// Guard before touching either stage so a non-finite input never
// advances the signal EMA on a stale, re-fed PPO value.
if !input.is_finite() {
return self.current;
}
let ppo = self.ppo.update(input)?;
let signal = self.signal_ema.update(ppo)?;
let histogram = ppo - signal;
self.current = Some(histogram);
Some(histogram)
}
fn reset(&mut self) {
self.ppo.reset();
self.signal_ema.reset();
self.current = None;
}
fn warmup_period(&self) -> usize {
// Slow EMA seeds the PPO, then the signal EMA needs `signal 1` more.
self.ppo.warmup_period() + self.signal_period - 1
}
fn is_ready(&self) -> bool {
self.current.is_some()
}
fn name(&self) -> &'static str {
"PpoHistogram"
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::traits::BatchExt;
use approx::assert_relative_eq;
#[test]
fn rejects_invalid_periods() {
assert!(matches!(
PpoHistogram::new(0, 26, 9),
Err(Error::PeriodZero)
));
assert!(matches!(
PpoHistogram::new(12, 0, 9),
Err(Error::PeriodZero)
));
assert!(matches!(
PpoHistogram::new(12, 26, 0),
Err(Error::PeriodZero)
));
assert!(matches!(
PpoHistogram::new(26, 12, 9),
Err(Error::InvalidPeriod { .. })
));
}
#[test]
fn accessors_and_metadata() {
let osc = PpoHistogram::classic();
assert_eq!(osc.periods(), (12, 26, 9));
assert_eq!(osc.name(), "PpoHistogram");
assert_eq!(osc.warmup_period(), 26 + 9 - 1);
assert_eq!(osc.value(), None);
assert!(!osc.is_ready());
}
#[test]
fn equals_ppo_minus_signal_ema() {
// The histogram must equal PPO minus an EMA(signal) composed by hand.
let prices: Vec<f64> = (1..=120)
.map(|i| 100.0 + (f64::from(i) * 0.2).sin() * 6.0)
.collect();
let got = PpoHistogram::new(12, 26, 9).unwrap().batch(&prices);
let mut ppo = Ppo::new(12, 26).unwrap();
let mut sig = Ema::new(9).unwrap();
let mut expected = Vec::with_capacity(prices.len());
for p in &prices {
let out = ppo
.update(*p)
.and_then(|line| sig.update(line).map(|signal| line - signal));
expected.push(out);
}
assert_eq!(got, expected);
}
#[test]
fn warmup_emits_first_value_at_warmup_period() {
let mut osc = PpoHistogram::new(3, 6, 3).unwrap();
let warmup = osc.warmup_period();
assert_eq!(warmup, 6 + 3 - 1);
for i in 1..warmup {
assert!(osc.update(100.0 + i as f64).is_none());
}
assert!(osc.update(100.0 + warmup as f64).is_some());
assert!(osc.is_ready());
}
#[test]
fn constant_series_converges_to_zero() {
let mut osc = PpoHistogram::classic();
let out = osc.batch(&[100.0_f64; 200]);
let last = out.iter().rev().flatten().next().expect("emits a value");
assert_relative_eq!(*last, 0.0, epsilon = 1e-9);
}
#[test]
fn ignores_non_finite_input() {
let mut osc = PpoHistogram::new(3, 6, 3).unwrap();
let out = osc.batch(&(1..=40).map(f64::from).collect::<Vec<_>>());
let before = *out.last().unwrap();
assert!(before.is_some());
assert_eq!(osc.update(f64::NAN), before);
assert_eq!(osc.update(f64::INFINITY), before);
assert_eq!(osc.value(), before);
}
#[test]
fn batch_equals_streaming() {
let prices: Vec<f64> = (1..=100)
.map(|i| 100.0 + (f64::from(i) * 0.4).cos() * 10.0)
.collect();
let mut a = PpoHistogram::classic();
let mut b = PpoHistogram::classic();
assert_eq!(
a.batch(&prices),
prices.iter().map(|p| b.update(*p)).collect::<Vec<_>>()
);
}
#[test]
fn reset_clears_state() {
let mut osc = PpoHistogram::classic();
osc.batch(&(1..=80).map(f64::from).collect::<Vec<_>>());
assert!(osc.is_ready());
osc.reset();
assert!(!osc.is_ready());
assert_eq!(osc.update(1.0), None);
}
}
@@ -0,0 +1,206 @@
//! Time Series Forecast Oscillator (TSF Oscillator).
use crate::error::{Error, Result};
use crate::indicators::tsf::Tsf;
use crate::traits::Indicator;
/// Time Series Forecast Oscillator — the percentage gap between the close and
/// the **one-bar-ahead** time-series forecast of the close.
///
/// ```text
/// TSFOsc_t = 100 · (close_t TSF(close, period)_t) / close_t
/// ```
///
/// where [`Tsf`](crate::Tsf) projects the rolling least-squares line one bar
/// past the window (`a + b·period`). It is the close-relative companion to
/// [`Cfo`](crate::Cfo), which measures the same percentage gap against the
/// regression value at the *current* bar (`a + b·(period 1)`). Because `TSF`
/// advances one bar further than `LinearRegression`, the two differ by exactly
/// the slope term `100·b/close`: on a trending series `TSFOsc` reads more
/// negative in an uptrend (the forecast has already stepped above price) and
/// more positive in a downtrend.
///
/// Positive readings mean the close sits *above* its forward forecast (price
/// has overshot the projected trend); negative readings mean it sits below.
/// Wraps the existing `Tsf` so the warmup matches.
///
/// # Example
///
/// ```
/// use wickra_core::{Indicator, TsfOscillator};
///
/// let mut indicator = TsfOscillator::new(14).unwrap();
/// let mut last = None;
/// for i in 0..40 {
/// last = indicator.update(100.0 + f64::from(i));
/// }
/// assert!(last.is_some());
/// ```
#[derive(Debug, Clone)]
pub struct TsfOscillator {
period: usize,
tsf: Tsf,
current: Option<f64>,
}
impl TsfOscillator {
/// Construct a new TSF oscillator over `period` inputs.
///
/// # Errors
/// Returns [`Error::InvalidPeriod`] if `period < 2` — a regression line is
/// undefined for fewer than two points.
pub fn new(period: usize) -> Result<Self> {
if period < 2 {
return Err(Error::InvalidPeriod {
message: "TSF oscillator needs period >= 2",
});
}
Ok(Self {
period,
tsf: Tsf::new(period)?,
current: None,
})
}
/// Configured period.
pub const fn period(&self) -> usize {
self.period
}
}
impl Indicator for TsfOscillator {
type Input = f64;
type Output = f64;
fn update(&mut self, input: f64) -> Option<f64> {
let forecast = self.tsf.update(input)?;
// Hold the previous value if the close is zero — the percentage form
// is undefined and a return of inf would propagate badly.
if input == 0.0 {
return self.current;
}
let value = 100.0 * (input - forecast) / input;
self.current = Some(value);
Some(value)
}
fn reset(&mut self) {
self.tsf.reset();
self.current = None;
}
fn warmup_period(&self) -> usize {
self.period
}
fn is_ready(&self) -> bool {
self.current.is_some()
}
fn name(&self) -> &'static str {
"TsfOscillator"
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::traits::BatchExt;
use approx::assert_relative_eq;
#[test]
fn rejects_short_period() {
assert!(matches!(
TsfOscillator::new(1),
Err(Error::InvalidPeriod { .. })
));
assert!(matches!(
TsfOscillator::new(0),
Err(Error::InvalidPeriod { .. })
));
}
#[test]
fn accessors_and_metadata() {
let osc = TsfOscillator::new(14).unwrap();
assert_eq!(osc.period(), 14);
assert_eq!(osc.warmup_period(), 14);
assert_eq!(osc.name(), "TsfOscillator");
assert!(!osc.is_ready());
}
#[test]
fn reference_value() {
// period 3 over [1, 2, 9]: fit y = 0 + 4x, one-bar-ahead TSF at x = 3
// is 12. With close = 9, TSFOsc = 100·(9 12)/9 = 33.3333…%.
let mut osc = TsfOscillator::new(3).unwrap();
let out = osc.batch(&[1.0_f64, 2.0, 9.0]);
assert!(out[0].is_none());
assert!(out[1].is_none());
assert_relative_eq!(out[2].unwrap(), -100.0 / 3.0, epsilon = 1e-9);
assert!(osc.is_ready());
}
#[test]
fn constant_series_yields_zero() {
// On a flat series the regression slope is 0, so the one-bar-ahead TSF
// equals the constant and close forecast is exactly 0.
let mut osc = TsfOscillator::new(5).unwrap();
let out = osc.batch(&[42.0_f64; 30]);
for v in out.iter().skip(4).flatten() {
assert_relative_eq!(*v, 0.0, epsilon = 1e-12);
}
}
#[test]
fn linear_uptrend_reads_negative() {
// Unlike CFO (evaluated at the current bar), the forecast steps one bar
// ahead, so on a rising line the projection sits above the close and the
// oscillator is negative: TSFOsc = 100·slope/close.
let mut osc = TsfOscillator::new(5).unwrap();
let prices: Vec<f64> = (1..=20).map(|i| f64::from(i) * 2.0).collect();
let out = osc.batch(&prices);
for v in out.iter().skip(4).flatten() {
assert!(*v < 0.0, "uptrend forecast overshoots close, got {v}");
}
}
#[test]
fn warmup_emits_first_value_at_period() {
let mut osc = TsfOscillator::new(3).unwrap();
assert_eq!(osc.update(1.0), None);
assert_eq!(osc.update(2.0), None);
assert!(osc.update(3.0).is_some());
}
#[test]
fn batch_equals_streaming() {
let prices: Vec<f64> = (1..=80)
.map(|i| 100.0 + (f64::from(i) * 0.3).sin() * 5.0)
.collect();
let mut a = TsfOscillator::new(14).unwrap();
let mut b = TsfOscillator::new(14).unwrap();
assert_eq!(
a.batch(&prices),
prices.iter().map(|p| b.update(*p)).collect::<Vec<_>>()
);
}
#[test]
fn reset_clears_state() {
let mut osc = TsfOscillator::new(5).unwrap();
osc.batch(&(1..=20).map(f64::from).collect::<Vec<_>>());
assert!(osc.is_ready());
osc.reset();
assert!(!osc.is_ready());
assert_eq!(osc.update(1.0), None);
}
#[test]
fn zero_close_holds_value() {
let mut osc = TsfOscillator::new(3).unwrap();
osc.batch(&[1.0_f64, 2.0, 3.0]);
let before = osc.current;
assert_eq!(osc.update(0.0), before);
}
}