feat(family-10): add 16 Ehlers / Cycle (DSP) indicators (#49)

Implements Family 10 (Ehlers / Cycle) end-to-end across Rust core,
Python / Node / WASM bindings, fuzz, tests, benches and docs. This
is an entirely new family covering John Ehlers' digital-signal-
processing school of cycle analytics — a strong differentiator
versus TA-Lib and pandas-ta, which ship only fragments.

Indicators:
- MAMA (Mesa Adaptive MA) — multi-output { mama, fama }
- FAMA (Following Adaptive MA) — scalar wrapper around MAMA's slow line
- Fisher Transform — Gaussian-normalising price transform
- Inverse Fisher Transform — bounded oscillator (tanh-based)
- SuperSmoother — 2-pole Butterworth lowpass
- Roofing Filter — high-pass + SuperSmoother bandpass
- Decycler — price minus 2-pole high-pass (lag-free trend)
- Decycler Oscillator — fast / slow Decycler difference (MACD-like)
- Hilbert Dominant Cycle — phase-derived period estimator [6, 50]
- Sine Wave Indicator — sin(phase) with 45° lead companion
- Adaptive Cycle Indicator — half-period driver for adaptive oscillators
- Center of Gravity Oscillator — weighted-mass momentum
- Cybernetic Cycle Component — EasyLanguage classic
- Empirical Mode Decomposition — bandpass + envelope mean
- Ehlers Stochastic — Stochastic on Roofing Filter input, [-1, +1]
- Instantaneous Trendline — Ehlers 2-pole lag-free trend

Indicator count rises 71 -> 87 across nine families (was eight).

All sixteen pass batch == streaming equivalence, expose the standard
Indicator surface (update / batch / reset / is_ready / warmup_period
/ name), are fuzz-tested, benchmarked against the checked-in BTCUSDT
1-minute dataset and reach across all four bindings.

Wiki deep-dive drafts for every indicator + Sidebar / Overview /
Home / Warmup updates are staged under indicator-ideas/families/
wiki/family-10-ehlers-cycle/ in the main repo (ghost-ignored) for
the maintainer to publish to the wiki repo manually.
This commit is contained in:
kingchenc
2026-05-25 22:14:27 +02:00
committed by GitHub
parent 4f9ed34884
commit 7a18a26daf
34 changed files with 4947 additions and 46 deletions
+27
View File
@@ -8,6 +8,33 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased]
### Added
- **Family 10 — Ehlers / Cycle (DSP) indicators.** 16 new
streaming-first indicators implementing John Ehlers'
digital-signal-processing school of cycle analytics — a strong
differentiation feature versus TA-Lib and pandas-ta, which only
ship fragments of this catalogue:
- **MAMA / FAMA** (MESA Adaptive Moving Average + Following
Adaptive Moving Average) — phase-rate-adaptive smoothing pair
from the 2001 MESA paper, exposed both jointly via `Mama` (multi-
output) and as a scalar `Fama` wrapper.
- **Fisher Transform** and **Inverse Fisher Transform** — Gaussian
normalisation of price (Ehlers 2002) and its tanh-based bounded
counterpart for oscillators.
- **SuperSmoother**, **Roofing Filter**, **Decycler** and **Decycler
Oscillator** — 2-pole Butterworth lowpass, bandpass and
high-pass complement building blocks from *Cycle Analytics for
Traders* (2013).
- **Hilbert Dominant Cycle**, **Sine Wave** and **Adaptive Cycle**
— Hilbert-transform-based period estimation from *Rocket Science
for Traders* (2001).
- **Center of Gravity**, **Cybernetic Cycle Component**,
**Instantaneous Trendline**, **Ehlers Stochastic** and
**Empirical Mode Decomposition** — EasyLanguage classics from
Ehlers' published catalogue.
- All sixteen are exposed across Rust, Python, Node.js and WASM
bindings, fuzz-tested, benchmarked against real BTCUSDT
1-minute data, and pass `batch == streaming` equivalence.
- Indicator count rises from 71 to **87** across **nine** families.
- **DeMark family (family 11) — 12 new indicators.** TD Setup (9-bar
buy/sell setup counter with parameterised lookback and target), TD
Sequential (Setup + Countdown phase machine emitting setup count,
+2 -1
View File
@@ -109,7 +109,7 @@ python -m benchmarks.compare_libraries
## Indicators
147 streaming-first indicators across eleven families. Every one passes the
163 streaming-first indicators across twelve families. Every one passes the
`batch == streaming` equivalence test, reference-value tests, and reset
semantics tests.
@@ -124,6 +124,7 @@ semantics tests.
| Trailing Stops | Parabolic SAR, SuperTrend, Chandelier Exit, Chande Kroll Stop, ATR Trailing Stop, HiLo Activator, Volty Stop, Yo-Yo Exit, Donchian Channel Stop, Percentage Trailing Stop, Step Trailing Stop, Renko Trailing Stop |
| Volume | OBV, VWAP (cumulative + rolling), ADL, Volume-Price Trend, Chaikin Money Flow, Chaikin Oscillator, Force Index, Ease of Movement, Klinger Volume Oscillator, Volume Oscillator, NVI, PVI, Williams A/D, Anchored VWAP, Demand Index, TSV, VZO, Market Facilitation Index |
| Price Statistics | Typical Price, Median Price, Weighted Close, Linear Regression, Linear Regression Slope, Z-Score, Linear Regression Angle |
| Ehlers / Cycle (DSP) | MAMA, FAMA, Fisher Transform, Inverse Fisher Transform, SuperSmoother, Hilbert Dominant Cycle, Sine Wave, Decycler, Decycler Oscillator, Roofing Filter, Center of Gravity, Cybernetic Cycle, Adaptive Cycle, Empirical Mode Decomposition, Ehlers Stochastic, Instantaneous Trendline |
| Pivots & S/R | Classic Pivots, Fibonacci Pivots, Camarilla, Woodie Pivots, DeMark Pivots, Williams Fractals, ZigZag |
| DeMark | TD Setup, TD Sequential, TD DeMarker, TD REI, TD Pressure, TD Combo, TD Countdown, TD Lines, TD Range Projection, TD Differential, TD Open, TD Risk Level |
@@ -75,6 +75,22 @@ const scalarFactories = {
LaguerreRSI: () => new wickra.LaguerreRSI(0.5),
ConnorsRSI: () => new wickra.ConnorsRSI(3, 2, 100),
RVIVolatility: () => new wickra.RVIVolatility(10),
// Family 10 — Ehlers / Cycle
SuperSmoother: () => new wickra.SuperSmoother(10),
FisherTransform: () => new wickra.FisherTransform(10),
InverseFisherTransform: () => new wickra.InverseFisherTransform(1.0),
Decycler: () => new wickra.Decycler(20),
DecyclerOscillator: () => new wickra.DecyclerOscillator(10, 30),
RoofingFilter: () => new wickra.RoofingFilter(10, 48),
CenterOfGravity: () => new wickra.CenterOfGravity(10),
CyberneticCycle: () => new wickra.CyberneticCycle(10),
InstantaneousTrendline: () => new wickra.InstantaneousTrendline(20),
EhlersStochastic: () => new wickra.EhlersStochastic(20),
EmpiricalModeDecomposition: () => new wickra.EmpiricalModeDecomposition(20, 0.5),
HilbertDominantCycle: () => new wickra.HilbertDominantCycle(),
AdaptiveCycle: () => new wickra.AdaptiveCycle(),
SineWave: () => new wickra.SineWave(),
FAMA: () => new wickra.FAMA(0.5, 0.05),
};
for (const [name, make] of Object.entries(scalarFactories)) {
@@ -213,6 +229,8 @@ const multi = {
TDLines: { make: () => new wickra.TDLines(4, 9), fields: ['resistance', 'support'], step: (ind, i) => ind.update(high[i], low[i], close[i]), batch: (ind) => ind.batch(high, low, close) },
TDRangeProjection: { make: () => new wickra.TDRangeProjection(), fields: ['high', 'low'], step: (ind, i) => ind.update(open[i], high[i], low[i], close[i]), batch: (ind) => ind.batch(open, high, low, close) },
TDRiskLevel: { make: () => new wickra.TDRiskLevel(4, 9), fields: ['buyRisk', 'sellRisk'], step: (ind, i) => ind.update(high[i], low[i], close[i]), batch: (ind) => ind.batch(high, low, close) },
// Family 10: Ehlers / Cycle (multi-output)
MAMA: { make: () => new wickra.MAMA(0.5, 0.05), fields: ['mama', 'fama'], step: (ind, i) => ind.update(close[i]), batch: (ind) => ind.batch(close) },
};
for (const [name, d] of Object.entries(multi)) {
+17 -1
View File
@@ -310,7 +310,7 @@ if (!nativeBinding) {
throw new Error(`Failed to load native binding`)
}
const { version, SMA, EMA, WMA, RSI, DEMA, TEMA, HMA, ROC, TRIX, SMMA, TRIMA, ZLEMA, MOM, CMO, DPO, StdDev, UlcerIndex, VerticalHorizontalFilter, ZScore, MACD, BollingerBands, ATR, Stochastic, OBV, ADX, ADXR, CCI, WilliamsR, MFI, PSAR, Keltner, Donchian, VWAP, RollingVWAP, AwesomeOscillator, Aroon, KAMA, RVI, PGO, KST, SMI, LaguerreRSI, ConnorsRSI, Inertia, ALMA, McGinleyDynamic, FRAMA, VIDYA, JMA, Alligator, EVWMA, APO, AwesomeOscillatorHistogram, CFO, ZeroLagMACD, ElderImpulse, STC, T3, TSI, PMO, TII, ADL, VolumePriceTrend, ChaikinMoneyFlow, ChaikinOscillator, ForceIndex, EaseOfMovement, KVO, VolumeOscillator, NVI, PVI, WilliamsAD, AnchoredVWAP, DemandIndex, TSV, VZO, MarketFacilitationIndex, SuperTrend, ChandelierExit, ChandeKrollStop, AtrTrailingStop, HiLoActivator, VoltyStop, YoyoExit, DonchianStop, PercentageTrailingStop, StepTrailingStop, RenkoTrailingStop, TypicalPrice, MedianPrice, WeightedClose, LinearRegression, LinRegSlope, AcceleratorOscillator, BalanceOfPower, ChoppinessIndex, TrueRange, ChaikinVolatility, LinRegAngle, BollingerBandwidth, PercentB, NATR, HistoricalVolatility, AroonOscillator, Vortex, RWI, WaveTrend, MassIndex, StochRSI, UltimateOscillator, PPO, Coppock, VWMA, RVIVolatility, ParkinsonVolatility, GarmanKlassVolatility, RogersSatchellVolatility, YangZhangVolatility, MaEnvelope, AccelerationBands, StarcBands, AtrBands, HurstChannel, LinRegChannel, StandardErrorBands, DoubleBollinger, TtmSqueeze, FractalChaosBands, VwapStdDevBands, ClassicPivots, FibonacciPivots, Camarilla, WoodiePivots, DemarkPivots, WilliamsFractals, ZigZag, TDSetup, TDSequential, TDDeMarker, TDREI, TDPressure, TDCombo, TDCountdown, TDLines, TDRangeProjection, TDDifferential, TDOpen, TDRiskLevel } = nativeBinding
const { version, SMA, EMA, WMA, RSI, DEMA, TEMA, HMA, ROC, TRIX, SMMA, TRIMA, ZLEMA, MOM, CMO, DPO, StdDev, UlcerIndex, VerticalHorizontalFilter, ZScore, MACD, BollingerBands, ATR, Stochastic, OBV, ADX, ADXR, CCI, WilliamsR, MFI, PSAR, Keltner, Donchian, VWAP, RollingVWAP, AwesomeOscillator, Aroon, KAMA, RVI, PGO, KST, SMI, LaguerreRSI, ConnorsRSI, Inertia, ALMA, McGinleyDynamic, FRAMA, VIDYA, JMA, Alligator, EVWMA, APO, AwesomeOscillatorHistogram, CFO, ZeroLagMACD, ElderImpulse, STC, T3, TSI, PMO, TII, ADL, VolumePriceTrend, ChaikinMoneyFlow, ChaikinOscillator, ForceIndex, EaseOfMovement, KVO, VolumeOscillator, NVI, PVI, WilliamsAD, AnchoredVWAP, DemandIndex, TSV, VZO, MarketFacilitationIndex, SuperTrend, ChandelierExit, ChandeKrollStop, AtrTrailingStop, HiLoActivator, VoltyStop, YoyoExit, DonchianStop, PercentageTrailingStop, StepTrailingStop, RenkoTrailingStop, TypicalPrice, MedianPrice, WeightedClose, LinearRegression, LinRegSlope, AcceleratorOscillator, BalanceOfPower, ChoppinessIndex, TrueRange, ChaikinVolatility, LinRegAngle, BollingerBandwidth, PercentB, NATR, HistoricalVolatility, AroonOscillator, Vortex, RWI, WaveTrend, MassIndex, StochRSI, UltimateOscillator, PPO, Coppock, VWMA, RVIVolatility, ParkinsonVolatility, GarmanKlassVolatility, RogersSatchellVolatility, YangZhangVolatility, MaEnvelope, AccelerationBands, StarcBands, AtrBands, HurstChannel, LinRegChannel, StandardErrorBands, DoubleBollinger, TtmSqueeze, FractalChaosBands, VwapStdDevBands, ClassicPivots, FibonacciPivots, Camarilla, WoodiePivots, DemarkPivots, WilliamsFractals, ZigZag, TDSetup, TDSequential, TDDeMarker, TDREI, TDPressure, TDCombo, TDCountdown, TDLines, TDRangeProjection, TDDifferential, TDOpen, TDRiskLevel, SuperSmoother, FisherTransform, InverseFisherTransform, Decycler, DecyclerOscillator, RoofingFilter, CenterOfGravity, CyberneticCycle, InstantaneousTrendline, EhlersStochastic, EmpiricalModeDecomposition, HilbertDominantCycle, AdaptiveCycle, SineWave, MAMA, FAMA } = nativeBinding
module.exports.version = version
module.exports.SMA = SMA
@@ -461,3 +461,19 @@ module.exports.TDRangeProjection = TDRangeProjection
module.exports.TDDifferential = TDDifferential
module.exports.TDOpen = TDOpen
module.exports.TDRiskLevel = TDRiskLevel
module.exports.SuperSmoother = SuperSmoother
module.exports.FisherTransform = FisherTransform
module.exports.InverseFisherTransform = InverseFisherTransform
module.exports.Decycler = Decycler
module.exports.DecyclerOscillator = DecyclerOscillator
module.exports.RoofingFilter = RoofingFilter
module.exports.CenterOfGravity = CenterOfGravity
module.exports.CyberneticCycle = CyberneticCycle
module.exports.InstantaneousTrendline = InstantaneousTrendline
module.exports.EhlersStochastic = EhlersStochastic
module.exports.EmpiricalModeDecomposition = EmpiricalModeDecomposition
module.exports.HilbertDominantCycle = HilbertDominantCycle
module.exports.AdaptiveCycle = AdaptiveCycle
module.exports.SineWave = SineWave
module.exports.MAMA = MAMA
module.exports.FAMA = FAMA
+357
View File
@@ -119,6 +119,23 @@ node_scalar_indicator!(ZScoreNode, "ZScore", wc::ZScore);
node_scalar_indicator!(McGinleyDynamicNode, "McGinleyDynamic", wc::McGinleyDynamic);
node_scalar_indicator!(FramaNode, "FRAMA", wc::Frama);
// Family 10 — Ehlers / Cycle: single-period scalars.
node_scalar_indicator!(SuperSmootherNode, "SuperSmoother", wc::SuperSmoother);
node_scalar_indicator!(FisherTransformNode, "FisherTransform", wc::FisherTransform);
node_scalar_indicator!(DecyclerNode, "Decycler", wc::Decycler);
node_scalar_indicator!(CenterOfGravityNode, "CenterOfGravity", wc::CenterOfGravity);
node_scalar_indicator!(CyberneticCycleNode, "CyberneticCycle", wc::CyberneticCycle);
node_scalar_indicator!(
InstantaneousTrendlineNode,
"InstantaneousTrendline",
wc::InstantaneousTrendline
);
node_scalar_indicator!(
EhlersStochasticNode,
"EhlersStochastic",
wc::EhlersStochastic
);
// RviVolatility (Relative Volatility Index, Donald Dorsey). Disambiguated
// from `RVI` = Relative Vigor Index in Family 02. Takes a single `period`
// parameter and additionally rejects `period == 1` (a 1-bar standard
@@ -7389,3 +7406,343 @@ impl TdRiskLevelNode {
self.inner.warmup_period() as u32
}
}
// ============================== Family 10 — Ehlers / Cycle ==============================
#[napi(js_name = "InverseFisherTransform")]
pub struct InverseFisherTransformNode {
inner: wc::InverseFisherTransform,
}
#[napi]
impl InverseFisherTransformNode {
#[napi(constructor)]
pub fn new(scale: f64) -> napi::Result<Self> {
Ok(Self {
inner: wc::InverseFisherTransform::new(scale).map_err(map_err)?,
})
}
#[napi]
pub fn update(&mut self, value: f64) -> Option<f64> {
self.inner.update(value)
}
#[napi]
pub fn batch(&mut self, prices: Vec<f64>) -> Vec<f64> {
flatten(self.inner.batch(&prices))
}
#[napi]
pub fn reset(&mut self) {
self.inner.reset();
}
#[napi(js_name = "isReady")]
pub fn is_ready(&self) -> bool {
self.inner.is_ready()
}
#[napi(js_name = "warmupPeriod")]
pub fn warmup_period(&self) -> u32 {
self.inner.warmup_period() as u32
}
}
#[napi(js_name = "DecyclerOscillator")]
pub struct DecyclerOscillatorNode {
inner: wc::DecyclerOscillator,
}
#[napi]
impl DecyclerOscillatorNode {
#[napi(constructor)]
pub fn new(fast: u32, slow: u32) -> napi::Result<Self> {
Ok(Self {
inner: wc::DecyclerOscillator::new(fast as usize, slow as usize).map_err(map_err)?,
})
}
#[napi]
pub fn update(&mut self, value: f64) -> Option<f64> {
self.inner.update(value)
}
#[napi]
pub fn batch(&mut self, prices: Vec<f64>) -> Vec<f64> {
flatten(self.inner.batch(&prices))
}
#[napi]
pub fn reset(&mut self) {
self.inner.reset();
}
#[napi(js_name = "isReady")]
pub fn is_ready(&self) -> bool {
self.inner.is_ready()
}
#[napi(js_name = "warmupPeriod")]
pub fn warmup_period(&self) -> u32 {
self.inner.warmup_period() as u32
}
}
#[napi(js_name = "RoofingFilter")]
pub struct RoofingFilterNode {
inner: wc::RoofingFilter,
}
#[napi]
impl RoofingFilterNode {
#[napi(constructor)]
pub fn new(lp_period: u32, hp_period: u32) -> napi::Result<Self> {
Ok(Self {
inner: wc::RoofingFilter::new(lp_period as usize, hp_period as usize)
.map_err(map_err)?,
})
}
#[napi]
pub fn update(&mut self, value: f64) -> Option<f64> {
self.inner.update(value)
}
#[napi]
pub fn batch(&mut self, prices: Vec<f64>) -> Vec<f64> {
flatten(self.inner.batch(&prices))
}
#[napi]
pub fn reset(&mut self) {
self.inner.reset();
}
#[napi(js_name = "isReady")]
pub fn is_ready(&self) -> bool {
self.inner.is_ready()
}
#[napi(js_name = "warmupPeriod")]
pub fn warmup_period(&self) -> u32 {
self.inner.warmup_period() as u32
}
}
#[napi(js_name = "EmpiricalModeDecomposition")]
pub struct EmpiricalModeDecompositionNode {
inner: wc::EmpiricalModeDecomposition,
}
#[napi]
impl EmpiricalModeDecompositionNode {
#[napi(constructor)]
pub fn new(period: u32, fraction: f64) -> napi::Result<Self> {
Ok(Self {
inner: wc::EmpiricalModeDecomposition::new(period as usize, fraction)
.map_err(map_err)?,
})
}
#[napi]
pub fn update(&mut self, value: f64) -> Option<f64> {
self.inner.update(value)
}
#[napi]
pub fn batch(&mut self, prices: Vec<f64>) -> Vec<f64> {
flatten(self.inner.batch(&prices))
}
#[napi]
pub fn reset(&mut self) {
self.inner.reset();
}
#[napi(js_name = "isReady")]
pub fn is_ready(&self) -> bool {
self.inner.is_ready()
}
#[napi(js_name = "warmupPeriod")]
pub fn warmup_period(&self) -> u32 {
self.inner.warmup_period() as u32
}
}
#[napi(js_name = "HilbertDominantCycle")]
pub struct HilbertDominantCycleNode {
inner: wc::HilbertDominantCycle,
}
#[napi]
impl HilbertDominantCycleNode {
#[napi(constructor)]
pub fn new() -> Self {
Self {
inner: wc::HilbertDominantCycle::new(),
}
}
#[napi]
pub fn update(&mut self, value: f64) -> Option<f64> {
self.inner.update(value)
}
#[napi]
pub fn batch(&mut self, prices: Vec<f64>) -> Vec<f64> {
flatten(self.inner.batch(&prices))
}
#[napi]
pub fn reset(&mut self) {
self.inner.reset();
}
#[napi(js_name = "isReady")]
pub fn is_ready(&self) -> bool {
self.inner.is_ready()
}
#[napi(js_name = "warmupPeriod")]
pub fn warmup_period(&self) -> u32 {
self.inner.warmup_period() as u32
}
}
#[napi(js_name = "AdaptiveCycle")]
pub struct AdaptiveCycleNode {
inner: wc::AdaptiveCycle,
}
#[napi]
impl AdaptiveCycleNode {
#[napi(constructor)]
pub fn new() -> Self {
Self {
inner: wc::AdaptiveCycle::new(),
}
}
#[napi]
pub fn update(&mut self, value: f64) -> Option<f64> {
self.inner.update(value)
}
#[napi]
pub fn batch(&mut self, prices: Vec<f64>) -> Vec<f64> {
flatten(self.inner.batch(&prices))
}
#[napi]
pub fn reset(&mut self) {
self.inner.reset();
}
#[napi(js_name = "isReady")]
pub fn is_ready(&self) -> bool {
self.inner.is_ready()
}
#[napi(js_name = "warmupPeriod")]
pub fn warmup_period(&self) -> u32 {
self.inner.warmup_period() as u32
}
}
#[napi(js_name = "SineWave")]
pub struct SineWaveNode {
inner: wc::SineWave,
}
#[napi]
impl SineWaveNode {
#[napi(constructor)]
pub fn new() -> Self {
Self {
inner: wc::SineWave::new(),
}
}
#[napi]
pub fn update(&mut self, value: f64) -> Option<f64> {
self.inner.update(value)
}
#[napi]
pub fn batch(&mut self, prices: Vec<f64>) -> Vec<f64> {
flatten(self.inner.batch(&prices))
}
#[napi]
pub fn lead(&self) -> f64 {
self.inner.lead()
}
#[napi]
pub fn reset(&mut self) {
self.inner.reset();
}
#[napi(js_name = "isReady")]
pub fn is_ready(&self) -> bool {
self.inner.is_ready()
}
#[napi(js_name = "warmupPeriod")]
pub fn warmup_period(&self) -> u32 {
self.inner.warmup_period() as u32
}
}
#[napi(object)]
pub struct MamaValue {
pub mama: f64,
pub fama: f64,
}
#[napi(js_name = "MAMA")]
pub struct MamaNode {
inner: wc::Mama,
}
#[napi]
impl MamaNode {
#[napi(constructor)]
pub fn new(fast_limit: f64, slow_limit: f64) -> napi::Result<Self> {
Ok(Self {
inner: wc::Mama::new(fast_limit, slow_limit).map_err(map_err)?,
})
}
#[napi]
pub fn update(&mut self, value: f64) -> Option<MamaValue> {
self.inner.update(value).map(|o| MamaValue {
mama: o.mama,
fama: o.fama,
})
}
/// Returns a flat array of length `2 * n`: `[mama0, fama0, mama1, fama1, ...]`.
#[napi]
pub fn batch(&mut self, prices: Vec<f64>) -> Vec<f64> {
let mut out = vec![f64::NAN; prices.len() * 2];
for (i, p) in prices.iter().enumerate() {
if let Some(o) = self.inner.update(*p) {
out[i * 2] = o.mama;
out[i * 2 + 1] = o.fama;
}
}
out
}
#[napi]
pub fn reset(&mut self) {
self.inner.reset();
}
#[napi(js_name = "isReady")]
pub fn is_ready(&self) -> bool {
self.inner.is_ready()
}
#[napi(js_name = "warmupPeriod")]
pub fn warmup_period(&self) -> u32 {
self.inner.warmup_period() as u32
}
}
#[napi(js_name = "FAMA")]
pub struct FamaNode {
inner: wc::Fama,
}
#[napi]
impl FamaNode {
#[napi(constructor)]
pub fn new(fast_limit: f64, slow_limit: f64) -> napi::Result<Self> {
Ok(Self {
inner: wc::Fama::new(fast_limit, slow_limit).map_err(map_err)?,
})
}
#[napi]
pub fn update(&mut self, value: f64) -> Option<f64> {
self.inner.update(value)
}
#[napi]
pub fn batch(&mut self, prices: Vec<f64>) -> Vec<f64> {
flatten(self.inner.batch(&prices))
}
#[napi]
pub fn reset(&mut self) {
self.inner.reset();
}
#[napi(js_name = "isReady")]
pub fn is_ready(&self) -> bool {
self.inner.is_ready()
}
#[napi(js_name = "warmupPeriod")]
pub fn warmup_period(&self) -> u32 {
self.inner.warmup_period() as u32
}
}
+34
View File
@@ -149,6 +149,23 @@ from ._wickra import (
LinRegSlope,
ZScore,
LinRegAngle,
# Ehlers / Cycle
SuperSmoother,
FisherTransform,
InverseFisherTransform,
Decycler,
DecyclerOscillator,
RoofingFilter,
CenterOfGravity,
CyberneticCycle,
InstantaneousTrendline,
EhlersStochastic,
EmpiricalModeDecomposition,
HilbertDominantCycle,
AdaptiveCycle,
SineWave,
MAMA,
FAMA,
# Bands & Channels
MaEnvelope,
AccelerationBands,
@@ -310,6 +327,23 @@ __all__ = [
"LinRegSlope",
"ZScore",
"LinRegAngle",
# Ehlers / Cycle
"SuperSmoother",
"FisherTransform",
"InverseFisherTransform",
"Decycler",
"DecyclerOscillator",
"RoofingFilter",
"CenterOfGravity",
"CyberneticCycle",
"InstantaneousTrendline",
"EhlersStochastic",
"EmpiricalModeDecomposition",
"HilbertDominantCycle",
"AdaptiveCycle",
"SineWave",
"MAMA",
"FAMA",
# Bands & Channels
"MaEnvelope",
"AccelerationBands",
+524
View File
@@ -9417,6 +9417,513 @@ impl PyTdRiskLevel {
}
}
// ============================== Ehlers / Cycle (Family 10) ==============================
macro_rules! py_scalar_one_period {
($wrapper:ident, $py_name:literal, $rust_ty:ty) => {
#[pyclass(name = $py_name, module = "wickra._wickra", skip_from_py_object)]
#[derive(Clone)]
struct $wrapper {
inner: $rust_ty,
}
#[pymethods]
impl $wrapper {
#[new]
fn new(period: usize) -> PyResult<Self> {
Ok(Self {
inner: <$rust_ty>::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 slice = prices
.as_slice()
.map_err(|_| PyValueError::new_err(NON_CONTIGUOUS))?;
Ok(flatten(self.inner.batch(slice)).into_pyarray(py))
}
#[getter]
fn period(&self) -> usize {
self.inner.period()
}
#[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 {
format!("{}(period={})", $py_name, self.inner.period())
}
}
};
}
py_scalar_one_period!(PySuperSmoother, "SuperSmoother", wc::SuperSmoother);
py_scalar_one_period!(PyFisherTransform, "FisherTransform", wc::FisherTransform);
py_scalar_one_period!(PyDecycler, "Decycler", wc::Decycler);
py_scalar_one_period!(PyCenterOfGravity, "CenterOfGravity", wc::CenterOfGravity);
py_scalar_one_period!(PyCyberneticCycle, "CyberneticCycle", wc::CyberneticCycle);
py_scalar_one_period!(
PyInstantaneousTrendline,
"InstantaneousTrendline",
wc::InstantaneousTrendline
);
py_scalar_one_period!(PyEhlersStochastic, "EhlersStochastic", wc::EhlersStochastic);
// --- InverseFisherTransform: single f64 `scale` param ---
#[pyclass(
name = "InverseFisherTransform",
module = "wickra._wickra",
skip_from_py_object
)]
#[derive(Clone)]
struct PyInverseFisherTransform {
inner: wc::InverseFisherTransform,
}
#[pymethods]
impl PyInverseFisherTransform {
#[new]
#[pyo3(signature = (scale=1.0))]
fn new(scale: f64) -> PyResult<Self> {
Ok(Self {
inner: wc::InverseFisherTransform::new(scale).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 slice = prices
.as_slice()
.map_err(|_| PyValueError::new_err(NON_CONTIGUOUS))?;
Ok(flatten(self.inner.batch(slice)).into_pyarray(py))
}
#[getter]
fn scale(&self) -> f64 {
self.inner.scale()
}
#[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 {
format!("InverseFisherTransform(scale={})", self.inner.scale())
}
}
// --- DecyclerOscillator: two-period ---
#[pyclass(
name = "DecyclerOscillator",
module = "wickra._wickra",
skip_from_py_object
)]
#[derive(Clone)]
struct PyDecyclerOscillator {
inner: wc::DecyclerOscillator,
}
#[pymethods]
impl PyDecyclerOscillator {
#[new]
fn new(fast: usize, slow: usize) -> PyResult<Self> {
Ok(Self {
inner: wc::DecyclerOscillator::new(fast, slow).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 slice = prices
.as_slice()
.map_err(|_| PyValueError::new_err(NON_CONTIGUOUS))?;
Ok(flatten(self.inner.batch(slice)).into_pyarray(py))
}
#[getter]
fn periods(&self) -> (usize, usize) {
self.inner.periods()
}
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 (f, s) = self.inner.periods();
format!("DecyclerOscillator(fast={f}, slow={s})")
}
}
// --- RoofingFilter: two-period (lp, hp) ---
#[pyclass(name = "RoofingFilter", module = "wickra._wickra", skip_from_py_object)]
#[derive(Clone)]
struct PyRoofingFilter {
inner: wc::RoofingFilter,
}
#[pymethods]
impl PyRoofingFilter {
#[new]
#[pyo3(signature = (lp_period=10, hp_period=48))]
fn new(lp_period: usize, hp_period: usize) -> PyResult<Self> {
Ok(Self {
inner: wc::RoofingFilter::new(lp_period, hp_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 slice = prices
.as_slice()
.map_err(|_| PyValueError::new_err(NON_CONTIGUOUS))?;
Ok(flatten(self.inner.batch(slice)).into_pyarray(py))
}
#[getter]
fn periods(&self) -> (usize, usize) {
self.inner.periods()
}
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 (lp, hp) = self.inner.periods();
format!("RoofingFilter(lp_period={lp}, hp_period={hp})")
}
}
// --- EmpiricalModeDecomposition: period + fraction ---
#[pyclass(
name = "EmpiricalModeDecomposition",
module = "wickra._wickra",
skip_from_py_object
)]
#[derive(Clone)]
struct PyEmd {
inner: wc::EmpiricalModeDecomposition,
}
#[pymethods]
impl PyEmd {
#[new]
#[pyo3(signature = (period=20, fraction=0.5))]
fn new(period: usize, fraction: f64) -> PyResult<Self> {
Ok(Self {
inner: wc::EmpiricalModeDecomposition::new(period, fraction).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 slice = prices
.as_slice()
.map_err(|_| PyValueError::new_err(NON_CONTIGUOUS))?;
Ok(flatten(self.inner.batch(slice)).into_pyarray(py))
}
#[getter]
fn period(&self) -> usize {
self.inner.period()
}
#[getter]
fn fraction(&self) -> f64 {
self.inner.fraction()
}
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!(
"EmpiricalModeDecomposition(period={}, fraction={})",
self.inner.period(),
self.inner.fraction()
)
}
}
// --- HilbertDominantCycle / SineWave / AdaptiveCycle: parameterless ---
macro_rules! py_no_params_scalar {
($wrapper:ident, $py_name:literal, $rust_ty:ty) => {
#[pyclass(name = $py_name, module = "wickra._wickra", skip_from_py_object)]
#[derive(Clone)]
struct $wrapper {
inner: $rust_ty,
}
#[pymethods]
impl $wrapper {
#[new]
fn new() -> Self {
Self {
inner: <$rust_ty>::new(),
}
}
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 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 {
format!("{}()", $py_name)
}
}
};
}
py_no_params_scalar!(
PyHilbertDominantCycle,
"HilbertDominantCycle",
wc::HilbertDominantCycle
);
py_no_params_scalar!(PyAdaptiveCycle, "AdaptiveCycle", wc::AdaptiveCycle);
// SineWave needs a `lead` accessor in addition to scalar value, but otherwise
// matches the parameterless surface.
#[pyclass(name = "SineWave", module = "wickra._wickra", skip_from_py_object)]
#[derive(Clone)]
struct PySineWave {
inner: wc::SineWave,
}
#[pymethods]
impl PySineWave {
#[new]
fn new() -> Self {
Self {
inner: wc::SineWave::new(),
}
}
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 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()
}
#[getter]
fn lead(&self) -> f64 {
self.inner.lead()
}
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 {
"SineWave()".to_string()
}
}
// --- MAMA: multi-output (mama, fama), shape (n, 2) ---
#[pyclass(name = "MAMA", module = "wickra._wickra", skip_from_py_object)]
#[derive(Clone)]
struct PyMama {
inner: wc::Mama,
}
#[pymethods]
impl PyMama {
#[new]
#[pyo3(signature = (fast_limit=0.5, slow_limit=0.05))]
fn new(fast_limit: f64, slow_limit: f64) -> PyResult<Self> {
Ok(Self {
inner: wc::Mama::new(fast_limit, slow_limit).map_err(map_err)?,
})
}
/// Returns `(mama, fama)` or `None` during warmup.
fn update(&mut self, value: f64) -> Option<(f64, f64)> {
self.inner.update(value).map(|o| (o.mama, o.fama))
}
/// Batch returns shape `(n, 2)` columns `[mama, fama]`. Warmup rows NaN.
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.mama;
out[i * 2 + 1] = o.fama;
}
}
Ok(numpy::ndarray::Array2::from_shape_vec((n, 2), out)
.expect("shape consistent")
.into_pyarray(py))
}
#[getter]
fn limits(&self) -> (f64, f64) {
self.inner.limits()
}
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 (f, s) = self.inner.limits();
format!("MAMA(fast_limit={f}, slow_limit={s})")
}
}
// --- FAMA: scalar wrapper exposing only the fama line ---
#[pyclass(name = "FAMA", module = "wickra._wickra", skip_from_py_object)]
#[derive(Clone)]
struct PyFama {
inner: wc::Fama,
}
#[pymethods]
impl PyFama {
#[new]
#[pyo3(signature = (fast_limit=0.5, slow_limit=0.05))]
fn new(fast_limit: f64, slow_limit: f64) -> PyResult<Self> {
Ok(Self {
inner: wc::Fama::new(fast_limit, slow_limit).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 slice = prices
.as_slice()
.map_err(|_| PyValueError::new_err(NON_CONTIGUOUS))?;
Ok(flatten(self.inner.batch(slice)).into_pyarray(py))
}
#[getter]
fn limits(&self) -> (f64, f64) {
self.inner.limits()
}
#[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 {
let (f, s) = self.inner.limits();
format!("FAMA(fast_limit={f}, slow_limit={s})")
}
}
// ============================== Module ==============================
#[pymodule]
@@ -9572,5 +10079,22 @@ fn _wickra(_py: Python<'_>, m: &Bound<'_, PyModule>) -> PyResult<()> {
m.add_class::<PyTdDifferential>()?;
m.add_class::<PyTdOpen>()?;
m.add_class::<PyTdRiskLevel>()?;
// Family 10 — Ehlers / Cycle
m.add_class::<PySuperSmoother>()?;
m.add_class::<PyFisherTransform>()?;
m.add_class::<PyInverseFisherTransform>()?;
m.add_class::<PyDecycler>()?;
m.add_class::<PyDecyclerOscillator>()?;
m.add_class::<PyRoofingFilter>()?;
m.add_class::<PyCenterOfGravity>()?;
m.add_class::<PyCyberneticCycle>()?;
m.add_class::<PyInstantaneousTrendline>()?;
m.add_class::<PyEhlersStochastic>()?;
m.add_class::<PyEmd>()?;
m.add_class::<PyHilbertDominantCycle>()?;
m.add_class::<PyAdaptiveCycle>()?;
m.add_class::<PySineWave>()?;
m.add_class::<PyMama>()?;
m.add_class::<PyFama>()?;
Ok(())
}
@@ -39,3 +39,20 @@ def test_roc_and_trix_have_default_periods():
# ROC/TRIX gained constructor defaults matching the TA-Lib convention.
assert ta.ROC().period == 10
assert ta.TRIX() is not None
def test_family_10_ehlers_rejects_invalid_parameters():
with pytest.raises(ValueError):
ta.SuperSmoother(0)
with pytest.raises(ValueError):
ta.FisherTransform(0)
with pytest.raises(ValueError):
ta.InverseFisherTransform(0.0)
with pytest.raises(ValueError):
ta.DecyclerOscillator(30, 10)
with pytest.raises(ValueError):
ta.RoofingFilter(48, 10)
with pytest.raises(ValueError):
ta.MAMA(0.05, 0.5)
with pytest.raises(ValueError):
ta.EmpiricalModeDecomposition(20, 0.0)
@@ -332,6 +332,36 @@ def test_obv_cumulative_known_sequence():
np.testing.assert_allclose(out, [0.0, 20.0, -10.0, -10.0, 0.0])
# --- Family 10 — Ehlers / Cycle reference values ---
def test_inverse_fisher_saturates_for_large_input():
# tanh(10) ~ 0.99999996; very close to +1 without exceeding.
v = ta.InverseFisherTransform(1.0).batch(np.array([10.0]))[0]
assert v < 1.0
assert v > 0.999
def test_super_smoother_constant_input_is_constant():
out = ta.SuperSmoother(20).batch(np.full(200, 50.0))
# Steady-state gain is 1, so a flat input stays flat.
np.testing.assert_allclose(out[-50:], 50.0, atol=1e-9)
def test_decycler_oscillator_flat_series_is_zero():
out = ta.DecyclerOscillator(10, 30).batch(np.full(80, 42.0))
ready = out[~np.isnan(out)]
np.testing.assert_allclose(ready, 0.0, atol=1e-9)
def test_mama_constant_series_both_lines_converge_to_price():
out = ta.MAMA().batch(np.full(200, 100.0))
last = out[-1]
# MAMA and FAMA both track price closely on a flat series.
assert abs(last[0] - 100.0) < 1.0
assert abs(last[1] - 100.0) < 1.0
# --- DeMark family ---------------------------------------------------------
+17
View File
@@ -86,3 +86,20 @@ def test_candle_tuple_input_supported():
atr.update((10.0, 11.0, 9.0, 10.5, 1.0, 0))
v = atr.update((10.5, 12.0, 10.0, 11.0, 1.0, 1))
assert v is not None
def test_ehlers_indicators_lifecycle():
# Spot-check a few Family-10 entries beyond what test_new_indicators covers.
series = np.linspace(1.0, 200.0, 200) + np.sin(np.arange(200) * 0.3) * 5.0
for ind in [
ta.SuperSmoother(10),
ta.FisherTransform(10),
ta.MAMA(),
ta.HilbertDominantCycle(),
ta.SineWave(),
]:
assert not ind.is_ready()
ind.batch(series)
assert ind.is_ready()
ind.reset()
assert not ind.is_ready()
@@ -79,6 +79,22 @@ SCALAR = [
(ta.LaguerreRSI, (0.5,)),
(ta.ConnorsRSI, (3, 2, 100)),
(ta.RVIVolatility, (10,)),
# Family 10 — Ehlers / Cycle scalar indicators
(ta.SuperSmoother, (10,)),
(ta.FisherTransform, (10,)),
(ta.InverseFisherTransform, (1.0,)),
(ta.Decycler, (20,)),
(ta.DecyclerOscillator, (10, 30)),
(ta.RoofingFilter, (10, 48)),
(ta.CenterOfGravity, (10,)),
(ta.CyberneticCycle, (10,)),
(ta.InstantaneousTrendline, (20,)),
(ta.EhlersStochastic, (20,)),
(ta.EmpiricalModeDecomposition, (20, 0.5)),
(ta.HilbertDominantCycle, ()),
(ta.AdaptiveCycle, ()),
(ta.SineWave, ()),
(ta.FAMA, (0.5, 0.05)),
]
@@ -434,6 +450,10 @@ MULTI_SCALAR_INPUT = {
lambda: ta.KST(10, 15, 20, 30, 10, 10, 10, 15, 9),
lambda ind, c: ind.batch(c),
),
"MAMA": (
lambda: ta.MAMA(0.5, 0.05),
lambda ind, c: ind.batch(c),
),
}
@@ -888,6 +908,54 @@ def test_z_score_reference():
assert out[1] == pytest.approx(1.0)
# --- Family 10 — Ehlers / Cycle ---
def test_mama_batch_shape_and_streaming_equivalence(sine_prices):
batch = ta.MAMA().batch(sine_prices)
assert batch.shape == (sine_prices.size, 2)
streamer = ta.MAMA()
rows = []
for p in sine_prices:
v = streamer.update(float(p))
rows.append([math.nan, math.nan] if v is None else list(v))
streamed = np.array(rows, dtype=np.float64)
assert _eq_nan(batch, streamed)
def test_inverse_fisher_transform_zero_input_yields_zero():
out = ta.InverseFisherTransform(1.0).batch(np.array([0.0, 0.0, 0.0]))
np.testing.assert_allclose(out, [0.0, 0.0, 0.0], atol=1e-12)
def test_fisher_transform_flat_series_is_zero():
# Zero range -> the normaliser yields 0, and tanh(0) chain stays at 0.
out = ta.FisherTransform(5).batch(np.full(20, 42.0))
ready = out[~np.isnan(out)]
assert np.all(np.abs(ready) < 1e-6)
def test_decycler_flat_series_passes_through():
# High-pass of a flat input is zero, so the decycler equals the input.
out = ta.Decycler(20).batch(np.full(30, 100.0))
ready = out[~np.isnan(out)]
np.testing.assert_allclose(ready, 100.0, atol=1e-9)
def test_center_of_gravity_flat_series_is_zero():
out = ta.CenterOfGravity(5).batch(np.full(20, 7.0))
ready = out[~np.isnan(out)]
np.testing.assert_allclose(ready, 0.0, atol=1e-12)
def test_super_smoother_first_two_outputs_equal_inputs():
out = ta.SuperSmoother(10).batch(np.array([100.0, 101.0, 102.0]))
# The 2-pole filter is seeded with raw values for the first two bars.
assert out[0] == pytest.approx(100.0)
assert out[1] == pytest.approx(101.0)
def test_td_setup_pure_uptrend_reaches_minus_9():
# Every close is strictly greater than four bars ago -> sell-setup -9.
h = np.arange(2.0, 22.0)
+10
View File
@@ -55,3 +55,13 @@ def test_obv_batch_shape(ohlc_series):
volume = np.ones_like(close)
out = ta.OBV().batch(close, volume)
assert out.shape == close.shape
def test_ehlers_super_smoother_batch_shape(sine_prices):
out = ta.SuperSmoother(10).batch(sine_prices)
assert out.shape == sine_prices.shape
def test_mama_batch_shape(sine_prices):
out = ta.MAMA().batch(sine_prices)
assert out.shape == (sine_prices.size, 2)
@@ -117,6 +117,30 @@ def test_obv_streaming_matches_batch(ohlc_series):
assert _equal_with_nan(batch, streamed)
def test_mama_streaming_matches_batch(sine_prices):
batch = ta.MAMA().batch(sine_prices)
streamer = ta.MAMA()
rows = []
for p in sine_prices:
v = streamer.update(float(p))
if v is None:
rows.append([math.nan, math.nan])
else:
rows.append(list(v))
streamed = np.array(rows, dtype=np.float64)
assert _equal_with_nan(batch, streamed)
def test_super_smoother_streaming_matches_batch(sine_prices):
batch = ta.SuperSmoother(10).batch(sine_prices)
streamer = ta.SuperSmoother(10)
streamed = np.array(
[math.nan if (v := streamer.update(float(p))) is None else float(v) for p in sine_prices],
dtype=np.float64,
)
assert _equal_with_nan(batch, streamed)
def test_rolling_vwap_streaming_matches_batch(ohlc_series):
# RollingVWAP(20) on the shared OHLC series. Provides finite-memory VWAP
# parity coverage now that the indicator is exposed across all bindings.
+168
View File
@@ -448,6 +448,20 @@ impl WasmParkinsonVolatility {
}
}
// Family 10 — Ehlers / Cycle scalars
wasm_scalar_indicator!(WasmSuperSmoother, "SuperSmoother", wc::SuperSmoother, period: usize);
wasm_scalar_indicator!(WasmFisherTransform, "FisherTransform", wc::FisherTransform, period: usize);
wasm_scalar_indicator!(WasmInverseFisherTransform, "InverseFisherTransform", wc::InverseFisherTransform, scale: f64);
wasm_scalar_indicator!(WasmDecycler, "Decycler", wc::Decycler, period: usize);
wasm_scalar_indicator!(WasmDecyclerOscillator, "DecyclerOscillator", wc::DecyclerOscillator, fast: usize, slow: usize);
wasm_scalar_indicator!(WasmRoofingFilter, "RoofingFilter", wc::RoofingFilter, lp_period: usize, hp_period: usize);
wasm_scalar_indicator!(WasmCenterOfGravity, "CenterOfGravity", wc::CenterOfGravity, period: usize);
wasm_scalar_indicator!(WasmCyberneticCycle, "CyberneticCycle", wc::CyberneticCycle, period: usize);
wasm_scalar_indicator!(WasmInstantaneousTrendline, "InstantaneousTrendline", wc::InstantaneousTrendline, period: usize);
wasm_scalar_indicator!(WasmEhlersStochastic, "EhlersStochastic", wc::EhlersStochastic, period: usize);
wasm_scalar_indicator!(WasmEmpiricalModeDecomposition, "EmpiricalModeDecomposition", wc::EmpiricalModeDecomposition, period: usize, fraction: f64);
wasm_scalar_indicator!(WasmFama, "FAMA", wc::Fama, fast_limit: f64, slow_limit: f64);
// ---------- KAMA (three params) ----------
#[wasm_bindgen(js_name = KAMA)]
@@ -3486,6 +3500,159 @@ impl WasmAroon {
}
}
// ============================== Family 10: parameterless / multi-output ==============================
#[wasm_bindgen(js_name = HilbertDominantCycle)]
pub struct WasmHilbertDominantCycle {
inner: wc::HilbertDominantCycle,
}
#[wasm_bindgen(js_class = HilbertDominantCycle)]
impl WasmHilbertDominantCycle {
#[wasm_bindgen(constructor)]
#[allow(clippy::new_without_default)]
pub fn new() -> WasmHilbertDominantCycle {
Self {
inner: wc::HilbertDominantCycle::new(),
}
}
pub fn update(&mut self, value: f64) -> Option<f64> {
self.inner.update(value)
}
pub fn batch(&mut self, prices: &[f64]) -> Float64Array {
Float64Array::from(flatten(self.inner.batch(prices)).as_slice())
}
pub fn reset(&mut self) {
self.inner.reset();
}
#[wasm_bindgen(js_name = isReady)]
pub fn is_ready(&self) -> bool {
self.inner.is_ready()
}
#[wasm_bindgen(js_name = warmupPeriod)]
pub fn warmup_period(&self) -> usize {
self.inner.warmup_period()
}
}
#[wasm_bindgen(js_name = AdaptiveCycle)]
pub struct WasmAdaptiveCycle {
inner: wc::AdaptiveCycle,
}
#[wasm_bindgen(js_class = AdaptiveCycle)]
impl WasmAdaptiveCycle {
#[wasm_bindgen(constructor)]
#[allow(clippy::new_without_default)]
pub fn new() -> WasmAdaptiveCycle {
Self {
inner: wc::AdaptiveCycle::new(),
}
}
pub fn update(&mut self, value: f64) -> Option<f64> {
self.inner.update(value)
}
pub fn batch(&mut self, prices: &[f64]) -> Float64Array {
Float64Array::from(flatten(self.inner.batch(prices)).as_slice())
}
pub fn reset(&mut self) {
self.inner.reset();
}
#[wasm_bindgen(js_name = isReady)]
pub fn is_ready(&self) -> bool {
self.inner.is_ready()
}
#[wasm_bindgen(js_name = warmupPeriod)]
pub fn warmup_period(&self) -> usize {
self.inner.warmup_period()
}
}
#[wasm_bindgen(js_name = SineWave)]
pub struct WasmSineWave {
inner: wc::SineWave,
}
#[wasm_bindgen(js_class = SineWave)]
impl WasmSineWave {
#[wasm_bindgen(constructor)]
#[allow(clippy::new_without_default)]
pub fn new() -> WasmSineWave {
Self {
inner: wc::SineWave::new(),
}
}
pub fn update(&mut self, value: f64) -> Option<f64> {
self.inner.update(value)
}
pub fn batch(&mut self, prices: &[f64]) -> Float64Array {
Float64Array::from(flatten(self.inner.batch(prices)).as_slice())
}
pub fn lead(&self) -> f64 {
self.inner.lead()
}
pub fn reset(&mut self) {
self.inner.reset();
}
#[wasm_bindgen(js_name = isReady)]
pub fn is_ready(&self) -> bool {
self.inner.is_ready()
}
#[wasm_bindgen(js_name = warmupPeriod)]
pub fn warmup_period(&self) -> usize {
self.inner.warmup_period()
}
}
#[wasm_bindgen(js_name = MAMA)]
pub struct WasmMama {
inner: wc::Mama,
}
#[wasm_bindgen(js_class = MAMA)]
impl WasmMama {
#[wasm_bindgen(constructor)]
pub fn new(fast_limit: f64, slow_limit: f64) -> Result<WasmMama, JsError> {
Ok(Self {
inner: wc::Mama::new(fast_limit, slow_limit).map_err(map_err)?,
})
}
pub fn update(&mut self, value: f64) -> JsValue {
match self.inner.update(value) {
Some(o) => {
let obj = Object::new();
Reflect::set(&obj, &"mama".into(), &o.mama.into()).ok();
Reflect::set(&obj, &"fama".into(), &o.fama.into()).ok();
obj.into()
}
None => JsValue::NULL,
}
}
/// Returns a flat `Float64Array` of length `2 * n`: `[mama0, fama0, mama1, fama1, ...]`.
pub fn batch(&mut self, prices: &[f64]) -> Float64Array {
let n = prices.len();
let mut out = vec![f64::NAN; n * 2];
for (i, p) in prices.iter().enumerate() {
if let Some(o) = self.inner.update(*p) {
out[i * 2] = o.mama;
out[i * 2 + 1] = o.fama;
}
}
Float64Array::from(out.as_slice())
}
pub fn reset(&mut self) {
self.inner.reset();
}
#[wasm_bindgen(js_name = isReady)]
pub fn is_ready(&self) -> bool {
self.inner.is_ready()
}
#[wasm_bindgen(js_name = warmupPeriod)]
pub fn warmup_period(&self) -> usize {
self.inner.warmup_period()
}
}
// ============================== Family 05: Bands & Channels ==============================
// Every indicator below is multi-output (2-5 bands), so they bypass the
@@ -5335,6 +5502,7 @@ impl WasmTdRiskLevel {
self.inner.warmup_period()
}
}
#[cfg(test)]
mod tests {
use super::*;
@@ -0,0 +1,143 @@
//! Ehlers Adaptive Cycle period estimator (for adaptive oscillators).
use crate::indicators::hilbert_dominant_cycle::HilbertDominantCycle;
use crate::traits::Indicator;
/// Ehlers' Adaptive Cycle Indicator.
///
/// Returns half the current dominant cycle period — the "best" lookback for
/// downstream oscillators like an adaptive RSI or adaptive Stochastic, per
/// Ehlers' *Cycle Analytics for Traders* (2013, ch. 11). Halving accounts for
/// the fact that an oscillator over a half-cycle captures the full peak-to-
/// trough swing without aliasing.
///
/// The output is rounded to an integer-valued `f64` and clamped to `[3, 25]`,
/// matching the typical operating range of period-adaptive oscillators.
///
/// # Example
///
/// ```
/// use wickra_core::{Indicator, AdaptiveCycle};
///
/// let mut ac = AdaptiveCycle::new();
/// let mut last = None;
/// for i in 0..200 {
/// last = ac.update(100.0 + (f64::from(i) * 0.4).sin() * 5.0);
/// }
/// assert!(last.is_some());
/// ```
#[derive(Debug, Clone, Default)]
pub struct AdaptiveCycle {
cycle: HilbertDominantCycle,
last_value: Option<f64>,
}
impl AdaptiveCycle {
/// Construct a new adaptive cycle estimator.
pub fn new() -> Self {
Self::default()
}
/// Current adaptive period if available.
pub const fn value(&self) -> Option<f64> {
self.last_value
}
}
impl Indicator for AdaptiveCycle {
type Input = f64;
type Output = f64;
fn update(&mut self, input: f64) -> Option<f64> {
let period = self.cycle.update(input)?;
let half = (period * 0.5).round().clamp(3.0, 25.0);
self.last_value = Some(half);
Some(half)
}
fn reset(&mut self) {
self.cycle.reset();
self.last_value = None;
}
fn warmup_period(&self) -> usize {
self.cycle.warmup_period()
}
fn is_ready(&self) -> bool {
self.last_value.is_some()
}
fn name(&self) -> &'static str {
"AdaptiveCycle"
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::traits::BatchExt;
#[test]
fn accessors_and_metadata() {
let mut ac = AdaptiveCycle::new();
assert_eq!(ac.warmup_period(), 50);
assert_eq!(ac.name(), "AdaptiveCycle");
assert!(!ac.is_ready());
assert!(ac.value().is_none());
let prices: Vec<f64> = (0..120)
.map(|i| 100.0 + (f64::from(i) * 0.4).sin() * 5.0)
.collect();
ac.batch(&prices);
assert!(ac.is_ready());
assert!(ac.value().is_some());
}
#[test]
fn output_within_clamp_band() {
let prices: Vec<f64> = (0..200)
.map(|i| 100.0 + (f64::from(i) * 0.5).sin() * 5.0)
.collect();
let mut ac = AdaptiveCycle::new();
for v in ac.batch(&prices).into_iter().flatten() {
assert!((3.0..=25.0).contains(&v), "period {v} out of band");
assert_eq!(v, v.round(), "expected integer-valued output");
}
}
#[test]
fn batch_equals_streaming() {
let prices: Vec<f64> = (0..200)
.map(|i| 100.0 + (f64::from(i) * 0.3).sin() * 5.0)
.collect();
let mut a = AdaptiveCycle::new();
let mut b = AdaptiveCycle::new();
let batch = a.batch(&prices);
let streamed: Vec<_> = prices.iter().map(|p| b.update(*p)).collect();
assert_eq!(batch, streamed);
}
#[test]
fn ignores_non_finite_input() {
let mut ac = AdaptiveCycle::new();
let prices: Vec<f64> = (0..120)
.map(|i| 100.0 + (f64::from(i) * 0.4).sin() * 5.0)
.collect();
ac.batch(&prices);
let before = ac.value();
assert!(before.is_some());
assert_eq!(ac.update(f64::NAN), before);
}
#[test]
fn reset_clears_state() {
let mut ac = AdaptiveCycle::new();
let prices: Vec<f64> = (0..120)
.map(|i| 100.0 + (f64::from(i) * 0.4).sin() * 5.0)
.collect();
ac.batch(&prices);
assert!(ac.is_ready());
ac.reset();
assert!(!ac.is_ready());
}
}
@@ -0,0 +1,193 @@
//! Ehlers Center of Gravity Oscillator.
#![allow(clippy::manual_midpoint)]
use std::collections::VecDeque;
use crate::error::{Error, Result};
use crate::traits::Indicator;
/// Ehlers' Center of Gravity (CG) oscillator.
///
/// Treats the most recent `period` prices as masses and reports the
/// weighted "center" of that mass distribution, negated so positive readings
/// correspond to recent strength:
///
/// ```text
/// num = sum_{k=0..period-1} (1 + k) * price[t - k]
/// den = sum_{k=0..period-1} price[t - k]
/// cg = - num / den + (period + 1) / 2
/// ```
///
/// The constant offset centres the oscillator around zero. From Ehlers,
/// *Cybernetic Analysis for Stocks and Futures* (2004, ch. 7).
///
/// # Example
///
/// ```
/// use wickra_core::{Indicator, CenterOfGravity};
///
/// let mut cg = CenterOfGravity::new(10).unwrap();
/// let mut last = None;
/// for i in 0..30 {
/// last = cg.update(100.0 + (f64::from(i) * 0.2).sin() * 5.0);
/// }
/// assert!(last.is_some());
/// ```
#[derive(Debug, Clone)]
pub struct CenterOfGravity {
period: usize,
window: VecDeque<f64>,
last_value: Option<f64>,
}
impl CenterOfGravity {
/// Construct with the rolling window length.
///
/// # Errors
///
/// Returns [`Error::PeriodZero`] if `period == 0`.
pub fn new(period: usize) -> Result<Self> {
if period == 0 {
return Err(Error::PeriodZero);
}
Ok(Self {
period,
window: VecDeque::with_capacity(period),
last_value: None,
})
}
/// Configured period.
pub const fn period(&self) -> usize {
self.period
}
/// Current value if available.
pub const fn value(&self) -> Option<f64> {
self.last_value
}
}
impl Indicator for CenterOfGravity {
type Input = f64;
type Output = f64;
fn update(&mut self, input: f64) -> Option<f64> {
if !input.is_finite() {
return self.last_value;
}
if self.window.len() == self.period {
self.window.pop_front();
}
self.window.push_back(input);
if self.window.len() < self.period {
return None;
}
// Most recent has weight 1; oldest has weight `period`.
let mut num = 0.0;
let mut den = 0.0;
for (k, p) in self.window.iter().rev().enumerate() {
let w = 1.0 + k as f64;
num += w * p;
den += p;
}
let v = if den.abs() > f64::EPSILON {
-num / den + (self.period as f64 + 1.0) / 2.0
} else {
0.0
};
self.last_value = Some(v);
Some(v)
}
fn reset(&mut self) {
self.window.clear();
self.last_value = None;
}
fn warmup_period(&self) -> usize {
self.period
}
fn is_ready(&self) -> bool {
self.last_value.is_some()
}
fn name(&self) -> &'static str {
"CenterOfGravity"
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::traits::BatchExt;
use approx::assert_relative_eq;
#[test]
fn new_rejects_zero_period() {
assert!(matches!(CenterOfGravity::new(0), Err(Error::PeriodZero)));
}
#[test]
fn accessors_and_metadata() {
let mut cg = CenterOfGravity::new(10).unwrap();
assert_eq!(cg.period(), 10);
assert_eq!(cg.warmup_period(), 10);
assert_eq!(cg.name(), "CenterOfGravity");
assert!(!cg.is_ready());
for i in 1..=10 {
cg.update(f64::from(i));
}
assert!(cg.is_ready());
assert!(cg.value().is_some());
}
#[test]
fn constant_series_yields_zero() {
// num = sum k * p, den = period * p, ratio = (period + 1) / 2,
// so cg = - (period+1)/2 + (period+1)/2 = 0.
let mut cg = CenterOfGravity::new(5).unwrap();
let out = cg.batch(&[7.0_f64; 30]);
for x in out.iter().skip(5).flatten() {
assert_relative_eq!(*x, 0.0, epsilon = 1e-12);
}
}
#[test]
fn batch_equals_streaming() {
let prices: Vec<f64> = (1..=50).map(f64::from).collect();
let mut a = CenterOfGravity::new(10).unwrap();
let mut b = CenterOfGravity::new(10).unwrap();
let batch = a.batch(&prices);
let streamed: Vec<_> = prices.iter().map(|p| b.update(*p)).collect();
assert_eq!(batch, streamed);
}
#[test]
fn ignores_non_finite_input() {
let mut cg = CenterOfGravity::new(5).unwrap();
cg.batch(&(1..=10).map(f64::from).collect::<Vec<_>>());
let before = cg.value();
assert!(before.is_some());
assert_eq!(cg.update(f64::NAN), before);
}
#[test]
fn reset_clears_state() {
let mut cg = CenterOfGravity::new(5).unwrap();
cg.batch(&(1..=10).map(f64::from).collect::<Vec<_>>());
assert!(cg.is_ready());
cg.reset();
assert!(!cg.is_ready());
}
#[test]
fn warmup_returns_none_until_seed() {
let mut cg = CenterOfGravity::new(4).unwrap();
assert_eq!(cg.update(1.0), None);
assert_eq!(cg.update(2.0), None);
assert_eq!(cg.update(3.0), None);
assert!(cg.update(4.0).is_some());
}
}
@@ -0,0 +1,240 @@
//! Ehlers Cybernetic Cycle Component.
#![allow(clippy::doc_markdown)]
use crate::error::{Error, Result};
use crate::traits::Indicator;
/// Ehlers' Cybernetic Cycle Component (CCC).
///
/// Classic EasyLanguage construct from *Cybernetic Analysis for Stocks and
/// Futures* (Ehlers 2004, ch. 4):
///
/// ```text
/// smooth[t] = (x[t] + 2*x[t-1] + 2*x[t-2] + x[t-3]) / 6
/// cycle[t] = (1 - alpha/2)^2 * (smooth[t] - 2*smooth[t-1] + smooth[t-2])
/// + 2 * (1 - alpha) * cycle[t-1]
/// - (1 - alpha)^2 * cycle[t-2]
/// ```
///
/// The result is a near-zero-mean oscillator that tracks the dominant cycle
/// component while filtering trend. `alpha` is a smoothing fraction in
/// `(0, 1]`; Ehlers recommends `2 / (period + 1)` for a given critical period.
///
/// The first six outputs follow Ehlers' "use the input directly" initial
/// condition so downstream consumers stay reactive.
///
/// # Example
///
/// ```
/// use wickra_core::{Indicator, CyberneticCycle};
///
/// let mut cc = CyberneticCycle::new(10).unwrap();
/// let mut last = None;
/// for i in 0..30 {
/// last = cc.update(100.0 + (f64::from(i) * 0.3).sin() * 5.0);
/// }
/// assert!(last.is_some());
/// ```
#[derive(Debug, Clone)]
pub struct CyberneticCycle {
period: usize,
alpha: f64,
in_buf: [Option<f64>; 4],
smooth_buf: [Option<f64>; 3],
cycle_buf: [Option<f64>; 3],
count: usize,
last_value: Option<f64>,
}
impl CyberneticCycle {
/// Construct with the dominant-cycle period (alpha = 2 / (period + 1)).
///
/// # Errors
///
/// Returns [`Error::PeriodZero`] if `period == 0`.
pub fn new(period: usize) -> Result<Self> {
if period == 0 {
return Err(Error::PeriodZero);
}
let alpha = 2.0 / (period as f64 + 1.0);
Ok(Self {
period,
alpha,
in_buf: [None; 4],
smooth_buf: [None; 3],
cycle_buf: [None; 3],
count: 0,
last_value: None,
})
}
/// Configured period.
pub const fn period(&self) -> usize {
self.period
}
/// Smoothing alpha.
pub const fn alpha(&self) -> f64 {
self.alpha
}
/// Current value if available.
pub const fn value(&self) -> Option<f64> {
self.last_value
}
/// Shift in `x` at position 0 of a 3-slot buffer.
fn push3(buf: &mut [Option<f64>; 3], x: f64) {
buf[2] = buf[1];
buf[1] = buf[0];
buf[0] = Some(x);
}
fn push4(buf: &mut [Option<f64>; 4], x: f64) {
buf[3] = buf[2];
buf[2] = buf[1];
buf[1] = buf[0];
buf[0] = Some(x);
}
}
impl Indicator for CyberneticCycle {
type Input = f64;
type Output = f64;
fn update(&mut self, input: f64) -> Option<f64> {
if !input.is_finite() {
return self.last_value;
}
self.count += 1;
Self::push4(&mut self.in_buf, input);
// Smooth needs four prior inputs (positions 0..=3).
let smooth = if let (Some(a), Some(b), Some(c), Some(d)) = (
self.in_buf[0],
self.in_buf[1],
self.in_buf[2],
self.in_buf[3],
) {
(a + 2.0 * b + 2.0 * c + d) / 6.0
} else {
// Initial condition: use the raw input.
input
};
Self::push3(&mut self.smooth_buf, smooth);
// Cycle needs two prior smooths and two prior cycles.
let one_minus_half_alpha = 1.0 - self.alpha / 2.0;
let one_minus_alpha = 1.0 - self.alpha;
let drv = one_minus_half_alpha * one_minus_half_alpha;
let cycle = if let (Some(s0), Some(s1), Some(s2), Some(c1), Some(c2)) = (
self.smooth_buf[0],
self.smooth_buf[1],
self.smooth_buf[2],
self.cycle_buf[0],
self.cycle_buf[1],
) {
drv * (s0 - 2.0 * s1 + s2) + 2.0 * one_minus_alpha * c1
- one_minus_alpha * one_minus_alpha * c2
} else if self.count < 7 {
// Ehlers initial condition: cycle starts as the second-difference
// of the raw input series, scaled by 0.5 (matches the EasyLanguage
// implementation's first-bar fallback).
let (x0, x1, x2) = (
self.in_buf[0].unwrap_or(input),
self.in_buf[1].unwrap_or(input),
self.in_buf[2].unwrap_or(input),
);
(x0 - 2.0 * x1 + x2) / 4.0
} else {
0.0
};
Self::push3(&mut self.cycle_buf, cycle);
self.last_value = Some(cycle);
Some(cycle)
}
fn reset(&mut self) {
self.in_buf = [None; 4];
self.smooth_buf = [None; 3];
self.cycle_buf = [None; 3];
self.count = 0;
self.last_value = None;
}
fn warmup_period(&self) -> usize {
1
}
fn is_ready(&self) -> bool {
self.last_value.is_some()
}
fn name(&self) -> &'static str {
"CyberneticCycle"
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::traits::BatchExt;
use approx::assert_relative_eq;
#[test]
fn new_rejects_zero_period() {
assert!(matches!(CyberneticCycle::new(0), Err(Error::PeriodZero)));
}
#[test]
fn accessors_and_metadata() {
let mut cc = CyberneticCycle::new(10).unwrap();
assert_eq!(cc.period(), 10);
assert_relative_eq!(cc.alpha(), 2.0 / 11.0, epsilon = 1e-15);
assert_eq!(cc.warmup_period(), 1);
assert_eq!(cc.name(), "CyberneticCycle");
assert!(!cc.is_ready());
cc.update(100.0);
assert!(cc.is_ready());
}
#[test]
fn constant_series_converges_to_zero() {
let mut cc = CyberneticCycle::new(10).unwrap();
let out = cc.batch(&[50.0_f64; 200]);
for x in out.iter().skip(50).flatten() {
assert_relative_eq!(*x, 0.0, epsilon = 1e-9);
}
}
#[test]
fn batch_equals_streaming() {
let prices: Vec<f64> = (0..120)
.map(|i| 100.0 + (f64::from(i) * 0.25).sin() * 5.0)
.collect();
let mut a = CyberneticCycle::new(15).unwrap();
let mut b = CyberneticCycle::new(15).unwrap();
let batch = a.batch(&prices);
let streamed: Vec<_> = prices.iter().map(|p| b.update(*p)).collect();
assert_eq!(batch, streamed);
}
#[test]
fn ignores_non_finite_input() {
let mut cc = CyberneticCycle::new(10).unwrap();
cc.batch(&(1..=30).map(f64::from).collect::<Vec<_>>());
let before = cc.value();
assert!(before.is_some());
assert_eq!(cc.update(f64::NAN), before);
}
#[test]
fn reset_clears_state() {
let mut cc = CyberneticCycle::new(10).unwrap();
cc.batch(&(1..=30).map(f64::from).collect::<Vec<_>>());
assert!(cc.is_ready());
cc.reset();
assert!(!cc.is_ready());
}
}
@@ -0,0 +1,213 @@
//! Ehlers Decycler (single-pole high-pass complement).
use std::f64::consts::PI;
use crate::error::{Error, Result};
use crate::traits::Indicator;
/// Ehlers' Decycler: price minus the dominant cycle component.
///
/// Implemented as `decycler = input - HP(input)`, where `HP` is a 2-pole
/// high-pass filter with critical period `period`. Subtracting the high-pass
/// from the raw price leaves the slow component — equivalent to a smoothed
/// trend line with no group delay at low frequencies. From *Cycle Analytics
/// for Traders* (Ehlers 2013, ch. 4).
///
/// The high-pass uses the standard 2-pole formulation:
///
/// ```text
/// alpha = (cos(.707*2*pi/period) + sin(.707*2*pi/period) - 1) / cos(.707*2*pi/period)
/// HP[t] = (1 - alpha/2)^2 * (x[t] - 2*x[t-1] + x[t-2])
/// + 2*(1 - alpha) * HP[t-1]
/// - (1 - alpha)^2 * HP[t-2]
/// ```
///
/// The first two outputs simply equal the input (warmup buffering), which is
/// the conventional Ehlers initialisation and keeps downstream consumers
/// reactive while the recursion fills.
///
/// # Example
///
/// ```
/// use wickra_core::{Indicator, Decycler};
///
/// let mut dc = Decycler::new(20).unwrap();
/// let mut last = None;
/// for i in 0..50 {
/// last = dc.update(100.0 + f64::from(i) * 0.5);
/// }
/// assert!(last.is_some());
/// ```
#[derive(Debug, Clone)]
pub struct Decycler {
period: usize,
alpha: f64,
prev_in_1: Option<f64>,
prev_in_2: Option<f64>,
prev_hp_1: f64,
prev_hp_2: f64,
last_value: Option<f64>,
}
impl Decycler {
/// Construct a Decycler with the given critical period for the high-pass filter.
///
/// # Errors
///
/// Returns [`Error::PeriodZero`] if `period == 0`.
pub fn new(period: usize) -> Result<Self> {
if period == 0 {
return Err(Error::PeriodZero);
}
let arg = 0.707 * 2.0 * PI / period as f64;
let c = arg.cos();
let alpha = (c + arg.sin() - 1.0) / c;
Ok(Self {
period,
alpha,
prev_in_1: None,
prev_in_2: None,
prev_hp_1: 0.0,
prev_hp_2: 0.0,
last_value: None,
})
}
/// Configured period.
pub const fn period(&self) -> usize {
self.period
}
/// High-pass `alpha` coefficient derived from the period.
pub const fn alpha(&self) -> f64 {
self.alpha
}
/// Current decycler value if available.
pub const fn value(&self) -> Option<f64> {
self.last_value
}
/// Compute and store the high-pass output for the latest input.
fn step_hp(&mut self, input: f64) -> f64 {
let (Some(x1), Some(x2)) = (self.prev_in_1, self.prev_in_2) else {
self.prev_hp_2 = self.prev_hp_1;
self.prev_hp_1 = 0.0;
return 0.0;
};
let one_minus_half_alpha = 1.0 - self.alpha / 2.0;
let one_minus_alpha = 1.0 - self.alpha;
let drv = one_minus_half_alpha * one_minus_half_alpha;
let term1 = drv * (input - 2.0 * x1 + x2);
let term2 = 2.0 * one_minus_alpha * self.prev_hp_1;
let term3 = one_minus_alpha * one_minus_alpha * self.prev_hp_2;
let hp = term1 + term2 - term3;
self.prev_hp_2 = self.prev_hp_1;
self.prev_hp_1 = hp;
hp
}
}
impl Indicator for Decycler {
type Input = f64;
type Output = f64;
fn update(&mut self, input: f64) -> Option<f64> {
if !input.is_finite() {
return self.last_value;
}
let hp = self.step_hp(input);
let v = input - hp;
self.prev_in_2 = self.prev_in_1;
self.prev_in_1 = Some(input);
self.last_value = Some(v);
Some(v)
}
fn reset(&mut self) {
self.prev_in_1 = None;
self.prev_in_2 = None;
self.prev_hp_1 = 0.0;
self.prev_hp_2 = 0.0;
self.last_value = None;
}
fn warmup_period(&self) -> usize {
1
}
fn is_ready(&self) -> bool {
self.last_value.is_some()
}
fn name(&self) -> &'static str {
"Decycler"
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::traits::BatchExt;
use approx::assert_relative_eq;
#[test]
fn new_rejects_zero_period() {
assert!(matches!(Decycler::new(0), Err(Error::PeriodZero)));
}
#[test]
fn accessors_and_metadata() {
let mut dc = Decycler::new(20).unwrap();
assert_eq!(dc.period(), 20);
assert_eq!(dc.warmup_period(), 1);
assert_eq!(dc.name(), "Decycler");
assert!(dc.alpha() > 0.0 && dc.alpha() < 1.0);
assert!(!dc.is_ready());
dc.update(100.0);
assert!(dc.is_ready());
assert!(dc.value().is_some());
}
#[test]
fn constant_series_passes_through() {
// For a flat input, the high-pass output is zero, so the decycler
// equals the input.
let mut dc = Decycler::new(20).unwrap();
let out = dc.batch(&[42.0_f64; 80]);
for x in out.iter().flatten() {
assert_relative_eq!(*x, 42.0, epsilon = 1e-9);
}
}
#[test]
fn batch_equals_streaming() {
let prices: Vec<f64> = (0..100)
.map(|i| 100.0 + (f64::from(i) * 0.15).sin() * 5.0)
.collect();
let mut a = Decycler::new(20).unwrap();
let mut b = Decycler::new(20).unwrap();
let batch = a.batch(&prices);
let streamed: Vec<_> = prices.iter().map(|p| b.update(*p)).collect();
assert_eq!(batch, streamed);
}
#[test]
fn ignores_non_finite_input() {
let mut dc = Decycler::new(20).unwrap();
dc.batch(&(1..=30).map(f64::from).collect::<Vec<_>>());
let before = dc.value();
assert!(before.is_some());
assert_eq!(dc.update(f64::NAN), before);
assert_eq!(dc.update(f64::INFINITY), before);
}
#[test]
fn reset_clears_state() {
let mut dc = Decycler::new(20).unwrap();
dc.batch(&(1..=40).map(f64::from).collect::<Vec<_>>());
assert!(dc.is_ready());
dc.reset();
assert!(!dc.is_ready());
}
}
@@ -0,0 +1,179 @@
//! Ehlers Decycler Oscillator (difference of two decyclers).
use crate::error::{Error, Result};
use crate::indicators::decycler::Decycler;
use crate::traits::Indicator;
/// Difference between a fast and a slow [`Decycler`], producing a smoothed
/// oscillator that crosses zero at trend changes.
///
/// Defined as `fast_decycler - slow_decycler` with `fast_period < slow_period`.
/// The construct removes the trend component that both decyclers share, leaving
/// the medium-frequency cycle band — analogous in spirit to MACD but with
/// Ehlers' zero-lag high-pass filters instead of EMAs.
///
/// # Example
///
/// ```
/// use wickra_core::{Indicator, DecyclerOscillator};
///
/// let mut dco = DecyclerOscillator::new(10, 30).unwrap();
/// let mut last = None;
/// for i in 0..60 {
/// last = dco.update(100.0 + (f64::from(i) * 0.3).sin() * 5.0);
/// }
/// assert!(last.is_some());
/// ```
#[derive(Debug, Clone)]
pub struct DecyclerOscillator {
fast: Decycler,
slow: Decycler,
last_value: Option<f64>,
}
impl DecyclerOscillator {
/// Construct with the fast and slow periods.
///
/// # Errors
///
/// Returns [`Error::PeriodZero`] if either period is zero, and
/// [`Error::InvalidPeriod`] if `fast >= slow`.
pub fn new(fast: usize, slow: usize) -> Result<Self> {
if fast == 0 || slow == 0 {
return Err(Error::PeriodZero);
}
if fast >= slow {
return Err(Error::InvalidPeriod {
message: "fast period must be strictly less than slow period",
});
}
Ok(Self {
fast: Decycler::new(fast)?,
slow: Decycler::new(slow)?,
last_value: None,
})
}
/// Configured `(fast, slow)` periods.
pub fn periods(&self) -> (usize, usize) {
(self.fast.period(), self.slow.period())
}
/// Current value if available.
pub const fn value(&self) -> Option<f64> {
self.last_value
}
}
impl Indicator for DecyclerOscillator {
type Input = f64;
type Output = f64;
fn update(&mut self, input: f64) -> Option<f64> {
if !input.is_finite() {
return self.last_value;
}
let (Some(f), Some(s)) = (self.fast.update(input), self.slow.update(input)) else {
return None;
};
let v = f - s;
self.last_value = Some(v);
Some(v)
}
fn reset(&mut self) {
self.fast.reset();
self.slow.reset();
self.last_value = None;
}
fn warmup_period(&self) -> usize {
self.fast.warmup_period().max(self.slow.warmup_period())
}
fn is_ready(&self) -> bool {
self.last_value.is_some()
}
fn name(&self) -> &'static str {
"DecyclerOscillator"
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::traits::BatchExt;
use approx::assert_relative_eq;
#[test]
fn new_rejects_invalid_periods() {
assert!(matches!(
DecyclerOscillator::new(0, 20),
Err(Error::PeriodZero)
));
assert!(matches!(
DecyclerOscillator::new(10, 0),
Err(Error::PeriodZero)
));
assert!(matches!(
DecyclerOscillator::new(20, 10),
Err(Error::InvalidPeriod { .. })
));
assert!(matches!(
DecyclerOscillator::new(10, 10),
Err(Error::InvalidPeriod { .. })
));
}
#[test]
fn accessors_and_metadata() {
let mut dco = DecyclerOscillator::new(10, 30).unwrap();
assert_eq!(dco.periods(), (10, 30));
assert_eq!(dco.name(), "DecyclerOscillator");
assert!(dco.warmup_period() >= 1);
assert!(!dco.is_ready());
dco.update(100.0);
assert!(dco.is_ready());
assert!(dco.value().is_some());
}
#[test]
fn constant_series_yields_zero() {
let mut dco = DecyclerOscillator::new(10, 30).unwrap();
let out = dco.batch(&[42.0_f64; 80]);
for x in out.iter().flatten() {
assert_relative_eq!(*x, 0.0, epsilon = 1e-9);
}
}
#[test]
fn batch_equals_streaming() {
let prices: Vec<f64> = (0..100)
.map(|i| 100.0 + (f64::from(i) * 0.2).cos() * 6.0)
.collect();
let mut a = DecyclerOscillator::new(10, 30).unwrap();
let mut b = DecyclerOscillator::new(10, 30).unwrap();
let batch = a.batch(&prices);
let streamed: Vec<_> = prices.iter().map(|p| b.update(*p)).collect();
assert_eq!(batch, streamed);
}
#[test]
fn ignores_non_finite_input() {
let mut dco = DecyclerOscillator::new(10, 30).unwrap();
dco.batch(&(1..=50).map(f64::from).collect::<Vec<_>>());
let before = dco.value();
assert!(before.is_some());
assert_eq!(dco.update(f64::NAN), before);
}
#[test]
fn reset_clears_state() {
let mut dco = DecyclerOscillator::new(10, 30).unwrap();
dco.batch(&(1..=50).map(f64::from).collect::<Vec<_>>());
assert!(dco.is_ready());
dco.reset();
assert!(!dco.is_ready());
}
}
@@ -0,0 +1,214 @@
//! Ehlers Stochastic — Stochastic computed on a Roofing-Filter pre-filtered input.
#![allow(clippy::doc_markdown)]
use std::collections::VecDeque;
use crate::error::{Error, Result};
use crate::indicators::roofing_filter::RoofingFilter;
use crate::traits::Indicator;
/// Ehlers' Adaptive Stochastic.
///
/// Implements the construction described in *Cycle Analytics for Traders*
/// (Ehlers 2013, ch. 7): the raw price is first passed through a
/// [`RoofingFilter`] (high-pass + SuperSmoother bandpass) to isolate the
/// tradable cycle band, then the classic Stochastic %K formula is applied
/// to the filtered output over `period` bars and finally re-smoothed by a
/// 2-bar SuperSmoother. The result is a ±1-normalised oscillator that
/// reacts to cycles without trending bias from low-frequency drift.
///
/// The output uses Ehlers' `2 * (X - MinX) / (MaxX - MinX) - 1` convention,
/// so the range is `[-1, +1]` rather than the conventional `[0, 100]`.
///
/// # Example
///
/// ```
/// use wickra_core::{Indicator, EhlersStochastic};
///
/// let mut es = EhlersStochastic::new(20).unwrap();
/// let mut last = None;
/// for i in 0..120 {
/// last = es.update(100.0 + (f64::from(i) * 0.3).sin() * 5.0);
/// }
/// assert!(last.is_some());
/// ```
#[derive(Debug, Clone)]
pub struct EhlersStochastic {
period: usize,
roofing: RoofingFilter,
filtered_buf: VecDeque<f64>,
// Tiny 2-tap IIR (Ehlers uses a simple SMA(2) for the final smoothing).
prev_stoch: f64,
has_prev: bool,
last_value: Option<f64>,
}
impl EhlersStochastic {
/// Construct with the rolling window length used by the inner stochastic.
///
/// # Errors
///
/// Returns [`Error::PeriodZero`] if `period == 0`.
pub fn new(period: usize) -> Result<Self> {
if period == 0 {
return Err(Error::PeriodZero);
}
Ok(Self {
period,
// Defaults match Ehlers' (10, 48) roofing filter cutoffs.
roofing: RoofingFilter::new(10, 48)?,
filtered_buf: VecDeque::with_capacity(period),
prev_stoch: 0.0,
has_prev: false,
last_value: None,
})
}
/// Configured period.
pub const fn period(&self) -> usize {
self.period
}
/// Current value if available.
pub const fn value(&self) -> Option<f64> {
self.last_value
}
}
impl Indicator for EhlersStochastic {
type Input = f64;
type Output = f64;
fn update(&mut self, input: f64) -> Option<f64> {
if !input.is_finite() {
return self.last_value;
}
let filtered = self.roofing.update(input)?;
if self.filtered_buf.len() == self.period {
self.filtered_buf.pop_front();
}
self.filtered_buf.push_back(filtered);
if self.filtered_buf.len() < self.period {
return None;
}
let max = self
.filtered_buf
.iter()
.copied()
.fold(f64::NEG_INFINITY, f64::max);
let min = self
.filtered_buf
.iter()
.copied()
.fold(f64::INFINITY, f64::min);
let range = max - min;
let raw = if range > 0.0 {
((filtered - min) / range).mul_add(2.0, -1.0)
} else {
0.0
};
// 2-bar SMA smoothing.
let smoothed = if self.has_prev {
0.5 * (raw + self.prev_stoch)
} else {
raw
};
self.prev_stoch = raw;
self.has_prev = true;
self.last_value = Some(smoothed);
Some(smoothed)
}
fn reset(&mut self) {
self.roofing.reset();
self.filtered_buf.clear();
self.prev_stoch = 0.0;
self.has_prev = false;
self.last_value = None;
}
fn warmup_period(&self) -> usize {
self.period + self.roofing.warmup_period()
}
fn is_ready(&self) -> bool {
self.last_value.is_some()
}
fn name(&self) -> &'static str {
"EhlersStochastic"
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::traits::BatchExt;
#[test]
fn new_rejects_zero_period() {
assert!(matches!(EhlersStochastic::new(0), Err(Error::PeriodZero)));
}
#[test]
fn accessors_and_metadata() {
let mut es = EhlersStochastic::new(20).unwrap();
assert_eq!(es.period(), 20);
assert_eq!(es.warmup_period(), 22);
assert_eq!(es.name(), "EhlersStochastic");
assert!(!es.is_ready());
let prices: Vec<f64> = (0..150)
.map(|i| 100.0 + (f64::from(i) * 0.4).sin() * 5.0)
.collect();
es.batch(&prices);
assert!(es.is_ready());
assert!(es.value().is_some());
}
#[test]
fn output_bounded_in_unit_interval() {
let prices: Vec<f64> = (0..200)
.map(|i| 100.0 + (f64::from(i) * 0.3).sin() * 5.0)
.collect();
let mut es = EhlersStochastic::new(20).unwrap();
for v in es.batch(&prices).into_iter().flatten() {
assert!((-1.0..=1.0).contains(&v), "value out of band: {v}");
}
}
#[test]
fn batch_equals_streaming() {
let prices: Vec<f64> = (0..150)
.map(|i| 100.0 + (f64::from(i) * 0.3).sin() * 5.0)
.collect();
let mut a = EhlersStochastic::new(20).unwrap();
let mut b = EhlersStochastic::new(20).unwrap();
let batch = a.batch(&prices);
let streamed: Vec<_> = prices.iter().map(|p| b.update(*p)).collect();
assert_eq!(batch, streamed);
}
#[test]
fn ignores_non_finite_input() {
let mut es = EhlersStochastic::new(20).unwrap();
let prices: Vec<f64> = (0..150)
.map(|i| 100.0 + (f64::from(i) * 0.3).sin() * 5.0)
.collect();
es.batch(&prices);
let before = es.value();
assert!(before.is_some());
assert_eq!(es.update(f64::NAN), before);
}
#[test]
fn reset_clears_state() {
let mut es = EhlersStochastic::new(20).unwrap();
let prices: Vec<f64> = (0..150)
.map(|i| 100.0 + (f64::from(i) * 0.3).sin() * 5.0)
.collect();
es.batch(&prices);
assert!(es.is_ready());
es.reset();
assert!(!es.is_ready());
}
}
@@ -0,0 +1,266 @@
//! Ehlers Empirical Mode Decomposition (bandpass + envelope).
use std::collections::VecDeque;
use std::f64::consts::PI;
use crate::error::{Error, Result};
use crate::indicators::super_smoother::SuperSmoother;
use crate::traits::Indicator;
/// Ehlers' adaptation of Empirical Mode Decomposition (EMD).
///
/// Implementation per *Cycle Analytics for Traders* (Ehlers 2013, ch. 14).
/// The procedure is:
///
/// 1. Apply a bandpass filter centred on `period` to the price.
/// 2. Detect peaks and valleys of the bandpassed signal over a `fraction`
/// of the period.
/// 3. Average the peaks and valleys separately to form an upper / lower
/// envelope, then return the centred bandpass minus the envelope mean
/// (the "EMD" line).
///
/// The output crosses zero at trend changes and stays near zero in
/// non-trending markets — the classic visual cue Ehlers documents.
///
/// # Example
///
/// ```
/// use wickra_core::{Indicator, EmpiricalModeDecomposition};
///
/// let mut emd = EmpiricalModeDecomposition::new(20, 0.5).unwrap();
/// let mut last = None;
/// for i in 0..200 {
/// last = emd.update(100.0 + (f64::from(i) * 0.3).sin() * 5.0);
/// }
/// assert!(last.is_some());
/// ```
#[derive(Debug, Clone)]
pub struct EmpiricalModeDecomposition {
period: usize,
fraction: f64,
bandpass: f64,
prev_bp_1: f64,
prev_bp_2: f64,
prev_in_1: Option<f64>,
prev_in_2: Option<f64>,
beta: f64,
alpha: f64,
smoother: SuperSmoother,
peak_smoother: SuperSmoother,
valley_smoother: SuperSmoother,
bp_buf: VecDeque<f64>,
bp_history_len: usize,
last_value: Option<f64>,
}
impl EmpiricalModeDecomposition {
/// Construct with the bandpass centre period and the peak-detection
/// window fraction.
///
/// `fraction` is multiplied by `period` to size the rolling peak/valley
/// window; Ehlers recommends `0.5`. Both must be positive.
///
/// # Errors
///
/// Returns [`Error::PeriodZero`] if `period == 0`, and
/// [`Error::InvalidPeriod`] if `fraction <= 0` or non-finite.
pub fn new(period: usize, fraction: f64) -> Result<Self> {
if period == 0 {
return Err(Error::PeriodZero);
}
if !fraction.is_finite() || fraction <= 0.0 || fraction > 1.0 {
return Err(Error::InvalidPeriod {
message: "fraction must be in (0, 1]",
});
}
let beta = (2.0 * PI / period as f64).cos();
let gamma = 1.0 / (2.0 * PI * 0.25 / period as f64).cos();
let alpha = gamma - (gamma * gamma - 1.0).sqrt();
let history = (period as f64 * fraction).round().max(1.0) as usize;
Ok(Self {
period,
fraction,
bandpass: 0.0,
prev_bp_1: 0.0,
prev_bp_2: 0.0,
prev_in_1: None,
prev_in_2: None,
beta,
alpha,
smoother: SuperSmoother::new(period.max(2))?,
peak_smoother: SuperSmoother::new(period.max(2))?,
valley_smoother: SuperSmoother::new(period.max(2))?,
bp_buf: VecDeque::with_capacity(history),
bp_history_len: history,
last_value: None,
})
}
/// Configured period.
pub const fn period(&self) -> usize {
self.period
}
/// Configured fraction.
pub const fn fraction(&self) -> f64 {
self.fraction
}
/// Current value if available.
pub const fn value(&self) -> Option<f64> {
self.last_value
}
}
impl Indicator for EmpiricalModeDecomposition {
type Input = f64;
type Output = f64;
fn update(&mut self, input: f64) -> Option<f64> {
if !input.is_finite() {
return self.last_value;
}
// 2nd-order resonant bandpass per Ehlers ch. 6.
let bp = if let (Some(_x1), Some(x2)) = (self.prev_in_1, self.prev_in_2) {
0.5 * (1.0 - self.alpha) * (input - x2)
+ self.beta * (1.0 + self.alpha) * self.prev_bp_1
- self.alpha * self.prev_bp_2
} else {
0.0
};
self.prev_bp_2 = self.prev_bp_1;
self.prev_bp_1 = bp;
self.bandpass = bp;
self.prev_in_2 = self.prev_in_1;
self.prev_in_1 = Some(input);
if self.bp_buf.len() == self.bp_history_len {
self.bp_buf.pop_front();
}
self.bp_buf.push_back(bp);
if self.bp_buf.len() < self.bp_history_len {
return None;
}
// Identify the current peak (largest), valley (smallest) within the window.
let peak = self
.bp_buf
.iter()
.copied()
.fold(f64::NEG_INFINITY, f64::max);
let valley = self.bp_buf.iter().copied().fold(f64::INFINITY, f64::min);
let avg_peak = self.peak_smoother.update(peak)?;
let avg_valley = self.valley_smoother.update(valley)?;
// The EMD line is the bandpass minus the smoothed mean envelope.
let mean = 0.5 * (avg_peak + avg_valley);
let raw = bp - mean;
let v = self.smoother.update(raw)?;
self.last_value = Some(v);
Some(v)
}
fn reset(&mut self) {
self.bandpass = 0.0;
self.prev_bp_1 = 0.0;
self.prev_bp_2 = 0.0;
self.prev_in_1 = None;
self.prev_in_2 = None;
self.smoother.reset();
self.peak_smoother.reset();
self.valley_smoother.reset();
self.bp_buf.clear();
self.last_value = None;
}
fn warmup_period(&self) -> usize {
self.bp_history_len
}
fn is_ready(&self) -> bool {
self.last_value.is_some()
}
fn name(&self) -> &'static str {
"EmpiricalModeDecomposition"
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::traits::BatchExt;
#[test]
fn new_rejects_invalid_params() {
assert!(matches!(
EmpiricalModeDecomposition::new(0, 0.5),
Err(Error::PeriodZero)
));
assert!(matches!(
EmpiricalModeDecomposition::new(20, 0.0),
Err(Error::InvalidPeriod { .. })
));
assert!(matches!(
EmpiricalModeDecomposition::new(20, 1.5),
Err(Error::InvalidPeriod { .. })
));
assert!(matches!(
EmpiricalModeDecomposition::new(20, f64::NAN),
Err(Error::InvalidPeriod { .. })
));
}
#[test]
fn accessors_and_metadata() {
let mut emd = EmpiricalModeDecomposition::new(20, 0.5).unwrap();
assert_eq!(emd.period(), 20);
assert!((emd.fraction() - 0.5).abs() < 1e-15);
assert_eq!(emd.name(), "EmpiricalModeDecomposition");
assert!(emd.warmup_period() >= 1);
assert!(!emd.is_ready());
let prices: Vec<f64> = (0..200)
.map(|i| 100.0 + (f64::from(i) * 0.3).sin() * 5.0)
.collect();
emd.batch(&prices);
assert!(emd.is_ready());
assert!(emd.value().is_some());
}
#[test]
fn batch_equals_streaming() {
let prices: Vec<f64> = (0..200)
.map(|i| 100.0 + (f64::from(i) * 0.2).cos() * 5.0)
.collect();
let mut a = EmpiricalModeDecomposition::new(20, 0.5).unwrap();
let mut b = EmpiricalModeDecomposition::new(20, 0.5).unwrap();
let batch = a.batch(&prices);
let streamed: Vec<_> = prices.iter().map(|p| b.update(*p)).collect();
assert_eq!(batch, streamed);
}
#[test]
fn ignores_non_finite_input() {
let mut emd = EmpiricalModeDecomposition::new(20, 0.5).unwrap();
let prices: Vec<f64> = (0..200)
.map(|i| 100.0 + (f64::from(i) * 0.3).sin() * 5.0)
.collect();
emd.batch(&prices);
let before = emd.value();
assert!(before.is_some());
assert_eq!(emd.update(f64::NAN), before);
}
#[test]
fn reset_clears_state() {
let mut emd = EmpiricalModeDecomposition::new(20, 0.5).unwrap();
let prices: Vec<f64> = (0..200)
.map(|i| 100.0 + (f64::from(i) * 0.3).sin() * 5.0)
.collect();
emd.batch(&prices);
assert!(emd.is_ready());
emd.reset();
assert!(!emd.is_ready());
}
}
+161
View File
@@ -0,0 +1,161 @@
//! Ehlers Following Adaptive Moving Average (FAMA).
use crate::error::Result;
use crate::indicators::mama::Mama;
use crate::traits::Indicator;
/// Scalar wrapper that exposes only the FAMA line from a [`Mama`] indicator.
///
/// FAMA (Following Adaptive Moving Average) is MAMA's lagging companion in
/// Ehlers' MESA construction. It uses half MAMA's adaptive alpha, so it
/// reacts later than MAMA — MAMA crossing above FAMA marks a trend
/// confirmation, MAMA below FAMA a reversal. See [`Mama`] for the joint
/// `(mama, fama)` output; this wrapper exposes the slow line as a plain
/// scalar indicator so it can be chained directly.
///
/// # Example
///
/// ```
/// use wickra_core::{Indicator, Fama};
///
/// let mut fama = Fama::new(0.5, 0.05).unwrap();
/// let mut last = None;
/// for i in 0..80 {
/// last = fama.update(100.0 + (f64::from(i) * 0.3).sin() * 5.0);
/// }
/// assert!(last.is_some());
/// ```
#[derive(Debug, Clone)]
pub struct Fama {
inner: Mama,
last_value: Option<f64>,
}
impl Fama {
/// Construct with the same `(fast_limit, slow_limit)` semantics as [`Mama`].
///
/// # Errors
///
/// Forwards [`Mama::new`]'s validation errors.
pub fn new(fast_limit: f64, slow_limit: f64) -> Result<Self> {
Ok(Self {
inner: Mama::new(fast_limit, slow_limit)?,
last_value: None,
})
}
/// Default `(0.5, 0.05)` parameters.
pub fn classic() -> Self {
Self {
inner: Mama::classic(),
last_value: None,
}
}
/// Configured `(fast_limit, slow_limit)`.
pub const fn limits(&self) -> (f64, f64) {
self.inner.limits()
}
/// Current FAMA value if available.
pub const fn value(&self) -> Option<f64> {
self.last_value
}
}
impl Indicator for Fama {
type Input = f64;
type Output = f64;
fn update(&mut self, input: f64) -> Option<f64> {
let v = self.inner.update(input)?.fama;
self.last_value = Some(v);
Some(v)
}
fn reset(&mut self) {
self.inner.reset();
self.last_value = None;
}
fn warmup_period(&self) -> usize {
self.inner.warmup_period()
}
fn is_ready(&self) -> bool {
self.last_value.is_some()
}
fn name(&self) -> &'static str {
"FAMA"
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::error::Error;
use crate::traits::BatchExt;
#[test]
fn rejects_invalid_limits() {
assert!(matches!(
Fama::new(0.0, 0.05),
Err(Error::InvalidPeriod { .. })
));
assert!(matches!(
Fama::new(0.05, 0.5),
Err(Error::InvalidPeriod { .. })
));
}
#[test]
fn accessors_and_metadata() {
let mut fama = Fama::classic();
assert_eq!(fama.limits(), (0.5, 0.05));
assert_eq!(fama.warmup_period(), 33);
assert_eq!(fama.name(), "FAMA");
assert!(!fama.is_ready());
for i in 0..60 {
fama.update(100.0 + (f64::from(i) * 0.3).sin() * 5.0);
}
assert!(fama.is_ready());
assert!(fama.value().is_some());
}
#[test]
fn batch_equals_streaming() {
let prices: Vec<f64> = (0..120)
.map(|i| 100.0 + (f64::from(i) * 0.25).cos() * 5.0)
.collect();
let mut a = Fama::classic();
let mut b = Fama::classic();
let batch = a.batch(&prices);
let streamed: Vec<_> = prices.iter().map(|p| b.update(*p)).collect();
assert_eq!(batch, streamed);
}
#[test]
fn ignores_non_finite_input() {
let mut fama = Fama::classic();
let prices: Vec<f64> = (0..100)
.map(|i| 100.0 + (f64::from(i) * 0.3).sin() * 5.0)
.collect();
fama.batch(&prices);
let before = fama.value();
assert!(before.is_some());
assert_eq!(fama.update(f64::NAN), before);
}
#[test]
fn reset_clears_state() {
let mut fama = Fama::classic();
let prices: Vec<f64> = (0..100)
.map(|i| 100.0 + (f64::from(i) * 0.3).sin() * 5.0)
.collect();
fama.batch(&prices);
assert!(fama.is_ready());
fama.reset();
assert!(!fama.is_ready());
}
}
@@ -0,0 +1,199 @@
//! Ehlers Fisher Transform.
use std::collections::VecDeque;
use crate::error::{Error, Result};
use crate::traits::Indicator;
/// Ehlers' Fisher Transform of price.
///
/// Normalises the most recent price to `[-1, +1]` via min/max over a `period`
/// window, smooths the normalised value with a 0.33 / 0.67 IIR step, and
/// applies the Fisher transform `0.5 * ln((1+x)/(1-x))`. The result has a
/// near-Gaussian distribution, so extreme readings stand out cleanly. A
/// secondary signal is produced by lagging the Fisher value by one bar (the
/// classic trigger), making the indicator a two-line crossover system in
/// charts.
///
/// Only the primary Fisher value is exposed here as a scalar; the lagged
/// trigger is one update behind by construction.
///
/// # Example
///
/// ```
/// use wickra_core::{Indicator, FisherTransform};
///
/// let mut ft = FisherTransform::new(10).unwrap();
/// let mut last = None;
/// for i in 0..30 {
/// last = ft.update(100.0 + (f64::from(i) * 0.3).sin() * 5.0);
/// }
/// assert!(last.is_some());
/// ```
#[derive(Debug, Clone)]
pub struct FisherTransform {
period: usize,
window: VecDeque<f64>,
smoothed: f64,
last_fisher: Option<f64>,
}
impl FisherTransform {
/// Construct with the rolling extrema window length.
///
/// # Errors
///
/// Returns [`Error::PeriodZero`] if `period == 0`.
pub fn new(period: usize) -> Result<Self> {
if period == 0 {
return Err(Error::PeriodZero);
}
Ok(Self {
period,
window: VecDeque::with_capacity(period),
smoothed: 0.0,
last_fisher: None,
})
}
/// Configured period.
pub const fn period(&self) -> usize {
self.period
}
/// Current Fisher value if available.
pub const fn value(&self) -> Option<f64> {
self.last_fisher
}
}
impl Indicator for FisherTransform {
type Input = f64;
type Output = f64;
fn update(&mut self, input: f64) -> Option<f64> {
if !input.is_finite() {
return self.last_fisher;
}
if self.window.len() == self.period {
self.window.pop_front();
}
self.window.push_back(input);
if self.window.len() < self.period {
return None;
}
let max = self
.window
.iter()
.copied()
.fold(f64::NEG_INFINITY, f64::max);
let min = self.window.iter().copied().fold(f64::INFINITY, f64::min);
let range = max - min;
// Normalise to roughly [-1, +1]; centred midpoint when range == 0.
let raw = if range > 0.0 {
((input - min) / range).mul_add(2.0, -1.0)
} else {
0.0
};
// Ehlers IIR: 0.33 * raw + 0.67 * prev_smoothed, then clamp.
self.smoothed = 0.33f64.mul_add(raw, 0.67 * self.smoothed);
// Clamp strictly inside (-1, +1) to keep the log finite.
let clamped = self.smoothed.clamp(-0.999, 0.999);
let fisher = 0.5 * ((1.0 + clamped) / (1.0 - clamped)).ln();
self.last_fisher = Some(fisher);
Some(fisher)
}
fn reset(&mut self) {
self.window.clear();
self.smoothed = 0.0;
self.last_fisher = None;
}
fn warmup_period(&self) -> usize {
self.period
}
fn is_ready(&self) -> bool {
self.last_fisher.is_some()
}
fn name(&self) -> &'static str {
"FisherTransform"
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::traits::BatchExt;
#[test]
fn new_rejects_zero_period() {
assert!(matches!(FisherTransform::new(0), Err(Error::PeriodZero)));
}
#[test]
fn accessors_and_metadata() {
let mut ft = FisherTransform::new(10).unwrap();
assert_eq!(ft.period(), 10);
assert_eq!(ft.warmup_period(), 10);
assert_eq!(ft.name(), "FisherTransform");
assert!(ft.value().is_none());
for i in 1..=10 {
ft.update(f64::from(i));
}
assert!(ft.value().is_some());
assert!(ft.is_ready());
}
#[test]
fn warmup_returns_none_until_seed() {
let mut ft = FisherTransform::new(5).unwrap();
for i in 1..=4 {
assert_eq!(ft.update(f64::from(i)), None);
}
assert!(ft.update(5.0).is_some());
}
#[test]
fn constant_series_zero_range_yields_zero() {
let mut ft = FisherTransform::new(5).unwrap();
let out = ft.batch(&[42.0_f64; 30]);
for x in out.iter().skip(5).flatten() {
assert!(x.abs() < 1e-6, "expected near-zero, got {x}");
}
}
#[test]
fn batch_equals_streaming() {
let prices: Vec<f64> = (0..60)
.map(|i| 100.0 + (f64::from(i) * 0.2).sin() * 8.0)
.collect();
let mut a = FisherTransform::new(10).unwrap();
let mut b = FisherTransform::new(10).unwrap();
let batch = a.batch(&prices);
let streamed: Vec<_> = prices.iter().map(|p| b.update(*p)).collect();
assert_eq!(batch, streamed);
}
#[test]
fn ignores_non_finite_input() {
let mut ft = FisherTransform::new(5).unwrap();
ft.batch(&[1.0, 2.0, 3.0, 4.0, 5.0]);
let before = ft.value();
assert!(before.is_some());
assert_eq!(ft.update(f64::NAN), before);
assert_eq!(ft.update(f64::INFINITY), before);
}
#[test]
fn reset_clears_state() {
let mut ft = FisherTransform::new(5).unwrap();
ft.batch(&(1..=20).map(f64::from).collect::<Vec<_>>());
assert!(ft.is_ready());
ft.reset();
assert!(!ft.is_ready());
assert_eq!(ft.update(1.0), None);
}
}
@@ -0,0 +1,272 @@
//! Ehlers Hilbert Transform Dominant Cycle period estimator.
#![allow(clippy::manual_clamp)]
use std::f64::consts::PI;
use crate::traits::Indicator;
/// Ehlers' Hilbert Transformbased Dominant Cycle period estimator.
///
/// Decomposes price into in-phase and quadrature components via Ehlers'
/// truncated Hilbert transform, then derives the instantaneous phase. The
/// dominant cycle period is recovered from the phase rate of change and
/// median-smoothed. From *Rocket Science for Traders* (Ehlers 2001, ch. 7),
/// implementation aligned with the formulation used in TA-Lib's `HT_DCPERIOD`.
///
/// The output is clamped to the band `[6, 50]` bars, which Ehlers identifies
/// as the meaningful tradable cycle range. The estimator emits its first
/// value after ~50 inputs as the moving-average chain fills.
///
/// # Example
///
/// ```
/// use wickra_core::{Indicator, HilbertDominantCycle};
///
/// let mut ht = HilbertDominantCycle::new();
/// let mut last = None;
/// for i in 0..200 {
/// last = ht.update(100.0 + (f64::from(i) * 0.4).sin() * 5.0);
/// }
/// assert!(last.is_some());
/// ```
#[derive(Debug, Clone, Default)]
pub struct HilbertDominantCycle {
// Rolling 7-tap smoother input buffer.
smooth_buf: Vec<f64>,
// Detrender / Q1 / I1 ring history (need 6 prior).
detrender_buf: Vec<f64>,
q1_buf: Vec<f64>,
i1_buf: Vec<f64>,
// Smoothed I/Q lines for phase computation.
prev_i2: f64,
prev_q2: f64,
prev_re: f64,
prev_im: f64,
prev_period: f64,
prev_smooth_period: f64,
count: usize,
last_value: Option<f64>,
}
impl HilbertDominantCycle {
/// Construct a new dominant cycle estimator.
pub fn new() -> Self {
Self::default()
}
/// Current period estimate if available.
pub const fn value(&self) -> Option<f64> {
self.last_value
}
}
impl Indicator for HilbertDominantCycle {
type Input = f64;
type Output = f64;
fn update(&mut self, input: f64) -> Option<f64> {
if !input.is_finite() {
return self.last_value;
}
self.count += 1;
// 4-bar weighted moving average of the input (smoothed price).
// Ehlers: (4*x[0] + 3*x[1] + 2*x[2] + x[3]) / 10.
Self::push_front(&mut self.smooth_buf, input, 7);
if self.smooth_buf.len() < 4 {
return None;
}
let smooth = (4.0 * self.smooth_buf[0]
+ 3.0 * self.smooth_buf[1]
+ 2.0 * self.smooth_buf[2]
+ self.smooth_buf[3])
/ 10.0;
// Adaptive coefficient based on the previous period estimate.
let period = self.prev_period.max(6.0).min(50.0);
let adj = 0.075 * period + 0.54;
// We need the smooth buffer to hold ≥ 7 samples for the Hilbert taps.
if self.smooth_buf.len() < 7 {
return None;
}
// Ehlers' Hilbert transform of `smooth` (using current + 2/4/6 lags).
let s0 = smooth;
let s2 = self.smooth_buf[2];
let s4 = self.smooth_buf[4];
let s6 = self.smooth_buf[6];
let detrender = (0.0962 * s0 + 0.5769 * s2 - 0.5769 * s4 - 0.0962 * s6) * adj;
Self::push_front(&mut self.detrender_buf, detrender, 7);
if self.detrender_buf.len() < 7 {
return None;
}
// In-phase and quadrature components.
let q1 = (0.0962 * self.detrender_buf[0] + 0.5769 * self.detrender_buf[2]
- 0.5769 * self.detrender_buf[4]
- 0.0962 * self.detrender_buf[6])
* adj;
let i1 = self.detrender_buf[3];
Self::push_front(&mut self.q1_buf, q1, 7);
Self::push_front(&mut self.i1_buf, i1, 7);
if self.q1_buf.len() < 7 || self.i1_buf.len() < 7 {
return None;
}
// Advance the phase 90 deg via a second Hilbert pass.
let ji = (0.0962 * self.i1_buf[0] + 0.5769 * self.i1_buf[2]
- 0.5769 * self.i1_buf[4]
- 0.0962 * self.i1_buf[6])
* adj;
let jq = (0.0962 * self.q1_buf[0] + 0.5769 * self.q1_buf[2]
- 0.5769 * self.q1_buf[4]
- 0.0962 * self.q1_buf[6])
* adj;
// Phasor smoothing.
let mut i2 = i1 - jq;
let mut q2 = q1 + ji;
i2 = 0.2 * i2 + 0.8 * self.prev_i2;
q2 = 0.2 * q2 + 0.8 * self.prev_q2;
// Homodyne discriminator.
let mut re = i2 * self.prev_i2 + q2 * self.prev_q2;
let mut im = i2 * self.prev_q2 - q2 * self.prev_i2;
re = 0.2 * re + 0.8 * self.prev_re;
im = 0.2 * im + 0.8 * self.prev_im;
self.prev_i2 = i2;
self.prev_q2 = q2;
self.prev_re = re;
self.prev_im = im;
let mut new_period = if im.abs() > f64::EPSILON && re.abs() > f64::EPSILON {
2.0 * PI / im.atan2(re)
} else {
self.prev_period
};
// Rate-of-change clamp per Ehlers.
new_period = new_period.min(1.5 * self.prev_period);
new_period = new_period.max(0.67 * self.prev_period);
new_period = new_period.clamp(6.0, 50.0);
// EMA smoothing of the period.
self.prev_period = 0.2 * new_period + 0.8 * self.prev_period;
// Second smoothing step (TA-Lib uses 0.33/0.67).
self.prev_smooth_period = 0.33 * self.prev_period + 0.67 * self.prev_smooth_period;
if self.count < 50 {
return None;
}
self.last_value = Some(self.prev_smooth_period);
Some(self.prev_smooth_period)
}
fn reset(&mut self) {
self.smooth_buf.clear();
self.detrender_buf.clear();
self.q1_buf.clear();
self.i1_buf.clear();
self.prev_i2 = 0.0;
self.prev_q2 = 0.0;
self.prev_re = 0.0;
self.prev_im = 0.0;
self.prev_period = 0.0;
self.prev_smooth_period = 0.0;
self.count = 0;
self.last_value = None;
}
fn warmup_period(&self) -> usize {
50
}
fn is_ready(&self) -> bool {
self.last_value.is_some()
}
fn name(&self) -> &'static str {
"HilbertDominantCycle"
}
}
impl HilbertDominantCycle {
/// Push `v` at the front of `buf`, capping the length at `cap`.
fn push_front(buf: &mut Vec<f64>, v: f64, cap: usize) {
buf.insert(0, v);
if buf.len() > cap {
buf.truncate(cap);
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::traits::BatchExt;
#[test]
fn accessors_and_metadata() {
let mut ht = HilbertDominantCycle::new();
assert_eq!(ht.warmup_period(), 50);
assert_eq!(ht.name(), "HilbertDominantCycle");
assert!(!ht.is_ready());
assert!(ht.value().is_none());
for i in 0..120 {
ht.update(100.0 + (f64::from(i) * 0.3).sin() * 5.0);
}
assert!(ht.is_ready());
assert!(ht.value().is_some());
}
#[test]
fn output_within_clamp_band() {
let mut ht = HilbertDominantCycle::new();
let prices: Vec<f64> = (0..200)
.map(|i| 100.0 + (f64::from(i) * 0.4).sin() * 5.0)
.collect();
let out = ht.batch(&prices);
for v in out.iter().flatten() {
assert!((6.0..=50.0).contains(v), "period {v} outside [6, 50]");
}
}
#[test]
fn batch_equals_streaming() {
let prices: Vec<f64> = (0..200)
.map(|i| 100.0 + (f64::from(i) * 0.3).sin() * 5.0)
.collect();
let mut a = HilbertDominantCycle::new();
let mut b = HilbertDominantCycle::new();
let batch = a.batch(&prices);
let streamed: Vec<_> = prices.iter().map(|p| b.update(*p)).collect();
assert_eq!(batch, streamed);
}
#[test]
fn ignores_non_finite_input() {
let mut ht = HilbertDominantCycle::new();
let prices: Vec<f64> = (0..120)
.map(|i| 100.0 + (f64::from(i) * 0.4).sin() * 5.0)
.collect();
ht.batch(&prices);
let before = ht.value();
assert!(before.is_some());
assert_eq!(ht.update(f64::NAN), before);
}
#[test]
fn reset_clears_state() {
let mut ht = HilbertDominantCycle::new();
let prices: Vec<f64> = (0..120)
.map(|i| 100.0 + (f64::from(i) * 0.4).sin() * 5.0)
.collect();
ht.batch(&prices);
assert!(ht.is_ready());
ht.reset();
assert!(!ht.is_ready());
assert!(ht.value().is_none());
}
}
@@ -0,0 +1,213 @@
//! Ehlers Instantaneous Trendline (ITrend).
#![allow(clippy::doc_markdown)]
use crate::error::{Error, Result};
use crate::traits::Indicator;
/// Ehlers' Instantaneous Trendline (ITrend).
///
/// A 2-pole IIR that approximates a lag-free trend line:
///
/// ```text
/// itrend[t] = (alpha - alpha^2/4) * x[t]
/// + 0.5 * alpha^2 * x[t-1]
/// - (alpha - 0.75*alpha^2) * x[t-2]
/// + 2*(1 - alpha) * itrend[t-1]
/// - (1 - alpha)^2 * itrend[t-2]
/// ```
///
/// where `alpha = 2 / (period + 1)`. From *Cybernetic Analysis for Stocks
/// and Futures* (Ehlers 2004, ch. 8). During the first six bars the output
/// uses the EasyLanguage initial condition `(x[t] + 2*x[t-1] + x[t-2]) / 4`.
///
/// # Example
///
/// ```
/// use wickra_core::{Indicator, InstantaneousTrendline};
///
/// let mut it = InstantaneousTrendline::new(20).unwrap();
/// let mut last = None;
/// for i in 0..40 {
/// last = it.update(100.0 + f64::from(i) * 0.5);
/// }
/// assert!(last.is_some());
/// ```
#[derive(Debug, Clone)]
pub struct InstantaneousTrendline {
period: usize,
alpha: f64,
in_buf: [Option<f64>; 3],
out_buf: [Option<f64>; 2],
count: usize,
last_value: Option<f64>,
}
impl InstantaneousTrendline {
/// Construct with the dominant-cycle period.
///
/// # Errors
///
/// Returns [`Error::PeriodZero`] if `period == 0`.
pub fn new(period: usize) -> Result<Self> {
if period == 0 {
return Err(Error::PeriodZero);
}
let alpha = 2.0 / (period as f64 + 1.0);
Ok(Self {
period,
alpha,
in_buf: [None; 3],
out_buf: [None; 2],
count: 0,
last_value: None,
})
}
/// Configured period.
pub const fn period(&self) -> usize {
self.period
}
/// Smoothing alpha.
pub const fn alpha(&self) -> f64 {
self.alpha
}
/// Current value if available.
pub const fn value(&self) -> Option<f64> {
self.last_value
}
}
impl Indicator for InstantaneousTrendline {
type Input = f64;
type Output = f64;
fn update(&mut self, input: f64) -> Option<f64> {
if !input.is_finite() {
return self.last_value;
}
self.count += 1;
// Shift input buffer (position 0 = most recent).
self.in_buf[2] = self.in_buf[1];
self.in_buf[1] = self.in_buf[0];
self.in_buf[0] = Some(input);
let alpha = self.alpha;
let v = if self.count >= 7 {
// Full recursive formula.
let (x0, x1, x2) = (
self.in_buf[0].expect("filled"),
self.in_buf[1].expect("filled"),
self.in_buf[2].expect("filled"),
);
let (y1, y2) = (
self.out_buf[0].expect("filled"),
self.out_buf[1].expect("filled"),
);
(alpha - alpha * alpha / 4.0) * x0 + 0.5 * alpha * alpha * x1
- (alpha - 0.75 * alpha * alpha) * x2
+ 2.0 * (1.0 - alpha) * y1
- (1.0 - alpha) * (1.0 - alpha) * y2
} else {
// Initial condition: 4-point weighted average of the most recent
// inputs (Ehlers EasyLanguage default).
let x0 = self.in_buf[0].expect("just pushed");
let x1 = self.in_buf[1].unwrap_or(x0);
let x2 = self.in_buf[2].unwrap_or(x0);
(x0 + 2.0 * x1 + x2) / 4.0
};
self.out_buf[1] = self.out_buf[0];
self.out_buf[0] = Some(v);
self.last_value = Some(v);
Some(v)
}
fn reset(&mut self) {
self.in_buf = [None; 3];
self.out_buf = [None; 2];
self.count = 0;
self.last_value = None;
}
fn warmup_period(&self) -> usize {
1
}
fn is_ready(&self) -> bool {
self.last_value.is_some()
}
fn name(&self) -> &'static str {
"InstantaneousTrendline"
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::traits::BatchExt;
use approx::assert_relative_eq;
#[test]
fn new_rejects_zero_period() {
assert!(matches!(
InstantaneousTrendline::new(0),
Err(Error::PeriodZero)
));
}
#[test]
fn accessors_and_metadata() {
let mut it = InstantaneousTrendline::new(20).unwrap();
assert_eq!(it.period(), 20);
assert_relative_eq!(it.alpha(), 2.0 / 21.0, epsilon = 1e-15);
assert_eq!(it.warmup_period(), 1);
assert_eq!(it.name(), "InstantaneousTrendline");
assert!(!it.is_ready());
it.update(100.0);
assert!(it.is_ready());
}
#[test]
fn constant_series_passes_through() {
// Coefficients sum to 1, so a flat input stays flat after warmup.
let mut it = InstantaneousTrendline::new(20).unwrap();
let out = it.batch(&[42.0_f64; 200]);
for x in out.iter().skip(20).flatten() {
assert_relative_eq!(*x, 42.0, epsilon = 1e-6);
}
}
#[test]
fn batch_equals_streaming() {
let prices: Vec<f64> = (0..120)
.map(|i| 100.0 + (f64::from(i) * 0.2).cos() * 5.0)
.collect();
let mut a = InstantaneousTrendline::new(15).unwrap();
let mut b = InstantaneousTrendline::new(15).unwrap();
let batch = a.batch(&prices);
let streamed: Vec<_> = prices.iter().map(|p| b.update(*p)).collect();
assert_eq!(batch, streamed);
}
#[test]
fn ignores_non_finite_input() {
let mut it = InstantaneousTrendline::new(20).unwrap();
it.batch(&(1..=40).map(f64::from).collect::<Vec<_>>());
let before = it.value();
assert!(before.is_some());
assert_eq!(it.update(f64::NAN), before);
}
#[test]
fn reset_clears_state() {
let mut it = InstantaneousTrendline::new(20).unwrap();
it.batch(&(1..=40).map(f64::from).collect::<Vec<_>>());
assert!(it.is_ready());
it.reset();
assert!(!it.is_ready());
}
}
@@ -0,0 +1,164 @@
//! Inverse Fisher Transform (Ehlers).
use crate::error::{Error, Result};
use crate::traits::Indicator;
/// Inverse Fisher Transform of a scaled scalar input.
///
/// Compresses the input through `(e^{2x} - 1) / (e^{2x} + 1) = tanh(x)`, the
/// algebraic inverse of the Fisher transform. The output is bounded in
/// `[-1, +1]` (saturating to exactly `±1` for `|scale * input| >= ~19.06`
/// under IEEE 754 doubles), which makes overbought/oversold thresholds at, say, `±0.5`
/// universal across markets and timeframes — the classic use described by
/// Ehlers in *Cybernetic Analysis for Stocks and Futures* (2004).
///
/// The constructor takes a `scale` multiplier so callers can feed raw
/// oscillator readings (e.g. RSI in `[0, 100]`, mapped to `[-5, +5]` with
/// `scale = 0.1` after a `-50` shift) without writing their own scaler.
/// Internally the indicator just computes `tanh(scale * input)`.
///
/// # Example
///
/// ```
/// use wickra_core::{Indicator, InverseFisherTransform};
///
/// let mut ift = InverseFisherTransform::new(1.0).unwrap();
/// // Large positive input saturates to +1, large negative to -1.
/// assert!(ift.update(10.0).unwrap() > 0.999);
/// assert!(ift.update(-10.0).unwrap() < -0.999);
/// ```
#[derive(Debug, Clone)]
pub struct InverseFisherTransform {
scale: f64,
last_value: Option<f64>,
}
impl InverseFisherTransform {
/// Construct with a multiplicative scale applied before the tanh squash.
///
/// # Errors
///
/// Returns [`Error::InvalidPeriod`] if `scale` is not finite or non-positive.
pub fn new(scale: f64) -> Result<Self> {
if !scale.is_finite() || scale <= 0.0 {
return Err(Error::InvalidPeriod {
message: "scale must be a positive finite number",
});
}
Ok(Self {
scale,
last_value: None,
})
}
/// Configured scale.
pub const fn scale(&self) -> f64 {
self.scale
}
/// Current value if available.
pub const fn value(&self) -> Option<f64> {
self.last_value
}
}
impl Indicator for InverseFisherTransform {
type Input = f64;
type Output = f64;
fn update(&mut self, input: f64) -> Option<f64> {
if !input.is_finite() {
return self.last_value;
}
let scaled = self.scale * input;
// tanh is numerically safe for any finite input.
let v = scaled.tanh();
self.last_value = Some(v);
Some(v)
}
fn reset(&mut self) {
self.last_value = None;
}
fn warmup_period(&self) -> usize {
1
}
fn is_ready(&self) -> bool {
self.last_value.is_some()
}
fn name(&self) -> &'static str {
"InverseFisherTransform"
}
}
#[cfg(test)]
mod tests {
use super::*;
use approx::assert_relative_eq;
#[test]
fn new_rejects_non_positive_scale() {
assert!(matches!(
InverseFisherTransform::new(0.0),
Err(Error::InvalidPeriod { .. })
));
assert!(matches!(
InverseFisherTransform::new(-1.0),
Err(Error::InvalidPeriod { .. })
));
assert!(matches!(
InverseFisherTransform::new(f64::NAN),
Err(Error::InvalidPeriod { .. })
));
}
#[test]
fn accessors_and_metadata() {
let mut ift = InverseFisherTransform::new(0.5).unwrap();
assert_relative_eq!(ift.scale(), 0.5, epsilon = 1e-15);
assert_eq!(ift.warmup_period(), 1);
assert_eq!(ift.name(), "InverseFisherTransform");
assert!(!ift.is_ready());
assert!(ift.update(1.0).is_some());
assert!(ift.is_ready());
assert!(ift.value().is_some());
}
#[test]
fn zero_input_yields_zero() {
let mut ift = InverseFisherTransform::new(1.0).unwrap();
assert_relative_eq!(ift.update(0.0).unwrap(), 0.0, epsilon = 1e-15);
}
#[test]
fn output_bounded_in_closed_unit_interval() {
// tanh saturates to exactly ±1.0 in IEEE 754 once |x| >= ~19.06, so the
// output is in the closed interval [-1, +1] rather than strictly open.
let mut ift = InverseFisherTransform::new(1.0).unwrap();
for i in -100..=100 {
let v = ift.update(f64::from(i)).unwrap();
assert!((-1.0..=1.0).contains(&v), "v={v}");
}
}
#[test]
fn reset_clears_state() {
let mut ift = InverseFisherTransform::new(1.0).unwrap();
ift.update(2.0);
assert!(ift.is_ready());
ift.reset();
assert!(!ift.is_ready());
}
#[test]
fn ignores_non_finite_input() {
let mut ift = InverseFisherTransform::new(1.0).unwrap();
ift.update(1.0);
let before = ift.value();
assert_eq!(ift.update(f64::NAN), before);
assert_eq!(ift.update(f64::INFINITY), before);
}
}
+370
View File
@@ -0,0 +1,370 @@
//! Ehlers MESA Adaptive Moving Average (MAMA) and its follower (FAMA).
#![allow(
clippy::doc_markdown,
clippy::doc_lazy_continuation,
clippy::struct_field_names,
clippy::manual_clamp
)]
use std::f64::consts::PI;
use crate::error::{Error, Result};
use crate::traits::Indicator;
/// MAMA + FAMA output pair.
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct MamaOutput {
/// MESA Adaptive Moving Average.
pub mama: f64,
/// Following Adaptive Moving Average (slower companion).
pub fama: f64,
}
/// Ehlers' MESA Adaptive Moving Average (MAMA).
///
/// MAMA adapts its smoothing constant from the rate-of-change of price phase,
/// derived via a truncated Hilbert transform — full math in "Cycle Analytics
/// for Traders" (Ehlers 2013, ch. 8) and the original 2001 MESA paper.
///
/// The two-parameter `(fast_limit, slow_limit)` is the range over which the
/// adaptive alpha can vary; defaults `(0.5, 0.05)` match the canonical
/// EasyLanguage implementation. The companion FAMA is `mama * 0.5 * fast_limit
/// + fama_prev * (1 - 0.5 * fast_limit)`, lagging MAMA so crossovers signal
/// trend reversals.
///
/// The indicator emits both lines as a [`MamaOutput`]. Use the [`Fama`] wrapper
/// in this module to expose just the slow line if needed (e.g. for chaining).
///
/// # Example
///
/// ```
/// use wickra_core::{Indicator, Mama};
///
/// let mut mama = Mama::new(0.5, 0.05).unwrap();
/// let mut last = None;
/// for i in 0..100 {
/// last = mama.update(100.0 + (f64::from(i) * 0.2).sin() * 5.0);
/// }
/// assert!(last.is_some());
/// ```
#[derive(Debug, Clone)]
pub struct Mama {
fast_limit: f64,
slow_limit: f64,
smooth_buf: Vec<f64>,
detrender_buf: Vec<f64>,
q1_buf: Vec<f64>,
i1_buf: Vec<f64>,
prev_i2: f64,
prev_q2: f64,
prev_re: f64,
prev_im: f64,
prev_period: f64,
prev_phase: f64,
prev_mama: f64,
prev_fama: f64,
count: usize,
last_value: Option<MamaOutput>,
}
impl Mama {
/// Construct with custom `(fast_limit, slow_limit)` adaptive alpha bounds.
///
/// # Errors
///
/// Returns [`Error::InvalidPeriod`] if either limit is outside `(0, 1]`
/// or if `slow_limit > fast_limit`.
pub fn new(fast_limit: f64, slow_limit: f64) -> Result<Self> {
if !fast_limit.is_finite()
|| !slow_limit.is_finite()
|| fast_limit <= 0.0
|| fast_limit > 1.0
|| slow_limit <= 0.0
|| slow_limit > 1.0
|| slow_limit > fast_limit
{
return Err(Error::InvalidPeriod {
message: "fast_limit, slow_limit must satisfy 0 < slow_limit <= fast_limit <= 1",
});
}
Ok(Self {
fast_limit,
slow_limit,
smooth_buf: Vec::with_capacity(7),
detrender_buf: Vec::with_capacity(7),
q1_buf: Vec::with_capacity(7),
i1_buf: Vec::with_capacity(7),
prev_i2: 0.0,
prev_q2: 0.0,
prev_re: 0.0,
prev_im: 0.0,
prev_period: 0.0,
prev_phase: 0.0,
prev_mama: 0.0,
prev_fama: 0.0,
count: 0,
last_value: None,
})
}
/// Default `(0.5, 0.05)` parameters from Ehlers' original publication.
pub fn classic() -> Self {
Self::new(0.5, 0.05).expect("classic MAMA limits are valid")
}
/// Configured `(fast_limit, slow_limit)`.
pub const fn limits(&self) -> (f64, f64) {
(self.fast_limit, self.slow_limit)
}
/// Current `(mama, fama)` pair if available.
pub const fn value(&self) -> Option<MamaOutput> {
self.last_value
}
fn push_front(buf: &mut Vec<f64>, v: f64, cap: usize) {
buf.insert(0, v);
if buf.len() > cap {
buf.truncate(cap);
}
}
}
impl Indicator for Mama {
type Input = f64;
type Output = MamaOutput;
fn update(&mut self, input: f64) -> Option<MamaOutput> {
if !input.is_finite() {
return self.last_value;
}
self.count += 1;
Self::push_front(&mut self.smooth_buf, input, 7);
if self.smooth_buf.len() < 4 {
return None;
}
let smooth = (4.0 * self.smooth_buf[0]
+ 3.0 * self.smooth_buf[1]
+ 2.0 * self.smooth_buf[2]
+ self.smooth_buf[3])
/ 10.0;
let period = self.prev_period.max(6.0).min(50.0);
let adj = 0.075 * period + 0.54;
if self.smooth_buf.len() < 7 {
// Seed the EMA outputs with the smoothed price so early bars are
// well-behaved without producing a public value.
self.prev_mama = smooth;
self.prev_fama = smooth;
return None;
}
let s0 = smooth;
let s2 = self.smooth_buf[2];
let s4 = self.smooth_buf[4];
let s6 = self.smooth_buf[6];
let detrender = (0.0962 * s0 + 0.5769 * s2 - 0.5769 * s4 - 0.0962 * s6) * adj;
Self::push_front(&mut self.detrender_buf, detrender, 7);
if self.detrender_buf.len() < 7 {
return None;
}
let q1 = (0.0962 * self.detrender_buf[0] + 0.5769 * self.detrender_buf[2]
- 0.5769 * self.detrender_buf[4]
- 0.0962 * self.detrender_buf[6])
* adj;
let i1 = self.detrender_buf[3];
Self::push_front(&mut self.q1_buf, q1, 7);
Self::push_front(&mut self.i1_buf, i1, 7);
if self.q1_buf.len() < 7 || self.i1_buf.len() < 7 {
return None;
}
let ji = (0.0962 * self.i1_buf[0] + 0.5769 * self.i1_buf[2]
- 0.5769 * self.i1_buf[4]
- 0.0962 * self.i1_buf[6])
* adj;
let jq = (0.0962 * self.q1_buf[0] + 0.5769 * self.q1_buf[2]
- 0.5769 * self.q1_buf[4]
- 0.0962 * self.q1_buf[6])
* adj;
let mut i2 = i1 - jq;
let mut q2 = q1 + ji;
i2 = 0.2 * i2 + 0.8 * self.prev_i2;
q2 = 0.2 * q2 + 0.8 * self.prev_q2;
let mut re = i2 * self.prev_i2 + q2 * self.prev_q2;
let mut im = i2 * self.prev_q2 - q2 * self.prev_i2;
re = 0.2 * re + 0.8 * self.prev_re;
im = 0.2 * im + 0.8 * self.prev_im;
self.prev_i2 = i2;
self.prev_q2 = q2;
self.prev_re = re;
self.prev_im = im;
let mut new_period = if im.abs() > f64::EPSILON && re.abs() > f64::EPSILON {
2.0 * PI / im.atan2(re)
} else {
self.prev_period
};
new_period = new_period.min(1.5 * self.prev_period);
new_period = new_period.max(0.67 * self.prev_period);
new_period = new_period.clamp(6.0, 50.0);
self.prev_period = 0.2 * new_period + 0.8 * self.prev_period;
// Adaptive alpha derived from phase rate-of-change.
let phase = if i1.abs() > f64::EPSILON {
(q1 / i1).atan().to_degrees()
} else {
self.prev_phase
};
let mut delta_phase = self.prev_phase - phase;
self.prev_phase = phase;
if delta_phase < 1.0 {
delta_phase = 1.0;
}
let mut alpha = self.fast_limit / delta_phase;
if alpha < self.slow_limit {
alpha = self.slow_limit;
}
if alpha > self.fast_limit {
alpha = self.fast_limit;
}
self.prev_mama = alpha * input + (1.0 - alpha) * self.prev_mama;
let fama_alpha = 0.5 * alpha;
self.prev_fama = fama_alpha * self.prev_mama + (1.0 - fama_alpha) * self.prev_fama;
if self.count < 33 {
return None;
}
let out = MamaOutput {
mama: self.prev_mama,
fama: self.prev_fama,
};
self.last_value = Some(out);
Some(out)
}
fn reset(&mut self) {
self.smooth_buf.clear();
self.detrender_buf.clear();
self.q1_buf.clear();
self.i1_buf.clear();
self.prev_i2 = 0.0;
self.prev_q2 = 0.0;
self.prev_re = 0.0;
self.prev_im = 0.0;
self.prev_period = 0.0;
self.prev_phase = 0.0;
self.prev_mama = 0.0;
self.prev_fama = 0.0;
self.count = 0;
self.last_value = None;
}
fn warmup_period(&self) -> usize {
33
}
fn is_ready(&self) -> bool {
self.last_value.is_some()
}
fn name(&self) -> &'static str {
"MAMA"
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::traits::BatchExt;
#[test]
fn rejects_invalid_limits() {
assert!(matches!(
Mama::new(0.0, 0.05),
Err(Error::InvalidPeriod { .. })
));
assert!(matches!(
Mama::new(0.5, 0.0),
Err(Error::InvalidPeriod { .. })
));
assert!(matches!(
Mama::new(0.05, 0.5),
Err(Error::InvalidPeriod { .. })
));
assert!(matches!(
Mama::new(1.5, 0.05),
Err(Error::InvalidPeriod { .. })
));
assert!(matches!(
Mama::new(f64::NAN, 0.05),
Err(Error::InvalidPeriod { .. })
));
}
#[test]
fn accessors_and_metadata() {
let mut mama = Mama::classic();
assert_eq!(mama.limits(), (0.5, 0.05));
assert_eq!(mama.warmup_period(), 33);
assert_eq!(mama.name(), "MAMA");
assert!(!mama.is_ready());
for i in 0..60 {
mama.update(100.0 + (f64::from(i) * 0.3).sin() * 5.0);
}
assert!(mama.is_ready());
assert!(mama.value().is_some());
}
#[test]
fn fama_lags_or_equals_mama_on_constant_series() {
let mut mama = Mama::classic();
let out = mama.batch(&[100.0_f64; 200]);
let last = out.iter().flatten().last().unwrap();
// On a flat series both lines converge to the price.
assert!((last.mama - 100.0).abs() < 1.0);
assert!((last.fama - 100.0).abs() < 1.0);
}
#[test]
fn batch_equals_streaming() {
let prices: Vec<f64> = (0..120)
.map(|i| 100.0 + (f64::from(i) * 0.25).sin() * 5.0)
.collect();
let mut a = Mama::classic();
let mut b = Mama::classic();
let batch = a.batch(&prices);
let streamed: Vec<_> = prices.iter().map(|p| b.update(*p)).collect();
assert_eq!(batch, streamed);
}
#[test]
fn ignores_non_finite_input() {
let mut mama = Mama::classic();
let prices: Vec<f64> = (0..100)
.map(|i| 100.0 + (f64::from(i) * 0.3).sin() * 5.0)
.collect();
mama.batch(&prices);
let before = mama.value();
assert!(before.is_some());
assert_eq!(mama.update(f64::NAN), before);
}
#[test]
fn reset_clears_state() {
let mut mama = Mama::classic();
let prices: Vec<f64> = (0..100)
.map(|i| 100.0 + (f64::from(i) * 0.3).sin() * 5.0)
.collect();
mama.batch(&prices);
assert!(mama.is_ready());
mama.reset();
assert!(!mama.is_ready());
}
}
+32
View File
@@ -7,6 +7,7 @@
mod acceleration_bands;
mod accelerator_oscillator;
mod ad_oscillator;
mod adaptive_cycle;
mod adl;
mod adx;
mod adxr;
@@ -26,6 +27,7 @@ mod bollinger;
mod bollinger_bandwidth;
mod camarilla_pivots;
mod cci;
mod center_of_gravity;
mod cfo;
mod chaikin_oscillator;
mod chaikin_volatility;
@@ -37,6 +39,9 @@ mod cmf;
mod cmo;
mod connors_rsi;
mod coppock;
mod cybernetic_cycle;
mod decycler;
mod decycler_oscillator;
mod dema;
mod demand_index;
mod demark_pivots;
@@ -45,19 +50,26 @@ mod donchian_stop;
mod double_bollinger;
mod dpo;
mod ease_of_movement;
mod ehlers_stochastic;
mod elder_impulse;
mod ema;
mod empirical_mode_decomposition;
mod evwma;
mod fama;
mod fibonacci_pivots;
mod fisher_transform;
mod force_index;
mod fractal_chaos_bands;
mod frama;
mod garman_klass;
mod hilbert_dominant_cycle;
mod hilo_activator;
mod historical_volatility;
mod hma;
mod hurst_channel;
mod inertia;
mod instantaneous_trendline;
mod inverse_fisher_transform;
mod jma;
mod kama;
mod keltner;
@@ -70,6 +82,7 @@ mod linreg_channel;
mod linreg_slope;
mod ma_envelope;
mod macd;
mod mama;
mod market_facilitation_index;
mod mass_index;
mod mcginley_dynamic;
@@ -90,10 +103,12 @@ mod pvi;
mod renko_trailing_stop;
mod roc;
mod rogers_satchell;
mod roofing_filter;
mod rsi;
mod rvi;
mod rvi_volatility;
mod rwi;
mod sine_wave;
mod sma;
mod smi;
mod smma;
@@ -104,6 +119,7 @@ mod std_dev;
mod step_trailing_stop;
mod stoch_rsi;
mod stochastic;
mod super_smoother;
mod super_trend;
mod t3;
mod td_combo;
@@ -155,6 +171,7 @@ mod zlema;
pub use acceleration_bands::{AccelerationBands, AccelerationBandsOutput};
pub use accelerator_oscillator::AcceleratorOscillator;
pub use ad_oscillator::AdOscillator;
pub use adaptive_cycle::AdaptiveCycle;
pub use adl::Adl;
pub use adx::{Adx, AdxOutput};
pub use adxr::Adxr;
@@ -174,6 +191,7 @@ pub use bollinger::{BollingerBands, BollingerOutput};
pub use bollinger_bandwidth::BollingerBandwidth;
pub use camarilla_pivots::{Camarilla, CamarillaPivotsOutput};
pub use cci::Cci;
pub use center_of_gravity::CenterOfGravity;
pub use cfo::Cfo;
pub use chaikin_oscillator::ChaikinOscillator;
pub use chaikin_volatility::ChaikinVolatility;
@@ -185,6 +203,9 @@ pub use cmf::ChaikinMoneyFlow;
pub use cmo::Cmo;
pub use connors_rsi::ConnorsRsi;
pub use coppock::Coppock;
pub use cybernetic_cycle::CyberneticCycle;
pub use decycler::Decycler;
pub use decycler_oscillator::DecyclerOscillator;
pub use dema::Dema;
pub use demand_index::DemandIndex;
pub use demark_pivots::{DemarkPivots, DemarkPivotsOutput};
@@ -193,19 +214,26 @@ pub use donchian_stop::{DonchianStop, DonchianStopOutput};
pub use double_bollinger::{DoubleBollinger, DoubleBollingerOutput};
pub use dpo::Dpo;
pub use ease_of_movement::EaseOfMovement;
pub use ehlers_stochastic::EhlersStochastic;
pub use elder_impulse::ElderImpulse;
pub use ema::Ema;
pub use empirical_mode_decomposition::EmpiricalModeDecomposition;
pub use evwma::Evwma;
pub use fama::Fama;
pub use fibonacci_pivots::{FibonacciPivots, FibonacciPivotsOutput};
pub use fisher_transform::FisherTransform;
pub use force_index::ForceIndex;
pub use fractal_chaos_bands::{FractalChaosBands, FractalChaosBandsOutput};
pub use frama::Frama;
pub use garman_klass::GarmanKlassVolatility;
pub use hilbert_dominant_cycle::HilbertDominantCycle;
pub use hilo_activator::HiLoActivator;
pub use historical_volatility::HistoricalVolatility;
pub use hma::Hma;
pub use hurst_channel::{HurstChannel, HurstChannelOutput};
pub use inertia::Inertia;
pub use instantaneous_trendline::InstantaneousTrendline;
pub use inverse_fisher_transform::InverseFisherTransform;
pub use jma::Jma;
pub use kama::Kama;
pub use keltner::{Keltner, KeltnerOutput};
@@ -218,6 +246,7 @@ pub use linreg_channel::{LinRegChannel, LinRegChannelOutput};
pub use linreg_slope::LinRegSlope;
pub use ma_envelope::{MaEnvelope, MaEnvelopeOutput};
pub use macd::{MacdIndicator, MacdOutput};
pub use mama::{Mama, MamaOutput};
pub use market_facilitation_index::MarketFacilitationIndex;
pub use mass_index::MassIndex;
pub use mcginley_dynamic::McGinleyDynamic;
@@ -238,10 +267,12 @@ pub use pvi::Pvi;
pub use renko_trailing_stop::RenkoTrailingStop;
pub use roc::Roc;
pub use rogers_satchell::RogersSatchellVolatility;
pub use roofing_filter::RoofingFilter;
pub use rsi::Rsi;
pub use rvi::Rvi;
pub use rvi_volatility::RviVolatility;
pub use rwi::{Rwi, RwiOutput};
pub use sine_wave::SineWave;
pub use sma::Sma;
pub use smi::Smi;
pub use smma::Smma;
@@ -252,6 +283,7 @@ pub use std_dev::StdDev;
pub use step_trailing_stop::StepTrailingStop;
pub use stoch_rsi::StochRsi;
pub use stochastic::{Stochastic, StochasticOutput};
pub use super_smoother::SuperSmoother;
pub use super_trend::{SuperTrend, SuperTrendOutput};
pub use t3::T3;
pub use td_combo::TdCombo;
@@ -0,0 +1,202 @@
//! Ehlers Roofing Filter (high-pass followed by SuperSmoother).
#![allow(clippy::doc_markdown)]
use std::f64::consts::PI;
use crate::error::{Error, Result};
use crate::indicators::super_smoother::SuperSmoother;
use crate::traits::Indicator;
/// Ehlers' Roofing Filter — a bandpass formed by feeding a 2-pole high-pass
/// into a [`SuperSmoother`].
///
/// Defined in *Cycle Analytics for Traders* (Ehlers 2013, ch. 7) as the
/// canonical pre-filter for cycle-aware oscillators: the high-pass strips out
/// the trend (periods longer than `hp_period`), and the SuperSmoother removes
/// noise (periods shorter than `lp_period`). The result is essentially the
/// 1048 bar cycle band by default.
///
/// # Example
///
/// ```
/// use wickra_core::{Indicator, RoofingFilter};
///
/// let mut rf = RoofingFilter::new(10, 48).unwrap();
/// let mut last = None;
/// for i in 0..120 {
/// last = rf.update(100.0 + (f64::from(i) * 0.2).sin() * 5.0);
/// }
/// assert!(last.is_some());
/// ```
#[derive(Debug, Clone)]
pub struct RoofingFilter {
lp_period: usize,
hp_period: usize,
alpha: f64,
prev_in_1: Option<f64>,
prev_hp_1: f64,
prev_hp_2: f64,
smoother: SuperSmoother,
last_value: Option<f64>,
}
impl RoofingFilter {
/// Construct with `lp_period` (SuperSmoother critical period) and
/// `hp_period` (high-pass cutoff). Defaults in Ehlers are `(10, 48)`.
///
/// # Errors
///
/// Returns [`Error::PeriodZero`] if either period is zero, and
/// [`Error::InvalidPeriod`] if `lp_period >= hp_period`.
pub fn new(lp_period: usize, hp_period: usize) -> Result<Self> {
if lp_period == 0 || hp_period == 0 {
return Err(Error::PeriodZero);
}
if lp_period >= hp_period {
return Err(Error::InvalidPeriod {
message: "lp_period must be strictly less than hp_period",
});
}
// Single-pole high-pass alpha from Ehlers ch. 7.
let arg = 2.0 * PI / hp_period as f64;
let alpha = (arg.cos() + arg.sin() - 1.0) / arg.cos();
Ok(Self {
lp_period,
hp_period,
alpha,
prev_in_1: None,
prev_hp_1: 0.0,
prev_hp_2: 0.0,
smoother: SuperSmoother::new(lp_period)?,
last_value: None,
})
}
/// Configured `(lp_period, hp_period)`.
pub const fn periods(&self) -> (usize, usize) {
(self.lp_period, self.hp_period)
}
/// Current value if available.
pub const fn value(&self) -> Option<f64> {
self.last_value
}
}
impl Indicator for RoofingFilter {
type Input = f64;
type Output = f64;
fn update(&mut self, input: f64) -> Option<f64> {
if !input.is_finite() {
return self.last_value;
}
let hp = if let Some(x1) = self.prev_in_1 {
let one_minus_half_alpha = 1.0 - self.alpha / 2.0;
one_minus_half_alpha * (input - x1) + (1.0 - self.alpha) * self.prev_hp_1
} else {
0.0
};
self.prev_hp_2 = self.prev_hp_1;
self.prev_hp_1 = hp;
self.prev_in_1 = Some(input);
let v = self.smoother.update(hp)?;
self.last_value = Some(v);
Some(v)
}
fn reset(&mut self) {
self.prev_in_1 = None;
self.prev_hp_1 = 0.0;
self.prev_hp_2 = 0.0;
self.smoother.reset();
self.last_value = None;
}
fn warmup_period(&self) -> usize {
// SuperSmoother is ready after one input; we need two to compute HP.
2
}
fn is_ready(&self) -> bool {
self.last_value.is_some()
}
fn name(&self) -> &'static str {
"RoofingFilter"
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::traits::BatchExt;
use approx::assert_relative_eq;
#[test]
fn new_rejects_invalid_periods() {
assert!(matches!(RoofingFilter::new(0, 48), Err(Error::PeriodZero)));
assert!(matches!(RoofingFilter::new(10, 0), Err(Error::PeriodZero)));
assert!(matches!(
RoofingFilter::new(48, 10),
Err(Error::InvalidPeriod { .. })
));
assert!(matches!(
RoofingFilter::new(10, 10),
Err(Error::InvalidPeriod { .. })
));
}
#[test]
fn accessors_and_metadata() {
let mut rf = RoofingFilter::new(10, 48).unwrap();
assert_eq!(rf.periods(), (10, 48));
assert_eq!(rf.warmup_period(), 2);
assert_eq!(rf.name(), "RoofingFilter");
assert!(!rf.is_ready());
rf.update(100.0);
rf.update(101.0);
assert!(rf.is_ready());
assert!(rf.value().is_some());
}
#[test]
fn constant_series_converges_to_zero() {
// High-pass on a flat input is zero, and the smoother of zero is zero.
let mut rf = RoofingFilter::new(10, 48).unwrap();
let out = rf.batch(&[42.0_f64; 300]);
for x in out.iter().skip(100).flatten() {
assert_relative_eq!(*x, 0.0, epsilon = 1e-6);
}
}
#[test]
fn batch_equals_streaming() {
let prices: Vec<f64> = (0..200)
.map(|i| 100.0 + (f64::from(i) * 0.15).sin() * 5.0)
.collect();
let mut a = RoofingFilter::new(10, 48).unwrap();
let mut b = RoofingFilter::new(10, 48).unwrap();
let batch = a.batch(&prices);
let streamed: Vec<_> = prices.iter().map(|p| b.update(*p)).collect();
assert_eq!(batch, streamed);
}
#[test]
fn ignores_non_finite_input() {
let mut rf = RoofingFilter::new(10, 48).unwrap();
rf.batch(&(1..=100).map(f64::from).collect::<Vec<_>>());
let before = rf.value();
assert!(before.is_some());
assert_eq!(rf.update(f64::NAN), before);
}
#[test]
fn reset_clears_state() {
let mut rf = RoofingFilter::new(10, 48).unwrap();
rf.batch(&(1..=100).map(f64::from).collect::<Vec<_>>());
assert!(rf.is_ready());
rf.reset();
assert!(!rf.is_ready());
}
}
@@ -0,0 +1,218 @@
//! Ehlers Sine Wave indicator.
#![allow(clippy::manual_clamp)]
use std::f64::consts::PI;
use crate::indicators::hilbert_dominant_cycle::HilbertDominantCycle;
use crate::traits::Indicator;
/// Ehlers' Sine Wave indicator (sine + leadsine).
///
/// Implementation from *Rocket Science for Traders* (Ehlers 2001, ch. 9). Uses
/// the same Hilbert-transform machinery as [`HilbertDominantCycle`] to derive
/// the instantaneous phase, then returns `sin(phase)` and the 45° lead
/// `sin(phase + 45°)`. The two lines cross deep in trends but oscillate
/// rapidly during cycles, providing a visual lead/lag signal.
///
/// Only the primary `sine` line is exposed as the scalar output to match the
/// crate's standard scalar-indicator surface; the lead is accessible via the
/// [`SineWave::lead`] accessor after each update.
///
/// # Example
///
/// ```
/// use wickra_core::{Indicator, SineWave};
///
/// let mut sw = SineWave::new();
/// let mut last = None;
/// for i in 0..200 {
/// last = sw.update(100.0 + (f64::from(i) * 0.4).sin() * 5.0);
/// }
/// assert!(last.is_some());
/// ```
#[derive(Debug, Clone, Default)]
pub struct SineWave {
cycle: HilbertDominantCycle,
smooth_buf: Vec<f64>,
detrender_buf: Vec<f64>,
last_phase: f64,
last_sine: Option<f64>,
last_lead: f64,
count: usize,
}
impl SineWave {
/// Construct a new Sine Wave indicator.
pub fn new() -> Self {
Self::default()
}
/// Most recent lead (45°-ahead) value. `0.0` until the indicator is ready.
pub const fn lead(&self) -> f64 {
self.last_lead
}
/// Current sine value if available.
pub const fn value(&self) -> Option<f64> {
self.last_sine
}
fn push_front(buf: &mut Vec<f64>, v: f64, cap: usize) {
buf.insert(0, v);
if buf.len() > cap {
buf.truncate(cap);
}
}
}
impl Indicator for SineWave {
type Input = f64;
type Output = f64;
fn update(&mut self, input: f64) -> Option<f64> {
if !input.is_finite() {
return self.last_sine;
}
self.count += 1;
// Drive the dominant-cycle estimator first; its smoothing state is
// independent from ours so the two share input but not buffers.
let _ = self.cycle.update(input);
Self::push_front(&mut self.smooth_buf, input, 7);
if self.smooth_buf.len() < 4 {
return None;
}
let smooth = (4.0 * self.smooth_buf[0]
+ 3.0 * self.smooth_buf[1]
+ 2.0 * self.smooth_buf[2]
+ self.smooth_buf[3])
/ 10.0;
if self.smooth_buf.len() < 7 {
return None;
}
let period = self.cycle.value().unwrap_or(15.0).max(6.0).min(50.0);
let adj = 0.075 * period + 0.54;
let s0 = smooth;
let s2 = self.smooth_buf[2];
let s4 = self.smooth_buf[4];
let s6 = self.smooth_buf[6];
let detrender = (0.0962 * s0 + 0.5769 * s2 - 0.5769 * s4 - 0.0962 * s6) * adj;
Self::push_front(&mut self.detrender_buf, detrender, 7);
if self.detrender_buf.len() < 7 {
return None;
}
let q1 = (0.0962 * self.detrender_buf[0] + 0.5769 * self.detrender_buf[2]
- 0.5769 * self.detrender_buf[4]
- 0.0962 * self.detrender_buf[6])
* adj;
let i1 = self.detrender_buf[3];
let phase = if i1.abs() > f64::EPSILON {
(q1 / i1).atan()
} else {
self.last_phase
};
self.last_phase = phase;
let sine = phase.sin();
let lead = (phase + PI / 4.0).sin();
if self.count < 50 {
return None;
}
self.last_sine = Some(sine);
self.last_lead = lead;
Some(sine)
}
fn reset(&mut self) {
self.cycle.reset();
self.smooth_buf.clear();
self.detrender_buf.clear();
self.last_phase = 0.0;
self.last_sine = None;
self.last_lead = 0.0;
self.count = 0;
}
fn warmup_period(&self) -> usize {
50
}
fn is_ready(&self) -> bool {
self.last_sine.is_some()
}
fn name(&self) -> &'static str {
"SineWave"
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::traits::BatchExt;
#[test]
fn accessors_and_metadata() {
let mut sw = SineWave::new();
assert_eq!(sw.warmup_period(), 50);
assert_eq!(sw.name(), "SineWave");
assert!(!sw.is_ready());
assert!(sw.value().is_none());
let prices: Vec<f64> = (0..120)
.map(|i| 100.0 + (f64::from(i) * 0.4).sin() * 5.0)
.collect();
sw.batch(&prices);
assert!(sw.is_ready());
assert!(sw.value().is_some());
}
#[test]
fn output_bounded() {
let prices: Vec<f64> = (0..200)
.map(|i| 100.0 + (f64::from(i) * 0.3).cos() * 5.0)
.collect();
let mut sw = SineWave::new();
for v in sw.batch(&prices).into_iter().flatten() {
assert!((-1.0..=1.0).contains(&v), "sine out of bounds: {v}");
}
// Lead value also bounded after warmup.
assert!(sw.lead() >= -1.0 && sw.lead() <= 1.0);
}
#[test]
fn batch_equals_streaming() {
let prices: Vec<f64> = (0..200)
.map(|i| 100.0 + (f64::from(i) * 0.3).sin() * 5.0)
.collect();
let mut a = SineWave::new();
let mut b = SineWave::new();
let batch = a.batch(&prices);
let streamed: Vec<_> = prices.iter().map(|p| b.update(*p)).collect();
assert_eq!(batch, streamed);
}
#[test]
fn ignores_non_finite_input() {
let mut sw = SineWave::new();
let prices: Vec<f64> = (0..120)
.map(|i| 100.0 + (f64::from(i) * 0.4).sin() * 5.0)
.collect();
sw.batch(&prices);
let before = sw.value();
assert!(before.is_some());
assert_eq!(sw.update(f64::NAN), before);
}
#[test]
fn reset_clears_state() {
let mut sw = SineWave::new();
let prices: Vec<f64> = (0..120)
.map(|i| 100.0 + (f64::from(i) * 0.4).sin() * 5.0)
.collect();
sw.batch(&prices);
assert!(sw.is_ready());
sw.reset();
assert!(!sw.is_ready());
assert!(sw.value().is_none());
}
}
@@ -0,0 +1,217 @@
//! Ehlers SuperSmoother filter.
#![allow(clippy::doc_markdown)]
use std::f64::consts::PI;
use crate::error::{Error, Result};
use crate::traits::Indicator;
/// Ehlers' 2-pole Butterworth-style "SuperSmoother" lowpass filter.
///
/// From John Ehlers' *Cycle Analytics for Traders* (2013, ch. 3). For a given
/// critical period `period`, the filter coefficients are:
///
/// ```text
/// a1 = exp(-sqrt(2) * pi / period)
/// b1 = 2 * a1 * cos(sqrt(2) * pi / period)
/// c2 = b1
/// c3 = -a1 * a1
/// c1 = 1 - c2 - c3
/// y[t] = c1 * (x[t] + x[t-1]) / 2 + c2 * y[t-1] + c3 * y[t-2]
/// ```
///
/// The implementation needs two prior inputs and two prior outputs to begin
/// running; until then it returns the input itself (a common Ehlers initial
/// condition), which lets downstream filters warm up without long delays.
///
/// # Example
///
/// ```
/// use wickra_core::{Indicator, SuperSmoother};
///
/// let mut ss = SuperSmoother::new(10).unwrap();
/// let mut last = None;
/// for i in 0..40 {
/// last = ss.update(100.0 + f64::from(i));
/// }
/// assert!(last.is_some());
/// ```
#[derive(Debug, Clone)]
pub struct SuperSmoother {
period: usize,
c1: f64,
c2: f64,
c3: f64,
prev_input: Option<f64>,
prev_output_1: Option<f64>,
prev_output_2: Option<f64>,
count: usize,
}
impl SuperSmoother {
/// Construct a new SuperSmoother with the given critical period.
///
/// # Errors
///
/// Returns [`Error::PeriodZero`] if `period == 0`.
pub fn new(period: usize) -> Result<Self> {
if period == 0 {
return Err(Error::PeriodZero);
}
let arg = std::f64::consts::SQRT_2 * PI / period as f64;
let a1 = (-arg).exp();
let b1 = 2.0 * a1 * arg.cos();
let c2 = b1;
let c3 = -a1 * a1;
let c1 = 1.0 - c2 - c3;
Ok(Self {
period,
c1,
c2,
c3,
prev_input: None,
prev_output_1: None,
prev_output_2: None,
count: 0,
})
}
/// Configured period.
pub const fn period(&self) -> usize {
self.period
}
/// Filter coefficients `(c1, c2, c3)`.
pub const fn coefficients(&self) -> (f64, f64, f64) {
(self.c1, self.c2, self.c3)
}
/// Current value if available.
pub const fn value(&self) -> Option<f64> {
self.prev_output_1
}
}
impl Indicator for SuperSmoother {
type Input = f64;
type Output = f64;
fn update(&mut self, input: f64) -> Option<f64> {
if !input.is_finite() {
return self.prev_output_1;
}
self.count += 1;
let output = match (self.prev_input, self.prev_output_1, self.prev_output_2) {
(Some(p_in), Some(y1), Some(y2)) => {
let avg = 0.5 * (input + p_in);
self.c1 * avg + self.c2 * y1 + self.c3 * y2
}
_ => input,
};
self.prev_output_2 = self.prev_output_1;
self.prev_output_1 = Some(output);
self.prev_input = Some(input);
Some(output)
}
fn reset(&mut self) {
self.prev_input = None;
self.prev_output_1 = None;
self.prev_output_2 = None;
self.count = 0;
}
fn warmup_period(&self) -> usize {
1
}
fn is_ready(&self) -> bool {
self.prev_output_1.is_some()
}
fn name(&self) -> &'static str {
"SuperSmoother"
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::traits::BatchExt;
use approx::assert_relative_eq;
#[test]
fn new_rejects_zero_period() {
assert!(matches!(SuperSmoother::new(0), Err(Error::PeriodZero)));
}
#[test]
fn accessors_and_metadata() {
let mut ss = SuperSmoother::new(10).unwrap();
assert_eq!(ss.period(), 10);
assert_eq!(ss.name(), "SuperSmoother");
assert_eq!(ss.warmup_period(), 1);
let (c1, c2, c3) = ss.coefficients();
// Coefficients sum to 1 by construction (steady-state gain == 1).
assert_relative_eq!(c1 + c2 + c3, 1.0, epsilon = 1e-12);
assert!(ss.value().is_none());
ss.update(42.0);
assert!(ss.value().is_some());
assert!(ss.is_ready());
}
#[test]
fn first_output_equals_input_then_filters() {
let mut ss = SuperSmoother::new(10).unwrap();
// Initial condition: first two outputs equal their inputs.
assert_eq!(ss.update(100.0), Some(100.0));
assert_eq!(ss.update(101.0), Some(101.0));
let third = ss.update(102.0).unwrap();
// From step 3 onward, the recursive filter activates and the result
// is no longer the raw input.
assert!((third - 102.0).abs() < 5.0);
}
#[test]
fn constant_series_converges_to_constant() {
// Steady-state gain is 1 (c1 + c2 + c3 = 1), so a flat input yields a
// flat output after warmup.
let mut ss = SuperSmoother::new(20).unwrap();
let out = ss.batch(&[50.0_f64; 200]);
for x in out.iter().skip(50).flatten() {
assert_relative_eq!(*x, 50.0, epsilon = 1e-9);
}
}
#[test]
fn batch_equals_streaming() {
let prices: Vec<f64> = (0..120)
.map(|i| 100.0 + (f64::from(i) * 0.2).sin() * 5.0)
.collect();
let mut a = SuperSmoother::new(15).unwrap();
let mut b = SuperSmoother::new(15).unwrap();
let batch = a.batch(&prices);
let streamed: Vec<_> = prices.iter().map(|p| b.update(*p)).collect();
assert_eq!(batch, streamed);
}
#[test]
fn ignores_non_finite_input() {
let mut ss = SuperSmoother::new(10).unwrap();
ss.batch(&(1..=20).map(f64::from).collect::<Vec<_>>());
let before = ss.value();
assert!(before.is_some());
assert_eq!(ss.update(f64::NAN), before);
assert_eq!(ss.update(f64::INFINITY), before);
}
#[test]
fn reset_clears_state() {
let mut ss = SuperSmoother::new(10).unwrap();
ss.batch(&(1..=40).map(f64::from).collect::<Vec<_>>());
assert!(ss.is_ready());
ss.reset();
assert!(!ss.is_ready());
assert_eq!(ss.update(50.0), Some(50.0));
}
}
+30 -28
View File
@@ -44,35 +44,37 @@ pub mod indicators;
pub use error::{Error, Result};
pub use indicators::{
AccelerationBands, AccelerationBandsOutput, AcceleratorOscillator, AdOscillator, Adl, Adx,
AdxOutput, Adxr, Alligator, AlligatorOutput, Alma, AnchoredVwap, Apo, Aroon, AroonOscillator,
AroonOutput, Atr, AtrBands, AtrBandsOutput, AtrTrailingStop, AwesomeOscillator,
AwesomeOscillatorHistogram, BalanceOfPower, BollingerBands, BollingerBandwidth,
BollingerOutput, Camarilla, CamarillaPivotsOutput, Cci, Cfo, ChaikinMoneyFlow,
ChaikinOscillator, ChaikinVolatility, ChandeKrollStop, ChandeKrollStopOutput, ChandelierExit,
ChandelierExitOutput, ChoppinessIndex, ClassicPivots, ClassicPivotsOutput, Cmo, ConnorsRsi,
Coppock, Dema, DemandIndex, DemarkPivots, DemarkPivotsOutput, Donchian, DonchianOutput,
DonchianStop, DonchianStopOutput, DoubleBollinger, DoubleBollingerOutput, Dpo, EaseOfMovement,
ElderImpulse, Ema, Evwma, FibonacciPivots, FibonacciPivotsOutput, ForceIndex,
FractalChaosBands, FractalChaosBandsOutput, Frama, GarmanKlassVolatility, HiLoActivator,
HistoricalVolatility, Hma, HurstChannel, HurstChannelOutput, Inertia, Jma, Kama, Keltner,
KeltnerOutput, Kst, KstOutput, Kvo, LaguerreRsi, LinRegAngle, LinRegChannel,
AccelerationBands, AccelerationBandsOutput, AcceleratorOscillator, AdOscillator, AdaptiveCycle,
Adl, Adx, AdxOutput, Adxr, Alligator, AlligatorOutput, Alma, AnchoredVwap, Apo, Aroon,
AroonOscillator, AroonOutput, Atr, AtrBands, AtrBandsOutput, AtrTrailingStop,
AwesomeOscillator, AwesomeOscillatorHistogram, BalanceOfPower, BollingerBands,
BollingerBandwidth, BollingerOutput, Camarilla, CamarillaPivotsOutput, Cci, CenterOfGravity,
Cfo, ChaikinMoneyFlow, ChaikinOscillator, ChaikinVolatility, ChandeKrollStop,
ChandeKrollStopOutput, ChandelierExit, ChandelierExitOutput, ChoppinessIndex, ClassicPivots,
ClassicPivotsOutput, Cmo, ConnorsRsi, Coppock, CyberneticCycle, Decycler, DecyclerOscillator,
Dema, DemandIndex, DemarkPivots, DemarkPivotsOutput, Donchian, DonchianOutput, DonchianStop,
DonchianStopOutput, DoubleBollinger, DoubleBollingerOutput, Dpo, EaseOfMovement,
EhlersStochastic, ElderImpulse, Ema, EmpiricalModeDecomposition, Evwma, Fama, FibonacciPivots,
FibonacciPivotsOutput, FisherTransform, ForceIndex, FractalChaosBands, FractalChaosBandsOutput,
Frama, GarmanKlassVolatility, HiLoActivator, HilbertDominantCycle, HistoricalVolatility, Hma,
HurstChannel, HurstChannelOutput, Inertia, InstantaneousTrendline, InverseFisherTransform, Jma,
Kama, Keltner, KeltnerOutput, Kst, KstOutput, Kvo, LaguerreRsi, LinRegAngle, LinRegChannel,
LinRegChannelOutput, LinRegSlope, LinearRegression, MaEnvelope, MaEnvelopeOutput,
MacdIndicator, MacdOutput, MarketFacilitationIndex, MassIndex, McGinleyDynamic, MedianPrice,
Mfi, Mom, Natr, Nvi, Obv, ParkinsonVolatility, PercentB, PercentageTrailingStop, Pgo, Pmo, Ppo,
Psar, Pvi, RenkoTrailingStop, Roc, RogersSatchellVolatility, RollingVwap, Rsi, Rvi,
RviVolatility, Rwi, RwiOutput, Sma, Smi, Smma, StandardErrorBands, StandardErrorBandsOutput,
StarcBands, StarcBandsOutput, Stc, StdDev, StepTrailingStop, StochRsi, Stochastic,
StochasticOutput, SuperTrend, SuperTrendOutput, TdCombo, TdCountdown, TdDeMarker,
TdDifferential, TdLines, TdLinesOutput, TdOpen, TdPressure, TdRangeProjection,
TdRangeProjectionOutput, TdRei, TdRiskLevel, TdRiskLevelOutput, TdSequential,
TdSequentialOutput, TdSetup, Tema, Tii, Trima, Trix, TrueRange, Tsi, Tsv, TtmSqueeze,
TtmSqueezeOutput, TypicalPrice, UlcerIndex, UltimateOscillator, VerticalHorizontalFilter,
Vidya, VoltyStop, VolumeOscillator, VolumePriceTrend, Vortex, VortexOutput, Vwap,
VwapStdDevBands, VwapStdDevBandsOutput, Vwma, Vzo, WaveTrend, WaveTrendOutput, WeightedClose,
WilliamsFractals, WilliamsFractalsOutput, WilliamsR, Wma, WoodiePivots, WoodiePivotsOutput,
YangZhangVolatility, YoyoExit, ZScore, ZeroLagMacd, ZeroLagMacdOutput, ZigZag, ZigZagOutput,
Zlema, T3,
MacdIndicator, MacdOutput, Mama, MamaOutput, MarketFacilitationIndex, MassIndex,
McGinleyDynamic, MedianPrice, Mfi, Mom, Natr, Nvi, Obv, ParkinsonVolatility, PercentB,
PercentageTrailingStop, Pgo, Pmo, Ppo, Psar, Pvi, RenkoTrailingStop, Roc,
RogersSatchellVolatility, RollingVwap, RoofingFilter, Rsi, Rvi, RviVolatility, Rwi, RwiOutput,
SineWave, Sma, Smi, Smma, StandardErrorBands, StandardErrorBandsOutput, StarcBands,
StarcBandsOutput, Stc, StdDev, StepTrailingStop, StochRsi, Stochastic, StochasticOutput,
SuperSmoother, SuperTrend, SuperTrendOutput, TdCombo, TdCountdown, TdDeMarker, TdDifferential,
TdLines, TdLinesOutput, TdOpen, TdPressure, TdRangeProjection, TdRangeProjectionOutput, TdRei,
TdRiskLevel, TdRiskLevelOutput, TdSequential, TdSequentialOutput, TdSetup, Tema, Tii, Trima,
Trix, TrueRange, Tsi, Tsv, TtmSqueeze, TtmSqueezeOutput, TypicalPrice, UlcerIndex,
UltimateOscillator, VerticalHorizontalFilter, Vidya, VoltyStop, VolumeOscillator,
VolumePriceTrend, Vortex, VortexOutput, Vwap, VwapStdDevBands, VwapStdDevBandsOutput, Vwma,
Vzo, WaveTrend, WaveTrendOutput, WeightedClose, WilliamsFractals, WilliamsFractalsOutput,
WilliamsR, Wma, WoodiePivots, WoodiePivotsOutput, YangZhangVolatility, YoyoExit, ZScore,
ZeroLagMacd, ZeroLagMacdOutput, ZigZag, ZigZagOutput, Zlema, T3,
};
pub use ohlcv::{Candle, Tick};
pub use traits::{BatchExt, Chain, Indicator};
+71 -7
View File
@@ -19,13 +19,16 @@
use criterion::{criterion_group, criterion_main, BenchmarkId, Criterion, Throughput};
use std::hint::black_box;
use wickra::{
AccelerationBands, AdOscillator, Adxr, Alma, AnchoredVwap, Atr, AtrBands, BatchExt,
BollingerBands, Camarilla, Candle, ClassicPivots, DemandIndex, DemarkPivots, DonchianStop,
DoubleBollinger, Ema, FibonacciPivots, FractalChaosBands, Frama, GarmanKlassVolatility,
HiLoActivator, HurstChannel, Indicator, Jma, Kst, Kvo, LinRegChannel, MaEnvelope,
MacdIndicator, MarketFacilitationIndex, McGinleyDynamic, Nvi, Obv, ParkinsonVolatility,
PercentageTrailingStop, Pgo, Pvi, RenkoTrailingStop, RogersSatchellVolatility, Rsi, Rvi,
RviVolatility, Rwi, Sma, StandardErrorBands, StarcBands, StepTrailingStop, Stochastic, TdCombo,
AccelerationBands, AdOscillator, AdaptiveCycle, Adxr, Alma, AnchoredVwap, Atr, AtrBands,
BatchExt, BollingerBands, Camarilla, Candle, CenterOfGravity, ClassicPivots, CyberneticCycle,
Decycler, DecyclerOscillator, DemandIndex, DemarkPivots, DonchianStop, DoubleBollinger,
EhlersStochastic, Ema, EmpiricalModeDecomposition, Fama, FibonacciPivots, FisherTransform,
FractalChaosBands, Frama, GarmanKlassVolatility, HiLoActivator, HilbertDominantCycle,
HurstChannel, Indicator, InstantaneousTrendline, InverseFisherTransform, Jma, Kst, Kvo,
LinRegChannel, MaEnvelope, MacdIndicator, Mama, MarketFacilitationIndex, McGinleyDynamic, Nvi,
Obv, ParkinsonVolatility, PercentageTrailingStop, Pgo, Pvi, RenkoTrailingStop,
RogersSatchellVolatility, RoofingFilter, Rsi, Rvi, RviVolatility, Rwi, SineWave, Sma,
StandardErrorBands, StarcBands, StepTrailingStop, Stochastic, SuperSmoother, TdCombo,
TdCountdown, TdDeMarker, TdDifferential, TdLines, TdOpen, TdPressure, TdRangeProjection, TdRei,
TdRiskLevel, TdSequential, TdSetup, Tii, Tsv, TtmSqueeze, Vidya, VoltyStop, VolumeOscillator,
VwapStdDevBands, Vzo, WaveTrend, WilliamsFractals, Wma, WoodiePivots, YangZhangVolatility,
@@ -185,6 +188,67 @@ fn benches(c: &mut Criterion) {
bench_candle_input(c, "stochastic", &candles, Stochastic::classic);
bench_candle_input(c, "obv", &candles, Obv::new);
// Family 10 — Ehlers / Cycle scalar benchmarks.
bench_scalar(c, "super_smoother", &closes, || {
SuperSmoother::new(10).unwrap()
});
bench_scalar(c, "fisher_transform", &closes, || {
FisherTransform::new(10).unwrap()
});
bench_scalar(c, "inverse_fisher_transform", &closes, || {
InverseFisherTransform::new(1.0).unwrap()
});
bench_scalar(c, "decycler", &closes, || Decycler::new(20).unwrap());
bench_scalar(c, "decycler_oscillator", &closes, || {
DecyclerOscillator::new(10, 30).unwrap()
});
bench_scalar(c, "roofing_filter", &closes, || {
RoofingFilter::new(10, 48).unwrap()
});
bench_scalar(c, "center_of_gravity", &closes, || {
CenterOfGravity::new(10).unwrap()
});
bench_scalar(c, "cybernetic_cycle", &closes, || {
CyberneticCycle::new(10).unwrap()
});
bench_scalar(c, "instantaneous_trendline", &closes, || {
InstantaneousTrendline::new(20).unwrap()
});
bench_scalar(c, "ehlers_stochastic", &closes, || {
EhlersStochastic::new(20).unwrap()
});
bench_scalar(c, "empirical_mode_decomposition", &closes, || {
EmpiricalModeDecomposition::new(20, 0.5).unwrap()
});
bench_scalar(
c,
"hilbert_dominant_cycle",
&closes,
HilbertDominantCycle::new,
);
bench_scalar(c, "adaptive_cycle", &closes, AdaptiveCycle::new);
bench_scalar(c, "sine_wave", &closes, SineWave::new);
bench_scalar(c, "fama", &closes, || Fama::new(0.5, 0.05).unwrap());
// MAMA: multi-output, mirrored on macd's streaming-only bench style.
{
let mut group = c.benchmark_group("mama");
for &n in SIZES {
let n = n.min(closes.len());
let series = &closes[..n];
group.throughput(Throughput::Elements(n as u64));
group.bench_with_input(BenchmarkId::new("streaming", n), series, |b, prices| {
b.iter(|| {
let mut ind = Mama::classic();
for p in prices {
black_box(ind.update(*p));
}
});
});
}
group.finish();
}
// --- Family 11: DeMark ---
bench_candle_input(c, "td_setup", &candles, TdSetup::classic);
bench_candle_input(c, "td_sequential", &candles, TdSequential::classic);
+37 -9
View File
@@ -15,13 +15,16 @@
use libfuzzer_sys::fuzz_target;
use wickra_core::{
Alma, Apo, BatchExt, BollingerBands, Cfo, Cmo, ConnorsRsi, Coppock, Dema, DoubleBollinger, Dpo,
ElderImpulse, Ema, Frama, HistoricalVolatility, Hma, Indicator, Jma, Kama, Kst, LaguerreRsi,
LinRegAngle, LinRegChannel, LinRegSlope, LinearRegression, MaEnvelope, MacdIndicator,
McGinleyDynamic, Mom, PercentageTrailingStop, Pmo, Ppo, RenkoTrailingStop, Roc, Rsi,
RviVolatility, Sma, Smma, StandardErrorBands, Stc, StdDev, StepTrailingStop, StochRsi, T3, Tema,
Tii, Trima, Trix, Tsi, UlcerIndex, VerticalHorizontalFilter, Vidya, Wma, ZScore, ZeroLagMacd,
Zlema,
AdaptiveCycle, Alma, Apo, BatchExt, BollingerBands, CenterOfGravity, Cfo, Cmo, ConnorsRsi,
Coppock, CyberneticCycle, Decycler, DecyclerOscillator, Dema, DoubleBollinger, Dpo,
EhlersStochastic, ElderImpulse, Ema, EmpiricalModeDecomposition, Fama, FisherTransform,
Frama, HilbertDominantCycle, HistoricalVolatility, Hma, Indicator, InstantaneousTrendline,
InverseFisherTransform, Jma, Kama, Kst, LaguerreRsi, LinRegAngle, LinRegChannel,
LinRegSlope, LinearRegression, MaEnvelope, MacdIndicator, Mama, McGinleyDynamic, Mom,
PercentageTrailingStop, Pmo, Ppo, RenkoTrailingStop, Roc, RoofingFilter, Rsi, RviVolatility,
SineWave, Sma, Smma, StandardErrorBands, Stc, StdDev, StepTrailingStop, StochRsi,
SuperSmoother, T3, Tema, Tii, Trima, Trix, Tsi, UlcerIndex, VerticalHorizontalFilter, Vidya,
Wma, ZScore, ZeroLagMacd, Zlema,
};
/// Drive a single streaming + batch run through one scalar indicator. Marked
@@ -112,8 +115,26 @@ fuzz_target!(|data: Vec<f64>| {
drive(|| StepTrailingStop::new(1.0).unwrap(), &data);
drive(|| RenkoTrailingStop::new(1.0).unwrap(), &data);
// MACD and Bollinger Bands have non-`f64` outputs, so they cannot use the
// generic `drive` helper above. Streaming + batch are still both exercised.
// Family 10 — Ehlers / Cycle scalar indicators.
drive(|| SuperSmoother::new(10).unwrap(), &data);
drive(|| FisherTransform::new(10).unwrap(), &data);
drive(|| InverseFisherTransform::new(1.0).unwrap(), &data);
drive(|| Decycler::new(20).unwrap(), &data);
drive(|| DecyclerOscillator::new(10, 30).unwrap(), &data);
drive(|| RoofingFilter::new(10, 48).unwrap(), &data);
drive(|| CenterOfGravity::new(10).unwrap(), &data);
drive(|| CyberneticCycle::new(10).unwrap(), &data);
drive(|| InstantaneousTrendline::new(20).unwrap(), &data);
drive(|| EhlersStochastic::new(20).unwrap(), &data);
drive(|| EmpiricalModeDecomposition::new(20, 0.5).unwrap(), &data);
drive(HilbertDominantCycle::new, &data);
drive(AdaptiveCycle::new, &data);
drive(SineWave::new, &data);
drive(|| Fama::new(0.5, 0.05).unwrap(), &data);
// MACD, Bollinger Bands and MAMA have non-`f64` outputs, so they cannot
// use the generic `drive` helper above. Streaming + batch are still both
// exercised.
{
let mut macd = MacdIndicator::new(12, 26, 9).unwrap();
for &x in &data {
@@ -128,6 +149,13 @@ fuzz_target!(|data: Vec<f64>| {
}
let _ = BollingerBands::new(20, 2.0).unwrap().batch(&data);
}
{
let mut mama = Mama::new(0.5, 0.05).unwrap();
for &x in &data {
let _ = mama.update(x);
}
let _ = Mama::new(0.5, 0.05).unwrap().batch(&data);
}
// --- Family 05: scalar-input band/channel indicators (multi-output) ---
{