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:
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -99,7 +99,7 @@ pub use indicators::{
|
||||
LeadLagCrossCorrelation, LeadLagCrossCorrelationOutput, LinRegAngle, LinRegChannel,
|
||||
LinRegChannelOutput, LinRegIntercept, LinRegSlope, LinearRegression, LiquidationFeatures,
|
||||
LiquidationFeaturesOutput, LogReturn, LongLeggedDoji, LongLine, LongShortRatio, MaEnvelope,
|
||||
MaEnvelopeOutput, MacdExt, MacdFix, MacdIndicator, MacdOutput, Mama, MamaOutput,
|
||||
MaEnvelopeOutput, MacdExt, MacdFix, MacdHistogram, MacdIndicator, MacdOutput, Mama, MamaOutput,
|
||||
MarketFacilitationIndex, Marubozu, MassIndex, MatHold, MatchingLow, MaxDrawdown,
|
||||
McClellanOscillator, McClellanSummationIndex, McGinleyDynamic, MedianAbsoluteDeviation,
|
||||
MedianMa, MedianPrice, Mfi, Microprice, MidPoint, MidPrice, MinusDi, MinusDm, Mom,
|
||||
@@ -109,10 +109,10 @@ pub use indicators::{
|
||||
OuHalfLife, OvernightGap, OvernightIntradayReturn, OvernightIntradayReturnOutput, PainIndex,
|
||||
PairSpreadZScore, PairwiseBeta, ParkinsonVolatility, PearsonCorrelation, PercentAboveMa,
|
||||
PercentB, PercentageTrailingStop, Pgo, PiercingDarkCloud, PlusDi, PlusDm, Pmo,
|
||||
PointAndFigureBars, PolarizedFractalEfficiency, Ppo, ProfitFactor, Psar, Pvi, Qqe, QqeOutput,
|
||||
Qstick, QuotedSpread, RSquared, RealizedSpread, RealizedVolatility, RecoveryFactor,
|
||||
RectangleRange, RegimeLabel, RelativeStrengthAB, RelativeStrengthOutput, RenkoBars,
|
||||
RenkoTrailingStop, RickshawMan, RisingThreeMethods, Rmi, Roc, Rocp, Rocr, Rocr100,
|
||||
PointAndFigureBars, PolarizedFractalEfficiency, Ppo, PpoHistogram, ProfitFactor, Psar, Pvi,
|
||||
Qqe, QqeOutput, Qstick, QuotedSpread, RSquared, RealizedSpread, RealizedVolatility,
|
||||
RecoveryFactor, RectangleRange, RegimeLabel, RelativeStrengthAB, RelativeStrengthOutput,
|
||||
RenkoBars, RenkoTrailingStop, RickshawMan, RisingThreeMethods, Rmi, Roc, Rocp, Rocr, Rocr100,
|
||||
RogersSatchellVolatility, RollMeasure, RollingCorrelation, RollingCovariance, RollingIqr,
|
||||
RollingPercentileRank, RollingQuantile, RollingVwap, RoofingFilter, Rsi, Rsx, Rvi,
|
||||
RviVolatility, Rwi, RwiOutput, SarExt, SeasonalZScore, SeparatingLines, SessionHighLow,
|
||||
@@ -129,11 +129,11 @@ pub use indicators::{
|
||||
ThreeLineStrike, ThreeOutside, ThreeSoldiersOrCrows, ThreeStarsInSouth, Thrusting, TickIndex,
|
||||
Tii, TimeOfDayReturnProfile, TimeOfDayReturnProfileOutput, TpoProfile, TpoProfileOutput,
|
||||
TradeImbalance, TrendLabel, TrendStrengthIndex, TreynorRatio, Triangle, Trima, Trin,
|
||||
TripleTopBottom, Trix, TrueRange, Tsf, Tsi, Tsv, TtmSqueeze, TtmSqueezeOutput, TtmTrend,
|
||||
TurnOfMonth, Tweezer, TwoCrows, TypicalPrice, UlcerIndex, UltimateOscillator, UniqueThreeRiver,
|
||||
UpDownVolumeRatio, UpsideGapThreeMethods, UpsideGapTwoCrows, ValueArea, ValueAreaOutput,
|
||||
ValueAtRisk, Variance, VarianceRatio, VerticalHorizontalFilter, Vidya, VoltyStop,
|
||||
VolumeByTimeProfile, VolumeByTimeProfileOutput, VolumeOscillator, VolumePriceTrend,
|
||||
TripleTopBottom, Trix, TrueRange, Tsf, TsfOscillator, Tsi, Tsv, TtmSqueeze, TtmSqueezeOutput,
|
||||
TtmTrend, TurnOfMonth, Tweezer, TwoCrows, TypicalPrice, UlcerIndex, UltimateOscillator,
|
||||
UniqueThreeRiver, UpDownVolumeRatio, UpsideGapThreeMethods, UpsideGapTwoCrows, ValueArea,
|
||||
ValueAreaOutput, ValueAtRisk, Variance, VarianceRatio, VerticalHorizontalFilter, Vidya,
|
||||
VoltyStop, VolumeByTimeProfile, VolumeByTimeProfileOutput, VolumeOscillator, VolumePriceTrend,
|
||||
VolumeProfile, VolumeProfileOutput, Vortex, VortexOutput, Vpin, Vwap, VwapStdDevBands,
|
||||
VwapStdDevBandsOutput, Vwma, Vzo, WavePm, WaveTrend, WaveTrendOutput, Wedge, WeightedClose,
|
||||
WickRatio, WilliamsFractals, WilliamsFractalsOutput, WilliamsR, WinRate, Wma, WoodiePivots,
|
||||
|
||||
Reference in New Issue
Block a user