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:
@@ -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,
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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 ---------------------------------------------------------
|
||||
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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 Transform–based 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);
|
||||
}
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
/// 10–48 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));
|
||||
}
|
||||
}
|
||||
@@ -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};
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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) ---
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user