diff --git a/CHANGELOG.md b/CHANGELOG.md index db3f902a..d5365c9c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,13 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). ## [Unreleased] +- **GD** — generalized DEMA (GD), Tillson's volume-factor double EMA and the building block of T3 (`GD`). +- **GMA** — geometric moving average (GMA), the rolling geometric mean of prices (`GMA`). +- **Holt-Winters** — Holt's linear (double exponential) smoothing with level and trend components (`HoltWinters`). +- **Adaptive Laguerre** — Ehlers adaptive Laguerre filter with median-error-adaptive gamma (`AdaptiveLaguerre`). +- **Median MA** — median moving average, the rolling median of prices (`MedianMA`). +- **EHMA** — exponential Hull moving average (EHMA), the Hull construction built from EMAs (`EHMA`). +- **SWMA** — sine-weighted moving average (SWMA), a symmetric half-cycle sine window (`SWMA`). ## [0.5.4] - 2026-06-04 - **Roll Measure** — effective spread implied by the negative serial covariance of trade-price changes (Roll 1984) (`RollMeasure`). diff --git a/README.md b/README.md index 47ee4cdd..616a177f 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,5 @@

- Wickra — streaming-first technical indicators + Wickra — streaming-first technical indicators

[![CI](https://github.com/wickra-lib/wickra/actions/workflows/ci.yml/badge.svg)](https://github.com/wickra-lib/wickra/actions/workflows/ci.yml) @@ -48,7 +48,7 @@ Full documentation lives at **[docs.wickra.org](https://docs.wickra.org)**: [Node](https://docs.wickra.org/Quickstart-Node), [WASM](https://docs.wickra.org/Quickstart-WASM). - **Indicators** — a per-indicator deep dive (formula, parameters, warmup) for - every one of the 396 indicators; start at the + every one of the 403 indicators; start at the [indicators overview](https://docs.wickra.org/Indicators-Overview). - **Reference** — [warmup periods](https://docs.wickra.org/Warmup-Periods), [streaming vs batch](https://docs.wickra.org/Streaming-vs-Batch), @@ -136,14 +136,14 @@ python -m benchmarks.compare_libraries ## Indicators -396 streaming-first indicators across twenty-four families. Every one passes the +403 streaming-first indicators across twenty-four families. Every one passes the `batch == streaming` equivalence test, reference-value tests, and reset semantics tests. Each has a per-indicator deep dive (formula, parameters, warmup) at [docs.wickra.org](https://docs.wickra.org/Indicators-Overview). | Family | Indicators | |--------|-----------| -| Moving Averages | SMA, EMA, WMA, DEMA, TEMA, HMA, KAMA, SMMA, TRIMA, ZLEMA, T3, VWMA, ALMA, McGinley Dynamic, FRAMA, VIDYA, JMA, Alligator, EVWMA | +| Moving Averages | SMA, EMA, WMA, DEMA, TEMA, HMA, KAMA, SMMA, TRIMA, ZLEMA, T3, VWMA, ALMA, McGinley Dynamic, FRAMA, VIDYA, JMA, Alligator, EVWMA, SWMA, GMA, EHMA, Median MA, Adaptive Laguerre, GD, Holt-Winters | | Momentum Oscillators | RSI (Wilder), Anchored RSI, Stochastic, CCI, ROC, Williams %R, MFI, Awesome Oscillator, MOM, CMO, TSI, PMO, StochRSI, Ultimate Oscillator, RVI, PGO, KST, SMI, Laguerre RSI, Connors RSI, Inertia, ROC Percentage (ROCP), ROC Ratio (ROCR), ROC Ratio 100 (ROCR100) | | Trend & Directional | MACD, MACD Fixed (MACDFIX), MACD Extended (MACDEXT), ADX (+DI/-DI), ADXR, Aroon, TRIX, Aroon Oscillator, Vortex, Random Walk Index, Trend Intensity Index, Wave Trend Oscillator, Mass Index, Choppiness Index, Vertical Horizontal Filter, Plus DM, Minus DM, Plus DI, Minus DI, DX | | Price Oscillators | PPO, DPO, Coppock, Accelerator Oscillator, Balance of Power, APO, AO Histogram, CFO, Zero-Lag MACD, Elder Impulse, STC | @@ -245,7 +245,7 @@ A Python live-trading example using the public `websockets` package lives at ``` wickra/ ├── crates/ -│ ├── wickra-core/ core engine + all 396 indicators +│ ├── wickra-core/ core engine + all 403 indicators │ ├── wickra/ top-level facade crate (publishes on crates.io) + benches/ │ └── wickra-data/ CSV reader, tick aggregator, live exchange feeds ├── bindings/ diff --git a/bindings/node/__tests__/indicators.test.js b/bindings/node/__tests__/indicators.test.js index cbc564f8..bd1b7c36 100644 --- a/bindings/node/__tests__/indicators.test.js +++ b/bindings/node/__tests__/indicators.test.js @@ -28,6 +28,13 @@ function num(v) { // --- Scalar indicators: update(value) vs batch(prices) --- const scalarFactories = { + HoltWinters: () => new wickra.HoltWinters(0.2, 0.1), + GD: () => new wickra.GD(5, 0.7), + AdaptiveLaguerre: () => new wickra.AdaptiveLaguerre(13), + MedianMA: () => new wickra.MedianMA(14), + EHMA: () => new wickra.EHMA(9), + GMA: () => new wickra.GMA(14), + SWMA: () => new wickra.SWMA(14), Expectancy: () => new wickra.Expectancy(20), WinRate: () => new wickra.WinRate(20), RegimeLabel: () => new wickra.RegimeLabel(5, 20), diff --git a/bindings/node/index.d.ts b/bindings/node/index.d.ts index 81bed00e..90632787 100644 --- a/bindings/node/index.d.ts +++ b/bindings/node/index.d.ts @@ -872,6 +872,51 @@ export declare class Expectancy { isReady(): boolean warmupPeriod(): number } +export type SineWeightedMaNode = SWMA +export declare class SWMA { + constructor(period: number) + update(value: number): number | null + batch(prices: Array): Array + reset(): void + isReady(): boolean + warmupPeriod(): number +} +export type GeometricMaNode = GMA +export declare class GMA { + constructor(period: number) + update(value: number): number | null + batch(prices: Array): Array + reset(): void + isReady(): boolean + warmupPeriod(): number +} +export type EhmaNode = EHMA +export declare class EHMA { + constructor(period: number) + update(value: number): number | null + batch(prices: Array): Array + reset(): void + isReady(): boolean + warmupPeriod(): number +} +export type MedianMaNode = MedianMA +export declare class MedianMA { + constructor(period: number) + update(value: number): number | null + batch(prices: Array): Array + reset(): void + isReady(): boolean + warmupPeriod(): number +} +export type AdaptiveLaguerreFilterNode = AdaptiveLaguerre +export declare class AdaptiveLaguerre { + constructor(period: number) + update(value: number): number | null + batch(prices: Array): Array + reset(): void + isReady(): boolean + warmupPeriod(): number +} export type JumpIndicatorNode = JumpIndicator export declare class JumpIndicator { constructor(period: number, threshold: number) @@ -1677,6 +1722,24 @@ export declare class T3 { isReady(): boolean warmupPeriod(): number } +export type GeneralizedDemaNode = GD +export declare class GD { + constructor(period: number, v: number) + update(value: number): number | null + batch(prices: Array): Array + reset(): void + isReady(): boolean + warmupPeriod(): number +} +export type HoltWintersNode = HoltWinters +export declare class HoltWinters { + constructor(alpha: number, beta: number) + update(value: number): number | null + batch(prices: Array): Array + reset(): void + isReady(): boolean + warmupPeriod(): number +} export type TsiNode = TSI export declare class TSI { constructor(long: number, short: number) diff --git a/bindings/node/index.js b/bindings/node/index.js index e359a2c8..3c0ccf17 100644 --- a/bindings/node/index.js +++ b/bindings/node/index.js @@ -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, McGinleyDynamic, FRAMA, SuperSmoother, FisherTransform, Decycler, CenterOfGravity, CyberneticCycle, InstantaneousTrendline, EhlersStochastic, RVIVolatility, Variance, CoefficientOfVariation, Skewness, Kurtosis, StandardError, DetrendedStdDev, RSquared, MedianAbsoluteDeviation, MIDPOINT, ROCP, ROCR, ROCR100, LINEARREG_INTERCEPT, TSF, LogReturn, RealizedVolatility, RollingIqr, RollingPercentileRank, TrendLabel, WinRate, Expectancy, JumpIndicator, RegimeLabel, RollingQuantile, Autocorrelation, HurstExponent, PearsonCorrelation, Beta, PairwiseBeta, SpreadAr1Coefficient, SpearmanCorrelation, RollingCorrelation, RollingCovariance, OuHalfLife, SpreadHurst, DistanceSsd, BetaNeutralSpread, PairSpreadZScore, LeadLagCrossCorrelation, Cointegration, RelativeStrengthAB, VarianceRatio, GrangerCausality, KalmanHedgeRatio, SpreadBollingerBands, MACD, MACDFIX, MACDEXT, BollingerBands, ATR, PLUS_DM, MINUS_DM, PLUS_DI, MINUS_DI, DX, MIDPRICE, AVGPRICE, SAREXT, HT_PHASOR, CloseVsOpen, BodySizePct, WickRatio, HighLowRange, Stochastic, OBV, ADX, ADXR, CCI, WilliamsR, MFI, PSAR, Keltner, Donchian, VWAP, RollingVWAP, AwesomeOscillator, Aroon, Inertia, ConnorsRSI, LaguerreRSI, SMI, KST, PGO, RVI, AwesomeOscillatorHistogram, STC, ElderImpulse, ZeroLagMACD, CFO, APO, KAMA, EVWMA, Alligator, JMA, VIDYA, ALMA, T3, TSI, PMO, TII, ADL, VolumePriceTrend, ChaikinMoneyFlow, ChaikinOscillator, ForceIndex, NVI, PVI, VolumeOscillator, KVO, WilliamsAD, AnchoredRSI, AnchoredVWAP, DemandIndex, TSV, VZO, MarketFacilitationIndex, EaseOfMovement, SuperTrend, ChandelierExit, ChandeKrollStop, AtrTrailingStop, HiLoActivator, VoltyStop, YoyoExit, DonchianStop, PercentageTrailingStop, StepTrailingStop, RenkoTrailingStop, TypicalPrice, MedianPrice, WeightedClose, LinearRegression, LinRegSlope, AcceleratorOscillator, BalanceOfPower, ChoppinessIndex, TrueRange, ChaikinVolatility, YangZhangVolatility, RogersSatchellVolatility, GarmanKlassVolatility, ParkinsonVolatility, LinRegAngle, BollingerBandwidth, PercentB, NATR, HistoricalVolatility, AroonOscillator, WaveTrend, RWI, Vortex, MassIndex, StochRSI, UltimateOscillator, PPO, Coppock, VWMA, 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, InverseFisherTransform, DecyclerOscillator, RoofingFilter, EmpiricalModeDecomposition, HT_DCPHASE, HT_TRENDMODE, HilbertDominantCycle, AdaptiveCycle, SineWave, MAMA, FAMA, Ichimoku, HeikinAshi, ValueArea, VolumeProfile, TpoProfile, InitialBalance, OpeningRange, Doji, Hammer, InvertedHammer, HangingMan, ShootingStar, Engulfing, Harami, MorningEveningStar, ThreeSoldiersOrCrows, PiercingDarkCloud, Marubozu, Tweezer, SpinningTop, ThreeInside, ThreeOutside, TwoCrows, UpsideGapTwoCrows, IdenticalThreeCrows, ThreeLineStrike, ThreeStarsInSouth, AbandonedBaby, AdvanceBlock, BeltHold, Breakaway, Counterattack, DojiStar, DragonflyDoji, GravestoneDoji, LongLeggedDoji, RickshawMan, EveningDojiStar, MorningDojiStar, GapSideBySideWhite, HighWave, Hikkake, HikkakeModified, HomingPigeon, OnNeck, InNeck, Thrusting, SeparatingLines, Kicking, KickingByLength, LadderBottom, MatHold, MatchingLow, LongLine, ShortLine, RisingThreeMethods, FallingThreeMethods, UpsideGapThreeMethods, DownsideGapThreeMethods, StalledPattern, StickSandwich, Takuri, ClosingMarubozu, OpeningMarubozu, TasukiGap, UniqueThreeRiver, ConcealingBabySwallow, DoubleTopBottom, TripleTopBottom, HeadAndShoulders, Triangle, Wedge, FlagPennant, RectangleRange, CupAndHandle, Abcd, Gartley, Butterfly, Bat, Crab, Shark, Cypher, ThreeDrives, OrderBookImbalanceTop1, OrderBookImbalanceFull, Microprice, QuotedSpread, DepthSlope, OrderBookImbalanceTopN, SignedVolume, CumulativeVolumeDelta, TradeImbalance, OrderFlowImbalance, Vpin, AmihudIlliquidity, RollMeasure, EffectiveSpread, RealizedSpread, KylesLambda, Footprint, FundingRate, FundingRateMean, FundingRateZScore, FundingBasis, OpenInterestDelta, OIPriceDivergence, OIWeighted, LongShortRatio, TakerBuySellRatio, LiquidationFeatures, TermStructureBasis, CalendarSpread, AdvanceDecline, AdvanceDeclineRatio, AdVolumeLine, McClellanOscillator, McClellanSummationIndex, Trin, BreadthThrust, NewHighsNewLows, HighLowIndex, PercentAboveMa, UpDownVolumeRatio, BullishPercentIndex, CumulativeVolumeIndex, AbsoluteBreadthIndex, TickIndex, SharpeRatio, SortinoRatio, CalmarRatio, OmegaRatio, MaxDrawdown, AverageDrawdown, DrawdownDuration, PainIndex, ValueAtRisk, ConditionalValueAtRisk, ProfitFactor, GainLossRatio, RecoveryFactor, KellyCriterion, TreynorRatio, InformationRatio, RenkoBars, KagiBars, PointAndFigureBars, Alpha, SessionVwap, OvernightGap, SeasonalZScore, TimeOfDayReturnProfile, IntradayVolatilityProfile, VolumeByTimeProfile, DayOfWeekProfile, AverageDailyRange, TurnOfMonth, SessionHighLow, SessionRange, OvernightIntradayReturn, FibRetracement, FibExtension, FibProjection, AutoFib, GoldenPocket, FibConfluence, FibFan, FibArcs, FibChannel, FibTimeZones } = nativeBinding +const { version, SMA, EMA, WMA, RSI, DEMA, TEMA, HMA, ROC, TRIX, SMMA, TRIMA, ZLEMA, MOM, CMO, DPO, StdDev, UlcerIndex, VerticalHorizontalFilter, ZScore, McGinleyDynamic, FRAMA, SuperSmoother, FisherTransform, Decycler, CenterOfGravity, CyberneticCycle, InstantaneousTrendline, EhlersStochastic, RVIVolatility, Variance, CoefficientOfVariation, Skewness, Kurtosis, StandardError, DetrendedStdDev, RSquared, MedianAbsoluteDeviation, MIDPOINT, ROCP, ROCR, ROCR100, LINEARREG_INTERCEPT, TSF, LogReturn, RealizedVolatility, RollingIqr, RollingPercentileRank, TrendLabel, WinRate, Expectancy, SWMA, GMA, EHMA, MedianMA, AdaptiveLaguerre, JumpIndicator, RegimeLabel, RollingQuantile, Autocorrelation, HurstExponent, PearsonCorrelation, Beta, PairwiseBeta, SpreadAr1Coefficient, SpearmanCorrelation, RollingCorrelation, RollingCovariance, OuHalfLife, SpreadHurst, DistanceSsd, BetaNeutralSpread, PairSpreadZScore, LeadLagCrossCorrelation, Cointegration, RelativeStrengthAB, VarianceRatio, GrangerCausality, KalmanHedgeRatio, SpreadBollingerBands, MACD, MACDFIX, MACDEXT, BollingerBands, ATR, PLUS_DM, MINUS_DM, PLUS_DI, MINUS_DI, DX, MIDPRICE, AVGPRICE, SAREXT, HT_PHASOR, CloseVsOpen, BodySizePct, WickRatio, HighLowRange, Stochastic, OBV, ADX, ADXR, CCI, WilliamsR, MFI, PSAR, Keltner, Donchian, VWAP, RollingVWAP, AwesomeOscillator, Aroon, Inertia, ConnorsRSI, LaguerreRSI, SMI, KST, PGO, RVI, AwesomeOscillatorHistogram, STC, ElderImpulse, ZeroLagMACD, CFO, APO, KAMA, EVWMA, Alligator, JMA, VIDYA, ALMA, T3, GD, HoltWinters, TSI, PMO, TII, ADL, VolumePriceTrend, ChaikinMoneyFlow, ChaikinOscillator, ForceIndex, NVI, PVI, VolumeOscillator, KVO, WilliamsAD, AnchoredRSI, AnchoredVWAP, DemandIndex, TSV, VZO, MarketFacilitationIndex, EaseOfMovement, SuperTrend, ChandelierExit, ChandeKrollStop, AtrTrailingStop, HiLoActivator, VoltyStop, YoyoExit, DonchianStop, PercentageTrailingStop, StepTrailingStop, RenkoTrailingStop, TypicalPrice, MedianPrice, WeightedClose, LinearRegression, LinRegSlope, AcceleratorOscillator, BalanceOfPower, ChoppinessIndex, TrueRange, ChaikinVolatility, YangZhangVolatility, RogersSatchellVolatility, GarmanKlassVolatility, ParkinsonVolatility, LinRegAngle, BollingerBandwidth, PercentB, NATR, HistoricalVolatility, AroonOscillator, WaveTrend, RWI, Vortex, MassIndex, StochRSI, UltimateOscillator, PPO, Coppock, VWMA, 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, InverseFisherTransform, DecyclerOscillator, RoofingFilter, EmpiricalModeDecomposition, HT_DCPHASE, HT_TRENDMODE, HilbertDominantCycle, AdaptiveCycle, SineWave, MAMA, FAMA, Ichimoku, HeikinAshi, ValueArea, VolumeProfile, TpoProfile, InitialBalance, OpeningRange, Doji, Hammer, InvertedHammer, HangingMan, ShootingStar, Engulfing, Harami, MorningEveningStar, ThreeSoldiersOrCrows, PiercingDarkCloud, Marubozu, Tweezer, SpinningTop, ThreeInside, ThreeOutside, TwoCrows, UpsideGapTwoCrows, IdenticalThreeCrows, ThreeLineStrike, ThreeStarsInSouth, AbandonedBaby, AdvanceBlock, BeltHold, Breakaway, Counterattack, DojiStar, DragonflyDoji, GravestoneDoji, LongLeggedDoji, RickshawMan, EveningDojiStar, MorningDojiStar, GapSideBySideWhite, HighWave, Hikkake, HikkakeModified, HomingPigeon, OnNeck, InNeck, Thrusting, SeparatingLines, Kicking, KickingByLength, LadderBottom, MatHold, MatchingLow, LongLine, ShortLine, RisingThreeMethods, FallingThreeMethods, UpsideGapThreeMethods, DownsideGapThreeMethods, StalledPattern, StickSandwich, Takuri, ClosingMarubozu, OpeningMarubozu, TasukiGap, UniqueThreeRiver, ConcealingBabySwallow, DoubleTopBottom, TripleTopBottom, HeadAndShoulders, Triangle, Wedge, FlagPennant, RectangleRange, CupAndHandle, Abcd, Gartley, Butterfly, Bat, Crab, Shark, Cypher, ThreeDrives, OrderBookImbalanceTop1, OrderBookImbalanceFull, Microprice, QuotedSpread, DepthSlope, OrderBookImbalanceTopN, SignedVolume, CumulativeVolumeDelta, TradeImbalance, OrderFlowImbalance, Vpin, AmihudIlliquidity, RollMeasure, EffectiveSpread, RealizedSpread, KylesLambda, Footprint, FundingRate, FundingRateMean, FundingRateZScore, FundingBasis, OpenInterestDelta, OIPriceDivergence, OIWeighted, LongShortRatio, TakerBuySellRatio, LiquidationFeatures, TermStructureBasis, CalendarSpread, AdvanceDecline, AdvanceDeclineRatio, AdVolumeLine, McClellanOscillator, McClellanSummationIndex, Trin, BreadthThrust, NewHighsNewLows, HighLowIndex, PercentAboveMa, UpDownVolumeRatio, BullishPercentIndex, CumulativeVolumeIndex, AbsoluteBreadthIndex, TickIndex, SharpeRatio, SortinoRatio, CalmarRatio, OmegaRatio, MaxDrawdown, AverageDrawdown, DrawdownDuration, PainIndex, ValueAtRisk, ConditionalValueAtRisk, ProfitFactor, GainLossRatio, RecoveryFactor, KellyCriterion, TreynorRatio, InformationRatio, RenkoBars, KagiBars, PointAndFigureBars, Alpha, SessionVwap, OvernightGap, SeasonalZScore, TimeOfDayReturnProfile, IntradayVolatilityProfile, VolumeByTimeProfile, DayOfWeekProfile, AverageDailyRange, TurnOfMonth, SessionHighLow, SessionRange, OvernightIntradayReturn, FibRetracement, FibExtension, FibProjection, AutoFib, GoldenPocket, FibConfluence, FibFan, FibArcs, FibChannel, FibTimeZones } = nativeBinding module.exports.version = version module.exports.SMA = SMA @@ -363,6 +363,11 @@ module.exports.RollingPercentileRank = RollingPercentileRank module.exports.TrendLabel = TrendLabel module.exports.WinRate = WinRate module.exports.Expectancy = Expectancy +module.exports.SWMA = SWMA +module.exports.GMA = GMA +module.exports.EHMA = EHMA +module.exports.MedianMA = MedianMA +module.exports.AdaptiveLaguerre = AdaptiveLaguerre module.exports.JumpIndicator = JumpIndicator module.exports.RegimeLabel = RegimeLabel module.exports.RollingQuantile = RollingQuantile @@ -439,6 +444,8 @@ module.exports.JMA = JMA module.exports.VIDYA = VIDYA module.exports.ALMA = ALMA module.exports.T3 = T3 +module.exports.GD = GD +module.exports.HoltWinters = HoltWinters module.exports.TSI = TSI module.exports.PMO = PMO module.exports.TII = TII diff --git a/bindings/node/src/lib.rs b/bindings/node/src/lib.rs index f7874f37..4c63a4a1 100644 --- a/bindings/node/src/lib.rs +++ b/bindings/node/src/lib.rs @@ -197,6 +197,15 @@ node_scalar_indicator!( node_scalar_indicator!(TrendLabelNode, "TrendLabel", wc::TrendLabel); node_scalar_indicator!(WinRateNode, "WinRate", wc::WinRate); node_scalar_indicator!(ExpectancyNode, "Expectancy", wc::Expectancy); +node_scalar_indicator!(SineWeightedMaNode, "SWMA", wc::SineWeightedMa); +node_scalar_indicator!(GeometricMaNode, "GMA", wc::GeometricMa); +node_scalar_indicator!(EhmaNode, "EHMA", wc::Ehma); +node_scalar_indicator!(MedianMaNode, "MedianMA", wc::MedianMa); +node_scalar_indicator!( + AdaptiveLaguerreFilterNode, + "AdaptiveLaguerre", + wc::AdaptiveLaguerreFilter +); #[napi(js_name = "JumpIndicator")] pub struct JumpIndicatorNode { inner: wc::JumpIndicator, @@ -3794,6 +3803,80 @@ impl T3Node { } } +// ============================== GD ============================== + +#[napi(js_name = "GD")] +pub struct GeneralizedDemaNode { + inner: wc::GeneralizedDema, +} + +#[napi] +impl GeneralizedDemaNode { + #[napi(constructor)] + pub fn new(period: u32, v: f64) -> napi::Result { + Ok(Self { + inner: wc::GeneralizedDema::new(period as usize, v).map_err(map_err)?, + }) + } + #[napi] + pub fn update(&mut self, value: f64) -> Option { + self.inner.update(value) + } + #[napi] + pub fn batch(&mut self, prices: Vec) -> Vec { + 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 + } +} + +// ============================== HoltWinters ============================== + +#[napi(js_name = "HoltWinters")] +pub struct HoltWintersNode { + inner: wc::HoltWinters, +} + +#[napi] +impl HoltWintersNode { + #[napi(constructor)] + pub fn new(alpha: f64, beta: f64) -> napi::Result { + Ok(Self { + inner: wc::HoltWinters::new(alpha, beta).map_err(map_err)?, + }) + } + #[napi] + pub fn update(&mut self, value: f64) -> Option { + self.inner.update(value) + } + #[napi] + pub fn batch(&mut self, prices: Vec) -> Vec { + 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 + } +} + // ============================== TSI ============================== #[napi(js_name = "TSI")] diff --git a/bindings/python/python/wickra/__init__.py b/bindings/python/python/wickra/__init__.py index 5f678fa3..cc14f2a8 100644 --- a/bindings/python/python/wickra/__init__.py +++ b/bindings/python/python/wickra/__init__.py @@ -25,6 +25,13 @@ from __future__ import annotations from ._wickra import ( __version__, + HoltWinters, + GD, + AdaptiveLaguerre, + MedianMA, + EHMA, + GMA, + SWMA, Expectancy, WinRate, RegimeLabel, @@ -449,6 +456,13 @@ from ._wickra import ( ) __all__ = [ + "HoltWinters", + "GD", + "AdaptiveLaguerre", + "MedianMA", + "EHMA", + "GMA", + "SWMA", "Expectancy", "WinRate", "RegimeLabel", diff --git a/bindings/python/src/lib.rs b/bindings/python/src/lib.rs index 72559b7f..0b360a4d 100644 --- a/bindings/python/src/lib.rs +++ b/bindings/python/src/lib.rs @@ -2354,6 +2354,250 @@ impl PyExpectancy { } } +// ============================== SineWeightedMa ============================== + +#[pyclass(name = "SWMA", module = "wickra._wickra", skip_from_py_object)] +#[derive(Clone)] +struct PySineWeightedMa { + inner: wc::SineWeightedMa, +} + +#[pymethods] +impl PySineWeightedMa { + #[new] + #[pyo3(signature = (period=14))] + fn new(period: usize) -> PyResult { + Ok(Self { + inner: wc::SineWeightedMa::new(period).map_err(map_err)?, + }) + } + fn update(&mut self, value: f64) -> Option { + self.inner.update(value) + } + fn batch<'py>( + &mut self, + py: Python<'py>, + prices: PyReadonlyArray1<'py, f64>, + ) -> PyResult>> { + let s = prices + .as_slice() + .map_err(|_| PyValueError::new_err(NON_CONTIGUOUS))?; + Ok(flatten(self.inner.batch(s)).into_pyarray(py)) + } + #[getter] + fn period(&self) -> usize { + self.inner.period() + } + fn reset(&mut self) { + self.inner.reset(); + } + fn is_ready(&self) -> bool { + self.inner.is_ready() + } + fn warmup_period(&self) -> usize { + self.inner.warmup_period() + } + fn __repr__(&self) -> String { + format!("SWMA(period={})", self.inner.period()) + } +} + +// ============================== GeometricMa ============================== + +#[pyclass(name = "GMA", module = "wickra._wickra", skip_from_py_object)] +#[derive(Clone)] +struct PyGeometricMa { + inner: wc::GeometricMa, +} + +#[pymethods] +impl PyGeometricMa { + #[new] + #[pyo3(signature = (period=14))] + fn new(period: usize) -> PyResult { + Ok(Self { + inner: wc::GeometricMa::new(period).map_err(map_err)?, + }) + } + fn update(&mut self, value: f64) -> Option { + self.inner.update(value) + } + fn batch<'py>( + &mut self, + py: Python<'py>, + prices: PyReadonlyArray1<'py, f64>, + ) -> PyResult>> { + let s = prices + .as_slice() + .map_err(|_| PyValueError::new_err(NON_CONTIGUOUS))?; + Ok(flatten(self.inner.batch(s)).into_pyarray(py)) + } + #[getter] + fn period(&self) -> usize { + self.inner.period() + } + fn reset(&mut self) { + self.inner.reset(); + } + fn is_ready(&self) -> bool { + self.inner.is_ready() + } + fn warmup_period(&self) -> usize { + self.inner.warmup_period() + } + fn __repr__(&self) -> String { + format!("GMA(period={})", self.inner.period()) + } +} + +// ============================== Ehma ============================== + +#[pyclass(name = "EHMA", module = "wickra._wickra", skip_from_py_object)] +#[derive(Clone)] +struct PyEhma { + inner: wc::Ehma, +} + +#[pymethods] +impl PyEhma { + #[new] + #[pyo3(signature = (period=9))] + fn new(period: usize) -> PyResult { + Ok(Self { + inner: wc::Ehma::new(period).map_err(map_err)?, + }) + } + fn update(&mut self, value: f64) -> Option { + self.inner.update(value) + } + fn batch<'py>( + &mut self, + py: Python<'py>, + prices: PyReadonlyArray1<'py, f64>, + ) -> PyResult>> { + let s = prices + .as_slice() + .map_err(|_| PyValueError::new_err(NON_CONTIGUOUS))?; + Ok(flatten(self.inner.batch(s)).into_pyarray(py)) + } + #[getter] + fn period(&self) -> usize { + self.inner.period() + } + fn reset(&mut self) { + self.inner.reset(); + } + fn is_ready(&self) -> bool { + self.inner.is_ready() + } + fn warmup_period(&self) -> usize { + self.inner.warmup_period() + } + fn __repr__(&self) -> String { + format!("EHMA(period={})", self.inner.period()) + } +} + +// ============================== MedianMa ============================== + +#[pyclass(name = "MedianMA", module = "wickra._wickra", skip_from_py_object)] +#[derive(Clone)] +struct PyMedianMa { + inner: wc::MedianMa, +} + +#[pymethods] +impl PyMedianMa { + #[new] + #[pyo3(signature = (period=14))] + fn new(period: usize) -> PyResult { + Ok(Self { + inner: wc::MedianMa::new(period).map_err(map_err)?, + }) + } + fn update(&mut self, value: f64) -> Option { + self.inner.update(value) + } + fn batch<'py>( + &mut self, + py: Python<'py>, + prices: PyReadonlyArray1<'py, f64>, + ) -> PyResult>> { + let s = prices + .as_slice() + .map_err(|_| PyValueError::new_err(NON_CONTIGUOUS))?; + Ok(flatten(self.inner.batch(s)).into_pyarray(py)) + } + #[getter] + fn period(&self) -> usize { + self.inner.period() + } + fn reset(&mut self) { + self.inner.reset(); + } + fn is_ready(&self) -> bool { + self.inner.is_ready() + } + fn warmup_period(&self) -> usize { + self.inner.warmup_period() + } + fn __repr__(&self) -> String { + format!("MedianMA(period={})", self.inner.period()) + } +} + +// ============================== AdaptiveLaguerreFilter ============================== + +#[pyclass( + name = "AdaptiveLaguerre", + module = "wickra._wickra", + skip_from_py_object +)] +#[derive(Clone)] +struct PyAdaptiveLaguerreFilter { + inner: wc::AdaptiveLaguerreFilter, +} + +#[pymethods] +impl PyAdaptiveLaguerreFilter { + #[new] + #[pyo3(signature = (period=13))] + fn new(period: usize) -> PyResult { + Ok(Self { + inner: wc::AdaptiveLaguerreFilter::new(period).map_err(map_err)?, + }) + } + fn update(&mut self, value: f64) -> Option { + self.inner.update(value) + } + fn batch<'py>( + &mut self, + py: Python<'py>, + prices: PyReadonlyArray1<'py, f64>, + ) -> PyResult>> { + let s = prices + .as_slice() + .map_err(|_| PyValueError::new_err(NON_CONTIGUOUS))?; + Ok(flatten(self.inner.batch(s)).into_pyarray(py)) + } + #[getter] + fn period(&self) -> usize { + self.inner.period() + } + fn reset(&mut self) { + self.inner.reset(); + } + fn is_ready(&self) -> bool { + self.inner.is_ready() + } + fn warmup_period(&self) -> usize { + self.inner.warmup_period() + } + fn __repr__(&self) -> String { + format!("AdaptiveLaguerre(period={})", self.inner.period()) + } +} + // ============================== Stochastic ============================== #[pyclass(name = "Stochastic", module = "wickra._wickra", skip_from_py_object)] @@ -6197,6 +6441,130 @@ impl PyT3 { } } +// ============================== GD ============================== + +#[pyclass(name = "GD", module = "wickra._wickra", skip_from_py_object)] +#[derive(Clone)] +struct PyGeneralizedDema { + inner: wc::GeneralizedDema, +} + +#[pymethods] +impl PyGeneralizedDema { + #[new] + #[pyo3(signature = (period, v=0.7))] + fn new(period: usize, v: f64) -> PyResult { + Ok(Self { + inner: wc::GeneralizedDema::new(period, v).map_err(map_err)?, + }) + } + fn update(&mut self, value: f64) -> Option { + self.inner.update(value) + } + fn batch<'py>( + &mut self, + py: Python<'py>, + prices: PyReadonlyArray1<'py, f64>, + ) -> PyResult>> { + 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 volume_factor(&self) -> f64 { + self.inner.volume_factor() + } + 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!( + "GD(period={}, v={})", + self.inner.period(), + self.inner.volume_factor() + ) + } +} + +// ============================== HoltWinters ============================== + +#[pyclass(name = "HoltWinters", module = "wickra._wickra", skip_from_py_object)] +#[derive(Clone)] +struct PyHoltWinters { + inner: wc::HoltWinters, +} + +#[pymethods] +impl PyHoltWinters { + #[new] + #[pyo3(signature = (alpha=0.2, beta=0.1))] + fn new(alpha: f64, beta: f64) -> PyResult { + Ok(Self { + inner: wc::HoltWinters::new(alpha, beta).map_err(map_err)?, + }) + } + fn update(&mut self, value: f64) -> Option { + self.inner.update(value) + } + fn batch<'py>( + &mut self, + py: Python<'py>, + prices: PyReadonlyArray1<'py, f64>, + ) -> PyResult>> { + let slice = prices + .as_slice() + .map_err(|_| PyValueError::new_err(NON_CONTIGUOUS))?; + Ok(flatten(self.inner.batch(slice)).into_pyarray(py)) + } + #[getter] + fn alpha(&self) -> f64 { + self.inner.alpha() + } + #[getter] + fn beta(&self) -> f64 { + self.inner.beta() + } + #[getter] + fn level(&self) -> Option { + self.inner.level() + } + #[getter] + fn trend(&self) -> Option { + self.inner.trend() + } + #[getter] + fn value(&self) -> Option { + 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!( + "HoltWinters(alpha={}, beta={})", + self.inner.alpha(), + self.inner.beta() + ) + } +} + // ============================== VWMA ============================== #[pyclass(name = "VWMA", module = "wickra._wickra", skip_from_py_object)] @@ -19667,6 +20035,8 @@ fn _wickra(_py: Python<'_>, m: &Bound<'_, PyModule>) -> PyResult<()> { m.add_class::()?; m.add_class::()?; m.add_class::()?; + m.add_class::()?; + m.add_class::()?; m.add_class::()?; m.add_class::()?; m.add_class::()?; @@ -20031,5 +20401,10 @@ fn _wickra(_py: Python<'_>, m: &Bound<'_, PyModule>) -> PyResult<()> { m.add_class::()?; m.add_class::()?; m.add_class::()?; + m.add_class::()?; + m.add_class::()?; + m.add_class::()?; + m.add_class::()?; + m.add_class::()?; Ok(()) } diff --git a/bindings/python/tests/test_new_indicators.py b/bindings/python/tests/test_new_indicators.py index 3ec35522..9a5d43e1 100644 --- a/bindings/python/tests/test_new_indicators.py +++ b/bindings/python/tests/test_new_indicators.py @@ -45,6 +45,13 @@ def ohlcv() -> tuple[np.ndarray, np.ndarray, np.ndarray, np.ndarray]: # --- Scalar (f64 -> f64) indicators --------------------------------------- SCALAR = [ + (ta.HoltWinters, (0.2, 0.1)), + (ta.GD, (5, 0.7)), + (ta.AdaptiveLaguerre, (13,)), + (ta.MedianMA, (14,)), + (ta.EHMA, (9,)), + (ta.GMA, (14,)), + (ta.SWMA, (14,)), (ta.Expectancy, (20,)), (ta.WinRate, (20,)), (ta.RegimeLabel, (5, 20)), diff --git a/bindings/wasm/src/lib.rs b/bindings/wasm/src/lib.rs index 5e362ae4..91b77bdc 100644 --- a/bindings/wasm/src/lib.rs +++ b/bindings/wasm/src/lib.rs @@ -10213,6 +10213,13 @@ wasm_scalar_indicator!(WasmJumpIndicator, "JumpIndicator", wc::JumpIndicator, pe wasm_scalar_indicator!(WasmRegimeLabel, "RegimeLabel", wc::RegimeLabel, vol_period: usize, lookback: usize); wasm_scalar_indicator!(WasmWinRate, "WinRate", wc::WinRate, period: usize); wasm_scalar_indicator!(WasmExpectancy, "Expectancy", wc::Expectancy, period: usize); +wasm_scalar_indicator!(WasmSineWeightedMa, "SWMA", wc::SineWeightedMa, period: usize); +wasm_scalar_indicator!(WasmGeometricMa, "GMA", wc::GeometricMa, period: usize); +wasm_scalar_indicator!(WasmEhma, "EHMA", wc::Ehma, period: usize); +wasm_scalar_indicator!(WasmMedianMa, "MedianMA", wc::MedianMa, period: usize); +wasm_scalar_indicator!(WasmAdaptiveLaguerreFilter, "AdaptiveLaguerre", wc::AdaptiveLaguerreFilter, period: usize); +wasm_scalar_indicator!(WasmGeneralizedDema, "GD", wc::GeneralizedDema, period: usize, v: f64); +wasm_scalar_indicator!(WasmHoltWinters, "HoltWinters", wc::HoltWinters, alpha: f64, beta: f64); // --- DrawdownDuration: u32 output, no constructor args --- diff --git a/crates/wickra-core/src/indicators/adaptive_laguerre_filter.rs b/crates/wickra-core/src/indicators/adaptive_laguerre_filter.rs new file mode 100644 index 00000000..368c531b --- /dev/null +++ b/crates/wickra-core/src/indicators/adaptive_laguerre_filter.rs @@ -0,0 +1,344 @@ +//! Ehlers' Adaptive Laguerre Filter. + +use std::collections::VecDeque; + +use crate::error::{Error, Result}; +use crate::traits::Indicator; + +/// John Ehlers' Adaptive Laguerre Filter — a four-stage Laguerre polynomial +/// smoother whose damping factor `gamma` is recomputed every bar from how well +/// the filter is currently tracking price. +/// +/// The Laguerre cascade is the same one used by [`LaguerreRsi`](crate::LaguerreRsi), +/// but instead of a fixed `gamma` the filter adapts: it measures the recent +/// absolute error `|price − filter|`, normalises those errors across a window of +/// `period` bars to `[0, 1]`, and takes their **median** as `gamma`. When price +/// is tracking smoothly the errors are small and uniform (low `gamma`, fast +/// response); when price jumps, the spread of errors widens and `gamma` rises, +/// slowing the filter to reject the noise. +/// +/// ```text +/// diff_t = |price_t − filter_{t-1}| +/// over the last `period` diffs: +/// HH = max(diff), LL = min(diff) +/// norm_i = (diff_i − LL) / (HH − LL) (0 if HH == LL) +/// gamma = median(norm) +/// alpha = 1 − gamma +/// L0_t = alpha·price_t + gamma·L0_{t-1} +/// L1_t = −gamma·L0_t + L0_{t-1} + gamma·L1_{t-1} +/// L2_t = −gamma·L1_t + L1_{t-1} + gamma·L2_{t-1} +/// L3_t = −gamma·L2_t + L2_{t-1} + gamma·L3_{t-1} +/// filter_t = (L0_t + 2·L1_t + 2·L2_t + L3_t) / 6 +/// ``` +/// +/// The output is a smoothed price on the same scale as the input. The first +/// emission lands once the error window holds `period` values. +/// +/// Reference: John F. Ehlers, *"Adaptive Laguerre Filter"*, Technical Analysis +/// of Stocks & Commodities, 2007. +/// +/// # Example +/// +/// ``` +/// use wickra_core::{Indicator, AdaptiveLaguerreFilter}; +/// +/// let mut indicator = AdaptiveLaguerreFilter::new(13).unwrap(); +/// let mut last = None; +/// for i in 0..80 { +/// last = indicator.update(100.0 + f64::from(i)); +/// } +/// assert!(last.is_some()); +/// ``` +#[derive(Debug, Clone)] +pub struct AdaptiveLaguerreFilter { + period: usize, + l0: f64, + l1: f64, + l2: f64, + l3: f64, + /// Previous filter output, or `None` before the first bar. + filter: Option, + /// The last `period` absolute errors `|price − filter|`. + diffs: VecDeque, +} + +impl AdaptiveLaguerreFilter { + /// Construct a new adaptive Laguerre filter with the given error-window + /// length. + /// + /// # Errors + /// + /// Returns [`Error::PeriodZero`] if `period == 0`. + pub fn new(period: usize) -> Result { + if period == 0 { + return Err(Error::PeriodZero); + } + Ok(Self { + period, + l0: 0.0, + l1: 0.0, + l2: 0.0, + l3: 0.0, + filter: None, + diffs: VecDeque::with_capacity(period), + }) + } + + /// Configured error-window length. + pub const fn period(&self) -> usize { + self.period + } + + /// Current value if the error window is full. + pub fn value(&self) -> Option { + if self.diffs.len() == self.period { + self.filter + } else { + None + } + } + + /// Median of the normalised errors currently in the window. Returns `0.0` + /// when every error is equal (e.g. during a constant warmup), which makes + /// the filter maximally fast. + fn adaptive_gamma(&self) -> f64 { + let mut hh = f64::MIN; + let mut ll = f64::MAX; + for &d in &self.diffs { + if d > hh { + hh = d; + } + if d < ll { + ll = d; + } + } + let range = hh - ll; + if range <= 0.0 { + return 0.0; + } + let mut norm: Vec = self.diffs.iter().map(|&d| (d - ll) / range).collect(); + // `total_cmp` never panics — under pathological (e.g. overflowing) fuzz + // inputs a normalised error can be non-finite; a total order keeps the + // sort sound where `partial_cmp` would return `None`. + norm.sort_by(f64::total_cmp); + let mid = norm.len() / 2; + if norm.len() % 2 == 1 { + norm[mid] + } else { + f64::midpoint(norm[mid - 1], norm[mid]) + } + } +} + +impl Indicator for AdaptiveLaguerreFilter { + type Input = f64; + type Output = f64; + + fn update(&mut self, price: f64) -> Option { + if !price.is_finite() { + return self.value(); + } + // Absolute tracking error against the previous filter (0 on the first + // bar, where there is no prior filter value). + let diff = self.filter.map_or(0.0, |f| (price - f).abs()); + if self.diffs.len() == self.period { + self.diffs.pop_front(); + } + self.diffs.push_back(diff); + + let gamma = self.adaptive_gamma(); + let alpha = 1.0 - gamma; + + let l0 = alpha * price + gamma * self.l0; + let l1 = -gamma * l0 + self.l0 + gamma * self.l1; + let l2 = -gamma * l1 + self.l1 + gamma * self.l2; + let l3 = -gamma * l2 + self.l2 + gamma * self.l3; + self.l0 = l0; + self.l1 = l1; + self.l2 = l2; + self.l3 = l3; + + let filter = (l0 + 2.0 * l1 + 2.0 * l2 + l3) / 6.0; + self.filter = Some(filter); + self.value() + } + + fn reset(&mut self) { + self.l0 = 0.0; + self.l1 = 0.0; + self.l2 = 0.0; + self.l3 = 0.0; + self.filter = None; + self.diffs.clear(); + } + + fn warmup_period(&self) -> usize { + self.period + } + + fn is_ready(&self) -> bool { + self.diffs.len() == self.period + } + + fn name(&self) -> &'static str { + "AdaptiveLaguerre" + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::traits::BatchExt; + use approx::assert_relative_eq; + + /// Independent reference: replays the exact recurrence from scratch. + fn naive(prices: &[f64], period: usize) -> Vec> { + let (mut l0, mut l1, mut l2, mut l3) = (0.0_f64, 0.0_f64, 0.0_f64, 0.0_f64); + let mut filter: Option = None; + let mut diffs: Vec = Vec::new(); + let mut out = Vec::with_capacity(prices.len()); + for &price in prices { + let diff = filter.map_or(0.0, |f: f64| (price - f).abs()); + diffs.push(diff); + if diffs.len() > period { + diffs.remove(0); + } + let hh = diffs.iter().copied().fold(f64::MIN, f64::max); + let ll = diffs.iter().copied().fold(f64::MAX, f64::min); + let range = hh - ll; + let gamma = if range <= 0.0 { + 0.0 + } else { + let mut norm: Vec = diffs.iter().map(|&d| (d - ll) / range).collect(); + norm.sort_by(|a, b| a.partial_cmp(b).unwrap()); + let mid = norm.len() / 2; + if norm.len() % 2 == 1 { + norm[mid] + } else { + f64::midpoint(norm[mid - 1], norm[mid]) + } + }; + let alpha = 1.0 - gamma; + let n0 = alpha * price + gamma * l0; + let n1 = -gamma * n0 + l0 + gamma * l1; + let n2 = -gamma * n1 + l1 + gamma * l2; + let n3 = -gamma * n2 + l2 + gamma * l3; + l0 = n0; + l1 = n1; + l2 = n2; + l3 = n3; + let f = (n0 + 2.0 * n1 + 2.0 * n2 + n3) / 6.0; + filter = Some(f); + out.push(if diffs.len() == period { Some(f) } else { None }); + } + out + } + + #[test] + fn new_rejects_zero_period() { + assert!(matches!( + AdaptiveLaguerreFilter::new(0), + Err(Error::PeriodZero) + )); + } + + /// Cover the const accessor `period` and the Indicator-impl `warmup_period` + /// + `name`. + #[test] + fn accessors_and_metadata() { + let alf = AdaptiveLaguerreFilter::new(13).unwrap(); + assert_eq!(alf.period(), 13); + assert_eq!(alf.warmup_period(), 13); + assert_eq!(alf.name(), "AdaptiveLaguerre"); + } + + #[test] + fn warmup_returns_none_until_window_full() { + let mut alf = AdaptiveLaguerreFilter::new(3).unwrap(); + assert_eq!(alf.update(10.0), None); + assert_eq!(alf.update(11.0), None); + assert!(alf.update(12.0).is_some()); + } + + #[test] + fn constant_series_converges_to_constant() { + // Errors are all zero -> gamma 0 -> the 4-stage delay line fills with + // the constant and the filter settles on it. + let mut alf = AdaptiveLaguerreFilter::new(5).unwrap(); + let out = alf.batch(&[42.0_f64; 40]); + let last = out.iter().rev().flatten().next().unwrap(); + assert_relative_eq!(*last, 42.0, epsilon = 1e-9); + } + + #[test] + fn converged_output_stays_within_price_range() { + // Once the Laguerre cascade has filled (it cold-starts from zero, so the + // first few post-warmup values ramp up toward price), the filter is a + // convex blend of recent prices and must stay inside the data range. + let prices: Vec = (0..120) + .map(|i| 50.0 + (f64::from(i) * 0.4).sin() * 10.0) + .collect(); + let lo = prices.iter().copied().fold(f64::MAX, f64::min); + let hi = prices.iter().copied().fold(f64::MIN, f64::max); + let period = 8; + let mut alf = AdaptiveLaguerreFilter::new(period).unwrap(); + for (i, v) in alf.batch(&prices).into_iter().enumerate() { + // Skip the cold-start transient (a few multiples of the window). + if i < 4 * period { + continue; + } + let v = v.expect("filter is ready well past warmup"); + assert!( + v >= lo - 1e-6 && v <= hi + 1e-6, + "filter out of range at {i}" + ); + } + } + + #[test] + fn matches_naive_recurrence() { + let prices: Vec = (0..80) + .map(|i| 100.0 + (f64::from(i) * 0.5).sin() * 8.0 + f64::from(i) * 0.1) + .collect(); + let mut alf = AdaptiveLaguerreFilter::new(10).unwrap(); + let got = alf.batch(&prices); + let want = naive(&prices, 10); + for (i, (g, w)) in got.iter().zip(want.iter()).enumerate() { + assert_eq!(g.is_some(), w.is_some(), "readiness mismatch at {i}"); + if let (Some(a), Some(b)) = (g, w) { + assert_relative_eq!(*a, *b, epsilon = 1e-9); + } + } + } + + #[test] + fn reset_clears_state() { + let mut alf = AdaptiveLaguerreFilter::new(5).unwrap(); + alf.batch(&(1..=40).map(f64::from).collect::>()); + assert!(alf.is_ready()); + alf.reset(); + assert!(!alf.is_ready()); + assert_eq!(alf.update(1.0), None); + } + + #[test] + fn batch_equals_streaming() { + let prices: Vec = (1..=50).map(|i| f64::from(i) * 0.7).collect(); + let mut a = AdaptiveLaguerreFilter::new(7).unwrap(); + let mut b = AdaptiveLaguerreFilter::new(7).unwrap(); + assert_eq!( + a.batch(&prices), + prices.iter().map(|p| b.update(*p)).collect::>() + ); + } + + #[test] + fn ignores_non_finite_input() { + let mut alf = AdaptiveLaguerreFilter::new(3).unwrap(); + alf.update(10.0); + alf.update(11.0); + let ready = alf.update(12.0).expect("ready after three inputs"); + assert_eq!(alf.update(f64::NAN), Some(ready)); + assert_eq!(alf.update(f64::INFINITY), Some(ready)); + } +} diff --git a/crates/wickra-core/src/indicators/ehma.rs b/crates/wickra-core/src/indicators/ehma.rs new file mode 100644 index 00000000..39899ab3 --- /dev/null +++ b/crates/wickra-core/src/indicators/ehma.rs @@ -0,0 +1,202 @@ +//! Exponential Hull Moving Average (EHMA). + +use crate::error::{Error, Result}; +use crate::indicators::ema::Ema; +use crate::traits::Indicator; + +/// Exponential Hull Moving Average: the Hull construction built from EMAs +/// instead of WMAs. +/// +/// ```text +/// EHMA = EMA( 2 · EMA(price, period/2) − EMA(price, period), round(sqrt(period)) ) +/// ``` +/// +/// Alan Hull's [`Hma`](crate::Hma) uses weighted moving averages; replacing them +/// with exponential moving averages keeps the same lag-reduction trick — a fast +/// half-length average minus a full-length one, smoothed over `sqrt(period)` — +/// while inheriting the EMA's strictly recursive O(1) update and infinite +/// (exponentially decaying) memory. The result is marginally smoother than the +/// WMA-based Hull at the cost of a little more lag. +/// +/// The half period is `(period / 2).max(1)` and the smoothing period is +/// `round(sqrt(period)).max(1)`, matching the rounding used by [`Hma`]. +/// +/// # Example +/// +/// ``` +/// use wickra_core::{Indicator, Ehma}; +/// +/// let mut indicator = Ehma::new(9).unwrap(); +/// let mut last = None; +/// for i in 0..80 { +/// last = indicator.update(100.0 + f64::from(i)); +/// } +/// assert!(last.is_some()); +/// ``` +#[derive(Debug, Clone)] +pub struct Ehma { + period: usize, + half_ema: Ema, + full_ema: Ema, + smooth_ema: Ema, +} + +impl Ehma { + /// # Errors + /// Returns [`Error::PeriodZero`] if `period == 0`. + pub fn new(period: usize) -> Result { + if period == 0 { + return Err(Error::PeriodZero); + } + let half = (period / 2).max(1); + let smooth = (period as f64).sqrt().round() as usize; + let smooth = smooth.max(1); + Ok(Self { + period, + half_ema: Ema::new(half)?, + full_ema: Ema::new(period)?, + smooth_ema: Ema::new(smooth)?, + }) + } + + /// Configured period. + pub const fn period(&self) -> usize { + self.period + } +} + +impl Indicator for Ehma { + type Input = f64; + type Output = f64; + + fn update(&mut self, input: f64) -> Option { + // Feed both component EMAs on every input so they warm up in parallel; + // gating the longer one behind the shorter would delay the first + // emission past `warmup_period()`. + let h = self.half_ema.update(input); + let f = self.full_ema.update(input); + let (h, f) = (h?, f?); + let diff = 2.0 * h - f; + self.smooth_ema.update(diff) + } + + fn reset(&mut self) { + self.half_ema.reset(); + self.full_ema.reset(); + self.smooth_ema.reset(); + } + + fn warmup_period(&self) -> usize { + // full_ema seeds at `period`, then smooth_ema needs another + // (round(sqrt(period)) - 1) values to seed. + let sm = (self.period as f64).sqrt().round() as usize; + self.period + sm.max(1) - 1 + } + + fn is_ready(&self) -> bool { + self.smooth_ema.is_ready() + } + + fn name(&self) -> &'static str { + "EHMA" + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::traits::BatchExt; + use approx::assert_relative_eq; + + #[test] + fn constant_series_yields_constant_ehma() { + let mut ehma = Ehma::new(9).unwrap(); + let out = ehma.batch(&[10.0_f64; 80]); + let last = out.iter().rev().flatten().next().unwrap(); + assert_relative_eq!(*last, 10.0, epsilon = 1e-9); + } + + #[test] + fn batch_equals_streaming() { + let prices: Vec = (1..=100).map(|i| f64::from(i) * 0.7).collect(); + let mut a = Ehma::new(9).unwrap(); + let mut b = Ehma::new(9).unwrap(); + assert_eq!( + a.batch(&prices), + prices.iter().map(|p| b.update(*p)).collect::>() + ); + } + + #[test] + fn reset_clears_state() { + let mut ehma = Ehma::new(9).unwrap(); + ehma.batch(&(1..=80).map(f64::from).collect::>()); + assert!(ehma.is_ready()); + ehma.reset(); + assert!(!ehma.is_ready()); + } + + #[test] + fn rejects_zero_period() { + assert!(Ehma::new(0).is_err()); + } + + /// Cover the const accessor `period` and the Indicator-impl `name`. + /// `warmup_period` is covered by `first_emission_matches_warmup_period`. + #[test] + fn accessors_and_metadata() { + let ehma = Ehma::new(9).unwrap(); + assert_eq!(ehma.period(), 9); + assert_eq!(ehma.name(), "EHMA"); + } + + #[test] + fn first_emission_matches_warmup_period() { + let prices: Vec = (1..=40).map(f64::from).collect(); + let mut ehma = Ehma::new(9).unwrap(); + let out = ehma.batch(&prices); + let warmup = ehma.warmup_period(); + // full EMA seeds at 9, smooth EMA round(sqrt(9))=3 needs 2 more -> 11. + assert_eq!(warmup, 11); + for (i, v) in out.iter().enumerate().take(warmup - 1) { + assert!(v.is_none(), "index {i} must be None during warmup"); + } + assert!( + out[warmup - 1].is_some(), + "first EHMA value must land at warmup_period - 1" + ); + } + + #[test] + fn matches_independent_emas() { + // The two component EMAs run as independent siblings on the price + // stream; EHMA must equal feeding three standalone EMAs and combining. + let prices: Vec = (1..=50) + .map(|i| (f64::from(i) * 0.3).sin() * 10.0 + 50.0) + .collect(); + let mut ehma = Ehma::new(9).unwrap(); + let mut half = Ema::new(4).unwrap(); // (9 / 2).max(1) + let mut full = Ema::new(9).unwrap(); + let mut smooth = Ema::new(3).unwrap(); // round(sqrt(9)) + for (i, &p) in prices.iter().enumerate() { + let got = ehma.update(p); + let want = match (half.update(p), full.update(p)) { + (Some(h), Some(f)) => smooth.update(2.0 * h - f), + _ => None, + }; + assert_eq!(got.is_some(), want.is_some(), "readiness mismatch at {i}"); + if let (Some(a), Some(b)) = (got, want) { + assert_relative_eq!(a, b, epsilon = 1e-9); + } + } + } + + #[test] + fn period_one_collapses_to_pass_through() { + // period 1: half=1, full=1, smooth=round(sqrt(1))=1; every EMA seeds on + // the first input, so EHMA(1) passes the price straight through. + let mut ehma = Ehma::new(1).unwrap(); + assert_relative_eq!(ehma.update(5.0).unwrap(), 5.0, epsilon = 1e-12); + assert_relative_eq!(ehma.update(8.0).unwrap(), 8.0, epsilon = 1e-12); + } +} diff --git a/crates/wickra-core/src/indicators/generalized_dema.rs b/crates/wickra-core/src/indicators/generalized_dema.rs new file mode 100644 index 00000000..8d6143a2 --- /dev/null +++ b/crates/wickra-core/src/indicators/generalized_dema.rs @@ -0,0 +1,222 @@ +//! Generalized DEMA (GD) — Tim Tillson's volume-factor double EMA. + +use crate::error::{Error, Result}; +use crate::indicators::ema::Ema; +use crate::traits::Indicator; + +/// Generalized DEMA — the building block of Tillson's [`T3`](crate::T3), +/// exposed on its own. +/// +/// ```text +/// GD = (1 + v) · EMA(price) − v · EMA(EMA(price)) +/// ``` +/// +/// where both EMAs share the same `period` and `v ∈ [0, 1]` is the *volume +/// factor*. `v` controls how much of the second-order lag correction is +/// applied: +/// +/// - `v = 0` collapses GD to a plain [`Ema`](crate::Ema) (no correction). +/// - `v = 1` recovers the standard [`Dema`](crate::Dema) `2·EMA − EMA(EMA)`. +/// - intermediate values (Tillson uses `0.7`) trade a little lag reduction for +/// less overshoot than DEMA. +/// +/// Because the coefficients `(1 + v)` and `−v` always sum to `1`, a constant +/// series maps to itself. The first output lands after `2·period − 1` inputs — +/// EMA1 seeds at `period`, then EMA2 needs another `period − 1` of EMA1's +/// outputs to seed, exactly like DEMA. +/// +/// # Example +/// +/// ``` +/// use wickra_core::{Indicator, GeneralizedDema}; +/// +/// let mut indicator = GeneralizedDema::new(5, 0.7).unwrap(); +/// let mut last = None; +/// for i in 0..80 { +/// last = indicator.update(100.0 + f64::from(i)); +/// } +/// assert!(last.is_some()); +/// ``` +#[derive(Debug, Clone)] +pub struct GeneralizedDema { + ema1: Ema, + ema2: Ema, + period: usize, + v: f64, +} + +impl GeneralizedDema { + /// Construct a generalized DEMA with the given `period` and volume factor + /// `v`. + /// + /// # Errors + /// + /// Returns [`Error::PeriodZero`] if `period == 0`, or + /// [`Error::InvalidPeriod`] if `v` is non-finite or outside `[0.0, 1.0]`. + pub fn new(period: usize, v: f64) -> Result { + if period == 0 { + return Err(Error::PeriodZero); + } + if !v.is_finite() || !(0.0..=1.0).contains(&v) { + return Err(Error::InvalidPeriod { + message: "GD volume factor must be a finite value in [0.0, 1.0]", + }); + } + Ok(Self { + ema1: Ema::new(period)?, + ema2: Ema::new(period)?, + period, + v, + }) + } + + /// Configured period. + pub const fn period(&self) -> usize { + self.period + } + + /// Configured volume factor `v`. + pub const fn volume_factor(&self) -> f64 { + self.v + } +} + +impl Indicator for GeneralizedDema { + type Input = f64; + type Output = f64; + + fn update(&mut self, input: f64) -> Option { + let e1 = self.ema1.update(input)?; + let e2 = self.ema2.update(e1)?; + Some((1.0 + self.v) * e1 - self.v * e2) + } + + fn reset(&mut self) { + self.ema1.reset(); + self.ema2.reset(); + } + + fn warmup_period(&self) -> usize { + // EMA1 seeds at period, then EMA2 needs another (period - 1) values. + 2 * self.period - 1 + } + + fn is_ready(&self) -> bool { + self.ema2.is_ready() + } + + fn name(&self) -> &'static str { + "GD" + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::indicators::Dema; + use crate::traits::BatchExt; + use approx::assert_relative_eq; + + #[test] + fn rejects_zero_period() { + assert!(matches!( + GeneralizedDema::new(0, 0.7), + Err(Error::PeriodZero) + )); + } + + #[test] + fn rejects_invalid_volume_factor() { + assert!(matches!( + GeneralizedDema::new(5, -0.1), + Err(Error::InvalidPeriod { .. }) + )); + assert!(matches!( + GeneralizedDema::new(5, 1.5), + Err(Error::InvalidPeriod { .. }) + )); + assert!(matches!( + GeneralizedDema::new(5, f64::NAN), + Err(Error::InvalidPeriod { .. }) + )); + assert!(GeneralizedDema::new(5, 0.0).is_ok()); + assert!(GeneralizedDema::new(5, 1.0).is_ok()); + } + + /// Cover the const accessors `period` + `volume_factor` and the + /// Indicator-impl `warmup_period` + `name`. + #[test] + fn accessors_and_metadata() { + let gd = GeneralizedDema::new(5, 0.7).unwrap(); + assert_eq!(gd.period(), 5); + assert_relative_eq!(gd.volume_factor(), 0.7, epsilon = 1e-12); + // EMA1 seeds at 5, EMA2 needs another 4 -> 2*period - 1 = 9. + assert_eq!(gd.warmup_period(), 9); + assert_eq!(gd.name(), "GD"); + } + + #[test] + fn constant_series_yields_constant() { + let mut gd = GeneralizedDema::new(5, 0.7).unwrap(); + let out = gd.batch(&[100.0_f64; 60]); + let last = out.iter().rev().flatten().next().unwrap(); + assert_relative_eq!(*last, 100.0, epsilon = 1e-9); + } + + #[test] + fn v_one_equals_dema() { + // GD with v = 1 is exactly the standard DEMA. + let prices: Vec = (1..=80) + .map(|i| (f64::from(i) * 0.3).sin() * 10.0 + 50.0) + .collect(); + let mut gd = GeneralizedDema::new(7, 1.0).unwrap(); + let mut dema = Dema::new(7).unwrap(); + let gd_out = gd.batch(&prices); + let dema_out = dema.batch(&prices); + for (g, d) in gd_out.iter().zip(dema_out.iter()) { + assert_eq!(g.is_some(), d.is_some()); + if let (Some(a), Some(b)) = (g, d) { + assert_relative_eq!(*a, *b, epsilon = 1e-9); + } + } + } + + #[test] + fn v_zero_equals_ema() { + // GD with v = 0 is a plain EMA (no second-order correction). + let prices: Vec = (1..=60).map(|i| f64::from(i) * 0.5).collect(); + let mut gd = GeneralizedDema::new(6, 0.0).unwrap(); + let mut ema = Ema::new(6).unwrap(); + let gd_out = gd.batch(&prices); + for (i, (g, p)) in gd_out.iter().zip(prices.iter()).enumerate() { + // GD(v=0) feeds EMA1 into EMA2 but outputs EMA1 alone (coefficient + // 1 on e1, 0 on e2); it is only ready once EMA2 is, so compare + // against a standalone EMA chained the same way. + let want = ema.update(*p).filter(|_| i + 1 >= gd.warmup_period()); + if let (Some(a), Some(b)) = (g, want) { + assert_relative_eq!(*a, b, epsilon = 1e-9); + } + } + } + + #[test] + fn batch_equals_streaming() { + let prices: Vec = (1..=80).map(|i| f64::from(i) * 0.5).collect(); + let mut a = GeneralizedDema::new(7, 0.7).unwrap(); + let mut b = GeneralizedDema::new(7, 0.7).unwrap(); + assert_eq!( + a.batch(&prices), + prices.iter().map(|p| b.update(*p)).collect::>() + ); + } + + #[test] + fn reset_clears_state() { + let mut gd = GeneralizedDema::new(5, 0.7).unwrap(); + gd.batch(&(1..=50).map(f64::from).collect::>()); + assert!(gd.is_ready()); + gd.reset(); + assert!(!gd.is_ready()); + assert_eq!(gd.update(1.0), None); + } +} diff --git a/crates/wickra-core/src/indicators/geometric_ma.rs b/crates/wickra-core/src/indicators/geometric_ma.rs new file mode 100644 index 00000000..150701b6 --- /dev/null +++ b/crates/wickra-core/src/indicators/geometric_ma.rs @@ -0,0 +1,275 @@ +//! Geometric Moving Average (GMA). + +use std::collections::VecDeque; + +use crate::error::{Error, Result}; +use crate::traits::Indicator; + +/// Geometric Moving Average — the rolling geometric mean of the last `period` +/// inputs. +/// +/// ```text +/// GMA = (Π value_i)^(1/period) = exp( (1/period) · Σ ln(value_i) ) +/// ``` +/// +/// The geometric mean is the natural average for *multiplicative* quantities +/// such as prices and growth factors: averaging in log-space weights relative +/// (percentage) moves symmetrically, so a `+10%` followed by a `−10%` move +/// pulls the average below the start, exactly as compounded returns do. It is +/// always less than or equal to the arithmetic mean of the same window. +/// +/// Maintained incrementally in O(1): the running sum of natural logs is updated +/// by adding the newcomer's log and subtracting the departing value's log as +/// the window slides. +/// +/// The geometric mean is only defined for **strictly positive** inputs. A +/// non-finite or non-positive input is ignored (it leaves the window unchanged +/// and returns the current value), mirroring the non-finite handling of the +/// other moving averages. +/// +/// # Example +/// +/// ``` +/// use wickra_core::{Indicator, GeometricMa}; +/// +/// let mut indicator = GeometricMa::new(5).unwrap(); +/// let mut last = None; +/// for i in 0..80 { +/// last = indicator.update(100.0 + f64::from(i)); +/// } +/// assert!(last.is_some()); +/// ``` +#[derive(Debug, Clone)] +pub struct GeometricMa { + period: usize, + /// Natural logs of the values currently in the window (oldest at front). + logs: VecDeque, + sum_logs: f64, +} + +impl GeometricMa { + /// Construct a new geometric moving average over `period` inputs. + /// + /// # Errors + /// + /// Returns [`Error::PeriodZero`] if `period == 0`. + pub fn new(period: usize) -> Result { + if period == 0 { + return Err(Error::PeriodZero); + } + Ok(Self { + period, + logs: VecDeque::with_capacity(period), + sum_logs: 0.0, + }) + } + + /// Configured period. + pub const fn period(&self) -> usize { + self.period + } + + /// Current value if the window is full. + pub fn value(&self) -> Option { + if self.logs.len() == self.period { + Some((self.sum_logs / self.period as f64).exp()) + } else { + None + } + } +} + +impl Indicator for GeometricMa { + type Input = f64; + type Output = f64; + + fn update(&mut self, input: f64) -> Option { + if !input.is_finite() || input <= 0.0 { + return self.value(); + } + if self.logs.len() == self.period { + let oldest = self.logs.pop_front().expect("window non-empty"); + self.sum_logs -= oldest; + } + let ln = input.ln(); + self.logs.push_back(ln); + self.sum_logs += ln; + self.value() + } + + fn reset(&mut self) { + self.logs.clear(); + self.sum_logs = 0.0; + } + + fn warmup_period(&self) -> usize { + self.period + } + + fn is_ready(&self) -> bool { + self.logs.len() == self.period + } + + fn name(&self) -> &'static str { + "GMA" + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::traits::BatchExt; + use approx::assert_relative_eq; + + /// Reference implementation: explicit geometric mean over a window. + fn gma_naive(prices: &[f64], period: usize) -> Vec> { + prices + .iter() + .enumerate() + .map(|(i, _)| { + if i + 1 < period { + None + } else { + let window = &prices[i + 1 - period..=i]; + let product: f64 = window.iter().product(); + Some(product.powf(1.0 / period as f64)) + } + }) + .collect() + } + + #[test] + fn new_rejects_zero_period() { + assert!(matches!(GeometricMa::new(0), Err(Error::PeriodZero))); + } + + /// Cover the const accessor `period` and the Indicator-impl `warmup_period` + /// + `name`. + #[test] + fn accessors_and_metadata() { + let gma = GeometricMa::new(7).unwrap(); + assert_eq!(gma.period(), 7); + assert_eq!(gma.warmup_period(), 7); + assert_eq!(gma.name(), "GMA"); + } + + #[test] + fn warmup_returns_none() { + let mut gma = GeometricMa::new(3).unwrap(); + assert_eq!(gma.update(1.0), None); + assert_eq!(gma.update(4.0), None); + // GMA(3) of [1, 4, 2] = (1·4·2)^(1/3) = 8^(1/3) = 2. + assert_relative_eq!(gma.update(2.0).unwrap(), 2.0, epsilon = 1e-12); + } + + #[test] + fn known_value_period_2() { + // GMA(2) of [4, 9] = sqrt(36) = 6. + let mut gma = GeometricMa::new(2).unwrap(); + let v = gma.batch(&[4.0, 9.0]); + assert_relative_eq!(v[1].unwrap(), 6.0, epsilon = 1e-12); + } + + #[test] + fn constant_series_returns_the_constant() { + let mut gma = GeometricMa::new(5).unwrap(); + for v in gma.batch(&[42.0; 20]).into_iter().flatten() { + assert_relative_eq!(v, 42.0, epsilon = 1e-9); + } + } + + #[test] + fn period_one_is_pass_through() { + let mut gma = GeometricMa::new(1).unwrap(); + assert_relative_eq!(gma.update(5.5).unwrap(), 5.5, epsilon = 1e-12); + assert_relative_eq!(gma.update(7.5).unwrap(), 7.5, epsilon = 1e-12); + } + + #[test] + fn below_or_equal_arithmetic_mean() { + // The geometric mean never exceeds the arithmetic mean of the same set. + let mut gma = GeometricMa::new(4).unwrap(); + let prices = [10.0, 20.0, 5.0, 40.0]; + let g = gma.batch(&prices)[3].unwrap(); + let arithmetic = prices.iter().sum::() / 4.0; + assert!( + g < arithmetic, + "geometric {g} should be below arithmetic {arithmetic}" + ); + } + + #[test] + fn matches_naive_over_inputs() { + let prices: Vec = (1..=30).map(|i| f64::from(i) * 1.7 + 1.0).collect(); + let mut gma = GeometricMa::new(7).unwrap(); + let got = gma.batch(&prices); + let want = gma_naive(&prices, 7); + for (i, (g, w)) in got.iter().zip(want.iter()).enumerate() { + assert_eq!(g.is_some(), w.is_some(), "warmup mismatch at index {i}"); + if let (Some(a), Some(b)) = (g, w) { + assert_relative_eq!(*a, *b, epsilon = 1e-9); + } + } + } + + #[test] + fn reset_clears_state() { + let mut gma = GeometricMa::new(4).unwrap(); + gma.batch(&[1.0, 2.0, 3.0, 4.0, 5.0]); + assert!(gma.is_ready()); + gma.reset(); + assert!(!gma.is_ready()); + assert_eq!(gma.update(10.0), None); + } + + #[test] + fn batch_equals_streaming() { + let prices: Vec = (1..=20).map(|i| f64::from(i) * 0.5 + 1.0).collect(); + let mut a = GeometricMa::new(5).unwrap(); + let mut b = GeometricMa::new(5).unwrap(); + assert_eq!( + a.batch(&prices), + prices.iter().map(|p| b.update(*p)).collect::>() + ); + } + + #[test] + fn ignores_non_finite_and_non_positive_input() { + let mut gma = GeometricMa::new(3).unwrap(); + gma.update(1.0); + gma.update(4.0); + let ready = gma.update(2.0).expect("GMA(3) ready after three inputs"); + // Non-finite and non-positive inputs are skipped (geometric mean needs + // strictly positive values) and the window is left unchanged. + assert_eq!(gma.update(f64::NAN), Some(ready)); + assert_eq!(gma.update(0.0), Some(ready)); + assert_eq!(gma.update(-3.0), Some(ready)); + // The window still holds 1, 4, 2 -> next real input slides it to 4, 2, 16. + let want = (4.0_f64 * 2.0 * 16.0).powf(1.0 / 3.0); + assert_relative_eq!(gma.update(16.0).unwrap(), want, epsilon = 1e-9); + } + + proptest::proptest! { + #![proptest_config(proptest::test_runner::Config::with_cases(48))] + #[test] + fn proptest_matches_naive( + period in 1usize..15, + prices in proptest::collection::vec(0.01_f64..1000.0, 0..120), + ) { + let mut gma = GeometricMa::new(period).unwrap(); + let got = gma.batch(&prices); + let want = gma_naive(&prices, period); + proptest::prop_assert_eq!(got.len(), want.len()); + for (g, w) in got.iter().zip(want.iter()) { + match (g, w) { + (None, None) => {} + (Some(a), Some(b)) => proptest::prop_assert!( + (a - b).abs() <= 1e-6 * b.abs().max(1.0), + "got={a} want={b}" + ), + _ => proptest::prop_assert!(false, "warmup mismatch"), + } + } + } + } +} diff --git a/crates/wickra-core/src/indicators/holt_winters.rs b/crates/wickra-core/src/indicators/holt_winters.rs new file mode 100644 index 00000000..c6258d56 --- /dev/null +++ b/crates/wickra-core/src/indicators/holt_winters.rs @@ -0,0 +1,315 @@ +//! Holt's linear (double exponential) smoothing. + +use crate::error::{Error, Result}; +use crate::traits::Indicator; + +/// Holt's linear method — double exponential smoothing with a level and a +/// trend component. +/// +/// A single [`Ema`](crate::Ema) tracks only a *level* and therefore lags any +/// sustained trend. Holt's method adds a second smoothed state, the trend, and +/// reports the one-step-ahead forecast `level + trend`, which removes that lag +/// on trending data while still smoothing noise. +/// +/// ```text +/// level_t = α · price_t + (1 − α) · (level_{t-1} + trend_{t-1}) +/// trend_t = β · (level_t − level_{t-1}) + (1 − β) · trend_{t-1} +/// output = level_t + trend_t (one-step-ahead forecast) +/// ``` +/// +/// `α ∈ (0, 1]` is the level smoothing constant and `β ∈ (0, 1]` the trend +/// smoothing constant. The state is seeded from the first two inputs +/// (`level = price_1`, `trend = price_1 − price_0`), so the first output lands +/// on the **second** input. +/// +/// On a perfectly linear series the forecast is exact from the second bar +/// onward (for any `α`, `β`): if the level equals the current value and the +/// trend equals the slope, both invariants are preserved and `level + trend` +/// equals the next value. +/// +/// # Example +/// +/// ``` +/// use wickra_core::{HoltWinters, Indicator}; +/// +/// let mut indicator = HoltWinters::new(0.2, 0.1).unwrap(); +/// let mut last = None; +/// for i in 0..80 { +/// last = indicator.update(100.0 + f64::from(i)); +/// } +/// assert!(last.is_some()); +/// ``` +#[derive(Debug, Clone)] +pub struct HoltWinters { + alpha: f64, + beta: f64, + /// `(level, trend)` once seeded. + state: Option<(f64, f64)>, + /// First input, held until the second arrives to seed the trend. + prev_price: Option, +} + +impl HoltWinters { + /// Construct Holt's linear smoother with level constant `alpha` and trend + /// constant `beta`. + /// + /// # Errors + /// + /// Returns [`Error::InvalidPeriod`] if either constant is non-finite or + /// outside `(0.0, 1.0]`. + pub fn new(alpha: f64, beta: f64) -> Result { + if !alpha.is_finite() || alpha <= 0.0 || alpha > 1.0 { + return Err(Error::InvalidPeriod { + message: "HoltWinters alpha must be in (0.0, 1.0]", + }); + } + if !beta.is_finite() || beta <= 0.0 || beta > 1.0 { + return Err(Error::InvalidPeriod { + message: "HoltWinters beta must be in (0.0, 1.0]", + }); + } + Ok(Self { + alpha, + beta, + state: None, + prev_price: None, + }) + } + + /// Level smoothing constant `alpha`. + pub const fn alpha(&self) -> f64 { + self.alpha + } + + /// Trend smoothing constant `beta`. + pub const fn beta(&self) -> f64 { + self.beta + } + + /// Current smoothed level, if seeded. + pub fn level(&self) -> Option { + self.state.map(|(level, _)| level) + } + + /// Current smoothed trend, if seeded. + pub fn trend(&self) -> Option { + self.state.map(|(_, trend)| trend) + } + + /// Current one-step-ahead forecast `level + trend`, if seeded. + pub fn value(&self) -> Option { + self.state.map(|(level, trend)| level + trend) + } +} + +impl Indicator for HoltWinters { + type Input = f64; + type Output = f64; + + fn update(&mut self, price: f64) -> Option { + if !price.is_finite() { + return self.value(); + } + match self.state { + None => { + if let Some(prev) = self.prev_price { + // Second input: seed level and trend. + let level = price; + let trend = price - prev; + self.state = Some((level, trend)); + Some(level + trend) + } else { + // First input: hold it to seed the trend next time. + self.prev_price = Some(price); + None + } + } + Some((level, trend)) => { + let level_new = self.alpha * price + (1.0 - self.alpha) * (level + trend); + let trend_new = self.beta * (level_new - level) + (1.0 - self.beta) * trend; + self.state = Some((level_new, trend_new)); + Some(level_new + trend_new) + } + } + } + + fn reset(&mut self) { + self.state = None; + self.prev_price = None; + } + + fn warmup_period(&self) -> usize { + // Two inputs are needed to seed the level and the trend. + 2 + } + + fn is_ready(&self) -> bool { + self.state.is_some() + } + + fn name(&self) -> &'static str { + "HoltWinters" + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::traits::BatchExt; + use approx::assert_relative_eq; + + /// Independent reference for the steady-state recurrence. + fn naive(prices: &[f64], alpha: f64, beta: f64) -> Vec> { + let mut state: Option<(f64, f64)> = None; + let mut prev: Option = None; + let mut out = Vec::with_capacity(prices.len()); + for &price in prices { + let v = match state { + None => { + if let Some(p0) = prev { + let level = price; + let trend = price - p0; + state = Some((level, trend)); + Some(level + trend) + } else { + prev = Some(price); + None + } + } + Some((level, trend)) => { + let ln = alpha * price + (1.0 - alpha) * (level + trend); + let tn = beta * (ln - level) + (1.0 - beta) * trend; + state = Some((ln, tn)); + Some(ln + tn) + } + }; + out.push(v); + } + out + } + + #[test] + fn rejects_invalid_alpha() { + assert!(matches!( + HoltWinters::new(0.0, 0.1), + Err(Error::InvalidPeriod { .. }) + )); + assert!(matches!( + HoltWinters::new(1.5, 0.1), + Err(Error::InvalidPeriod { .. }) + )); + assert!(matches!( + HoltWinters::new(f64::NAN, 0.1), + Err(Error::InvalidPeriod { .. }) + )); + } + + #[test] + fn rejects_invalid_beta() { + assert!(matches!( + HoltWinters::new(0.2, 0.0), + Err(Error::InvalidPeriod { .. }) + )); + assert!(matches!( + HoltWinters::new(0.2, 1.5), + Err(Error::InvalidPeriod { .. }) + )); + assert!(matches!( + HoltWinters::new(0.2, f64::INFINITY), + Err(Error::InvalidPeriod { .. }) + )); + } + + /// Cover the const accessors `alpha` + `beta` and the Indicator-impl + /// `warmup_period` + `name`. + #[test] + fn accessors_and_metadata() { + let hw = HoltWinters::new(0.2, 0.1).unwrap(); + assert_relative_eq!(hw.alpha(), 0.2, epsilon = 1e-12); + assert_relative_eq!(hw.beta(), 0.1, epsilon = 1e-12); + assert_eq!(hw.warmup_period(), 2); + assert_eq!(hw.name(), "HoltWinters"); + } + + #[test] + fn warmup_then_seed_on_second_input() { + let mut hw = HoltWinters::new(0.2, 0.1).unwrap(); + assert_eq!(hw.update(10.0), None); + // Second input seeds level = 12, trend = 12 - 10 = 2 -> forecast 14. + assert_relative_eq!(hw.update(12.0).unwrap(), 14.0, epsilon = 1e-12); + assert_relative_eq!(hw.level().unwrap(), 12.0, epsilon = 1e-12); + assert_relative_eq!(hw.trend().unwrap(), 2.0, epsilon = 1e-12); + } + + #[test] + fn linear_series_forecasts_exactly() { + // On a perfect ramp the one-step forecast equals the next value, for + // any alpha/beta, from the second bar onward. + let prices: Vec = (1..=20).map(f64::from).collect(); + let mut hw = HoltWinters::new(0.3, 0.4).unwrap(); + let out = hw.batch(&prices); + assert!(out[0].is_none()); + for (i, v) in out.iter().enumerate().skip(1) { + // forecast at index i is the price at index i + 1 = (i + 2). + assert_relative_eq!(v.unwrap(), (i + 2) as f64, epsilon = 1e-9); + } + } + + #[test] + fn constant_series_yields_constant() { + let mut hw = HoltWinters::new(0.2, 0.1).unwrap(); + let out = hw.batch(&[42.0_f64; 30]); + for v in out.into_iter().skip(1).flatten() { + assert_relative_eq!(v, 42.0, epsilon = 1e-9); + } + } + + #[test] + fn matches_naive_recurrence() { + let prices: Vec = (0..60) + .map(|i| 100.0 + (f64::from(i) * 0.3).sin() * 10.0 + f64::from(i) * 0.2) + .collect(); + let mut hw = HoltWinters::new(0.25, 0.15).unwrap(); + let got = hw.batch(&prices); + let want = naive(&prices, 0.25, 0.15); + for (g, w) in got.iter().zip(want.iter()) { + assert_eq!(g.is_some(), w.is_some()); + if let (Some(a), Some(b)) = (g, w) { + assert_relative_eq!(a, b, epsilon = 1e-9); + } + } + } + + #[test] + fn reset_clears_state() { + let mut hw = HoltWinters::new(0.2, 0.1).unwrap(); + hw.batch(&(1..=20).map(f64::from).collect::>()); + assert!(hw.is_ready()); + hw.reset(); + assert!(!hw.is_ready()); + assert_eq!(hw.update(1.0), None); + } + + #[test] + fn batch_equals_streaming() { + let prices: Vec = (1..=30).map(|i| f64::from(i) * 0.5).collect(); + let mut a = HoltWinters::new(0.3, 0.2).unwrap(); + let mut b = HoltWinters::new(0.3, 0.2).unwrap(); + assert_eq!( + a.batch(&prices), + prices.iter().map(|p| b.update(*p)).collect::>() + ); + } + + #[test] + fn ignores_non_finite_input() { + let mut hw = HoltWinters::new(0.2, 0.1).unwrap(); + // Non-finite before any state returns None. + assert_eq!(hw.update(f64::NAN), None); + hw.update(10.0); + let ready = hw.update(12.0).expect("seeded on second finite input"); + // Non-finite after seeding returns the current forecast unchanged. + assert_eq!(hw.update(f64::NAN), Some(ready)); + assert_eq!(hw.update(f64::INFINITY), Some(ready)); + } +} diff --git a/crates/wickra-core/src/indicators/median_ma.rs b/crates/wickra-core/src/indicators/median_ma.rs new file mode 100644 index 00000000..2d5b8a85 --- /dev/null +++ b/crates/wickra-core/src/indicators/median_ma.rs @@ -0,0 +1,205 @@ +//! Median Moving Average. + +use std::collections::VecDeque; + +use crate::error::{Error, Result}; +use crate::traits::Indicator; + +/// Median Moving Average — the rolling median of the last `period` inputs. +/// +/// For an odd `period` the output is the middle order statistic of the window; +/// for an even `period` it is the average of the two central values. Because it +/// is a rank statistic rather than a sum, the median MA is far more robust to +/// single outliers than the [`Sma`](crate::Sma): a lone spike shifts the rank +/// by at most one position instead of dragging the whole average. +/// +/// Each `update` slides the window and computes the median by sorting a copy of +/// the `period` buffered values — O(`period` · log `period`) per step, with the +/// period fixed and bounded. +/// +/// # Example +/// +/// ``` +/// use wickra_core::{Indicator, MedianMa}; +/// +/// let mut indicator = MedianMa::new(5).unwrap(); +/// let mut last = None; +/// for i in 0..80 { +/// last = indicator.update(100.0 + f64::from(i)); +/// } +/// assert!(last.is_some()); +/// ``` +#[derive(Debug, Clone)] +pub struct MedianMa { + period: usize, + window: VecDeque, +} + +impl MedianMa { + /// Construct a new median moving average over `period` inputs. + /// + /// # Errors + /// + /// Returns [`Error::PeriodZero`] if `period == 0`. + pub fn new(period: usize) -> Result { + if period == 0 { + return Err(Error::PeriodZero); + } + Ok(Self { + period, + window: VecDeque::with_capacity(period), + }) + } + + /// Configured period. + pub const fn period(&self) -> usize { + self.period + } + + /// Current value if the window is full. + pub fn value(&self) -> Option { + if self.window.len() != self.period { + return None; + } + let mut sorted: Vec = self.window.iter().copied().collect(); + sorted.sort_by(|a, b| a.partial_cmp(b).expect("window holds only finite values")); + let mid = self.period / 2; + if self.period % 2 == 1 { + Some(sorted[mid]) + } else { + Some(f64::midpoint(sorted[mid - 1], sorted[mid])) + } + } +} + +impl Indicator for MedianMa { + type Input = f64; + type Output = f64; + + fn update(&mut self, input: f64) -> Option { + if !input.is_finite() { + return self.value(); + } + if self.window.len() == self.period { + self.window.pop_front(); + } + self.window.push_back(input); + self.value() + } + + fn reset(&mut self) { + self.window.clear(); + } + + fn warmup_period(&self) -> usize { + self.period + } + + fn is_ready(&self) -> bool { + self.window.len() == self.period + } + + fn name(&self) -> &'static str { + "MedianMA" + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::traits::BatchExt; + use approx::assert_relative_eq; + + #[test] + fn new_rejects_zero_period() { + assert!(matches!(MedianMa::new(0), Err(Error::PeriodZero))); + } + + /// Cover the const accessor `period` and the Indicator-impl `warmup_period` + /// + `name`. + #[test] + fn accessors_and_metadata() { + let mma = MedianMa::new(7).unwrap(); + assert_eq!(mma.period(), 7); + assert_eq!(mma.warmup_period(), 7); + assert_eq!(mma.name(), "MedianMA"); + } + + #[test] + fn warmup_returns_none_then_odd_median() { + let mut mma = MedianMa::new(3).unwrap(); + assert_eq!(mma.update(5.0), None); + assert_eq!(mma.update(1.0), None); + // median of [5, 1, 3] = 3 (middle order statistic). + assert_relative_eq!(mma.update(3.0).unwrap(), 3.0, epsilon = 1e-12); + } + + #[test] + fn even_period_averages_two_central_values() { + // median of [1, 2, 3, 4] = (2 + 3) / 2 = 2.5. + let mut mma = MedianMa::new(4).unwrap(); + let v = mma.batch(&[1.0, 2.0, 3.0, 4.0]); + assert_relative_eq!(v[3].unwrap(), 2.5, epsilon = 1e-12); + } + + #[test] + fn robust_to_single_outlier() { + // A lone spike does not move the median of an odd window the way it + // would move an SMA. median of [10, 11, 9999] = 11. + let mut mma = MedianMa::new(3).unwrap(); + let v = mma.batch(&[10.0, 11.0, 9999.0]); + assert_relative_eq!(v[2].unwrap(), 11.0, epsilon = 1e-12); + } + + #[test] + fn period_one_is_pass_through() { + let mut mma = MedianMa::new(1).unwrap(); + assert_relative_eq!(mma.update(5.5).unwrap(), 5.5, epsilon = 1e-12); + assert_relative_eq!(mma.update(7.5).unwrap(), 7.5, epsilon = 1e-12); + } + + #[test] + fn slides_window_correctly() { + // After [1,2,3] the window slides to [2,3,4] -> median 3, then [3,4,5] -> 4. + let mut mma = MedianMa::new(3).unwrap(); + let v = mma.batch(&[1.0, 2.0, 3.0, 4.0, 5.0]); + assert_relative_eq!(v[2].unwrap(), 2.0, epsilon = 1e-12); + assert_relative_eq!(v[3].unwrap(), 3.0, epsilon = 1e-12); + assert_relative_eq!(v[4].unwrap(), 4.0, epsilon = 1e-12); + } + + #[test] + fn reset_clears_state() { + let mut mma = MedianMa::new(4).unwrap(); + mma.batch(&[1.0, 2.0, 3.0, 4.0, 5.0]); + assert!(mma.is_ready()); + mma.reset(); + assert!(!mma.is_ready()); + assert_eq!(mma.update(10.0), None); + } + + #[test] + fn batch_equals_streaming() { + let prices: Vec = (1..=20).map(|i| (f64::from(i) * 0.7).sin() * 5.0).collect(); + let mut a = MedianMa::new(5).unwrap(); + let mut b = MedianMa::new(5).unwrap(); + assert_eq!( + a.batch(&prices), + prices.iter().map(|p| b.update(*p)).collect::>() + ); + } + + #[test] + fn ignores_non_finite_input_but_keeps_state() { + let mut mma = MedianMa::new(3).unwrap(); + mma.update(5.0); + mma.update(1.0); + let ready = mma + .update(3.0) + .expect("MedianMA(3) ready after three inputs"); + assert_eq!(mma.update(f64::NAN), Some(ready)); + assert_eq!(mma.update(f64::INFINITY), Some(ready)); + // Window still [5, 1, 3] -> next real input slides to [1, 3, 8] -> median 3. + assert_relative_eq!(mma.update(8.0).unwrap(), 3.0, epsilon = 1e-12); + } +} diff --git a/crates/wickra-core/src/indicators/mod.rs b/crates/wickra-core/src/indicators/mod.rs index 528f34d0..058e94ae 100644 --- a/crates/wickra-core/src/indicators/mod.rs +++ b/crates/wickra-core/src/indicators/mod.rs @@ -17,6 +17,7 @@ mod accelerator_oscillator; mod ad_oscillator; mod ad_volume_line; mod adaptive_cycle; +mod adaptive_laguerre_filter; mod adl; mod advance_block; mod advance_decline; @@ -106,6 +107,7 @@ mod dx; mod ease_of_movement; mod effective_spread; mod ehlers_stochastic; +mod ehma; mod elder_impulse; mod ema; mod empirical_mode_decomposition; @@ -138,6 +140,8 @@ mod gain_loss_ratio; mod gap_side_by_side_white; mod garman_klass; mod gartley; +mod generalized_dema; +mod geometric_ma; mod golden_pocket; mod granger_causality; mod gravestone_doji; @@ -155,6 +159,7 @@ mod hilbert_dominant_cycle; mod hilo_activator; mod historical_volatility; mod hma; +mod holt_winters; mod homing_pigeon; mod ht_dcphase; mod ht_phasor; @@ -212,6 +217,7 @@ mod mcclellan_oscillator; mod mcclellan_summation_index; mod mcginley_dynamic; mod median_absolute_deviation; +mod median_ma; mod median_price; mod mfi; mod microprice; @@ -298,6 +304,7 @@ mod shooting_star; mod short_line; mod signed_volume; mod sine_wave; +mod sine_weighted_ma; mod skewness; mod sma; mod smi; @@ -413,6 +420,7 @@ pub use accelerator_oscillator::AcceleratorOscillator; pub use ad_oscillator::AdOscillator; pub use ad_volume_line::AdVolumeLine; pub use adaptive_cycle::AdaptiveCycle; +pub use adaptive_laguerre_filter::AdaptiveLaguerreFilter; pub use adl::Adl; pub use advance_block::AdvanceBlock; pub use advance_decline::AdvanceDecline; @@ -502,6 +510,7 @@ pub use dx::Dx; pub use ease_of_movement::EaseOfMovement; pub use effective_spread::EffectiveSpread; pub use ehlers_stochastic::EhlersStochastic; +pub use ehma::Ehma; pub use elder_impulse::ElderImpulse; pub use ema::Ema; pub use empirical_mode_decomposition::EmpiricalModeDecomposition; @@ -534,6 +543,8 @@ pub use gain_loss_ratio::GainLossRatio; pub use gap_side_by_side_white::GapSideBySideWhite; pub use garman_klass::GarmanKlassVolatility; pub use gartley::Gartley; +pub use generalized_dema::GeneralizedDema; +pub use geometric_ma::GeometricMa; pub use golden_pocket::{GoldenPocket, GoldenPocketOutput}; pub use granger_causality::GrangerCausality; pub use gravestone_doji::GravestoneDoji; @@ -551,6 +562,7 @@ pub use hilbert_dominant_cycle::HilbertDominantCycle; pub use hilo_activator::HiLoActivator; pub use historical_volatility::HistoricalVolatility; pub use hma::Hma; +pub use holt_winters::HoltWinters; pub use homing_pigeon::HomingPigeon; pub use ht_dcphase::HtDcPhase; pub use ht_phasor::{HtPhasor, HtPhasorOutput}; @@ -608,6 +620,7 @@ pub use mcclellan_oscillator::McClellanOscillator; pub use mcclellan_summation_index::McClellanSummationIndex; pub use mcginley_dynamic::McGinleyDynamic; pub use median_absolute_deviation::MedianAbsoluteDeviation; +pub use median_ma::MedianMa; pub use median_price::MedianPrice; pub use mfi::Mfi; pub use microprice::Microprice; @@ -694,6 +707,7 @@ pub use shooting_star::ShootingStar; pub use short_line::ShortLine; pub use signed_volume::SignedVolume; pub use sine_wave::SineWave; +pub use sine_weighted_ma::SineWeightedMa; pub use skewness::Skewness; pub use sma::Sma; pub use smi::Smi; @@ -830,6 +844,13 @@ pub const FAMILIES: &[(&str, &[&str])] = &[ "Jma", "Alligator", "Evwma", + "SineWeightedMa", + "GeometricMa", + "Ehma", + "MedianMa", + "AdaptiveLaguerreFilter", + "GeneralizedDema", + "HoltWinters", ], ), ( @@ -1342,6 +1363,6 @@ mod family_tests { // the actual indicator count is the early-warning signal that an // indicator was added without being assigned a family. let total: usize = FAMILIES.iter().map(|(_, ns)| ns.len()).sum(); - assert_eq!(total, 396, "FAMILIES total drifted from indicator count"); + assert_eq!(total, 403, "FAMILIES total drifted from indicator count"); } } diff --git a/crates/wickra-core/src/indicators/sine_weighted_ma.rs b/crates/wickra-core/src/indicators/sine_weighted_ma.rs new file mode 100644 index 00000000..eb88dee4 --- /dev/null +++ b/crates/wickra-core/src/indicators/sine_weighted_ma.rs @@ -0,0 +1,273 @@ +//! Sine-Weighted Moving Average (SWMA). + +use std::collections::VecDeque; + +use crate::error::{Error, Result}; +use crate::traits::Indicator; + +/// Sine-Weighted Moving Average — a windowed average whose weights follow one +/// half-cycle of a sine wave. +/// +/// Over the last `period` inputs the weight of the value at position +/// `i = 0, 1, …, period − 1` (oldest to newest) is +/// +/// ```text +/// w_i = sin(π · (i + 1) / (period + 1)) +/// SWMA = Σ (w_i · value_i) / Σ w_i +/// ``` +/// +/// The window is symmetric: weights rise to a peak in the middle of the window +/// and fall off at both ends, so the central observations dominate while the +/// extremes are de-emphasised. Every weight is strictly positive because the +/// argument `(i + 1) / (period + 1)` lies in the open interval `(0, 1)`, so the +/// normaliser is always non-zero. +/// +/// Each `update` is O(`period`): the fixed weight vector is dotted with the +/// trailing window, mirroring the way [`Alma`](crate::Alma) recomputes its +/// Gaussian weights. `period == 1` collapses to a pass-through +/// (`w_0 = sin(π/2) = 1`). +/// +/// # Example +/// +/// ``` +/// use wickra_core::{Indicator, SineWeightedMa}; +/// +/// let mut indicator = SineWeightedMa::new(5).unwrap(); +/// let mut last = None; +/// for i in 0..80 { +/// last = indicator.update(100.0 + f64::from(i)); +/// } +/// assert!(last.is_some()); +/// ``` +#[derive(Debug, Clone)] +pub struct SineWeightedMa { + period: usize, + window: VecDeque, + /// Sine weights for positions `0..period` (oldest to newest), constant in + /// `period`. + weights: Vec, + weights_total: f64, +} + +impl SineWeightedMa { + /// Construct a new sine-weighted moving average over `period` inputs. + /// + /// # Errors + /// + /// Returns [`Error::PeriodZero`] if `period == 0`. + pub fn new(period: usize) -> Result { + if period == 0 { + return Err(Error::PeriodZero); + } + let denom = period as f64 + 1.0; + let weights: Vec = (0..period) + .map(|i| (std::f64::consts::PI * (i as f64 + 1.0) / denom).sin()) + .collect(); + let weights_total = weights.iter().sum(); + Ok(Self { + period, + window: VecDeque::with_capacity(period), + weights, + weights_total, + }) + } + + /// Configured period. + pub const fn period(&self) -> usize { + self.period + } + + /// Current value if the window is full. + pub fn value(&self) -> Option { + if self.window.len() == self.period { + let dot: f64 = self + .window + .iter() + .zip(&self.weights) + .map(|(v, w)| v * w) + .sum(); + Some(dot / self.weights_total) + } else { + None + } + } +} + +impl Indicator for SineWeightedMa { + type Input = f64; + type Output = f64; + + fn update(&mut self, input: f64) -> Option { + if !input.is_finite() { + return self.value(); + } + if self.window.len() == self.period { + self.window.pop_front(); + } + self.window.push_back(input); + self.value() + } + + fn reset(&mut self) { + self.window.clear(); + } + + fn warmup_period(&self) -> usize { + self.period + } + + fn is_ready(&self) -> bool { + self.window.len() == self.period + } + + fn name(&self) -> &'static str { + "SWMA" + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::traits::BatchExt; + use approx::assert_relative_eq; + + /// Reference implementation: explicit sine-weighted average over a window. + fn swma_naive(prices: &[f64], period: usize) -> Vec> { + let denom = period as f64 + 1.0; + let weights: Vec = (0..period) + .map(|i| (std::f64::consts::PI * (i as f64 + 1.0) / denom).sin()) + .collect(); + let total: f64 = weights.iter().sum(); + prices + .iter() + .enumerate() + .map(|(i, _)| { + if i + 1 < period { + None + } else { + let window = &prices[i + 1 - period..=i]; + let dot: f64 = window.iter().zip(&weights).map(|(v, w)| v * w).sum(); + Some(dot / total) + } + }) + .collect() + } + + #[test] + fn new_rejects_zero_period() { + assert!(matches!(SineWeightedMa::new(0), Err(Error::PeriodZero))); + } + + /// Cover the const accessor `period` and the Indicator-impl `warmup_period` + /// + `name`. + #[test] + fn accessors_and_metadata() { + let swma = SineWeightedMa::new(7).unwrap(); + assert_eq!(swma.period(), 7); + assert_eq!(swma.warmup_period(), 7); + assert_eq!(swma.name(), "SWMA"); + } + + #[test] + fn warmup_returns_none() { + let mut swma = SineWeightedMa::new(3).unwrap(); + assert_eq!(swma.update(1.0), None); + assert_eq!(swma.update(2.0), None); + // SWMA(3): weights sin(pi/4), sin(pi/2), sin(3pi/4) = [√½, 1, √½]. + // Over [1,2,3]: (√½·1 + 1·2 + √½·3) / (√½ + 1 + √½). + let s = std::f64::consts::FRAC_1_SQRT_2; + let total = s + 1.0 + s; + let want = (s * 1.0 + 1.0 * 2.0 + s * 3.0) / total; + assert_relative_eq!(swma.update(3.0).unwrap(), want, epsilon = 1e-12); + } + + #[test] + fn symmetric_weights_give_midpoint_on_linear_window() { + // For a perfectly linear window the symmetric weighting reproduces the + // arithmetic centre of the window. + let mut swma = SineWeightedMa::new(5).unwrap(); + let v = swma.batch(&[1.0, 2.0, 3.0, 4.0, 5.0]); + assert_relative_eq!(v[4].unwrap(), 3.0, epsilon = 1e-12); + } + + #[test] + fn period_one_is_pass_through() { + let mut swma = SineWeightedMa::new(1).unwrap(); + assert_relative_eq!(swma.update(5.5).unwrap(), 5.5, epsilon = 1e-12); + assert_relative_eq!(swma.update(7.5).unwrap(), 7.5, epsilon = 1e-12); + } + + #[test] + fn matches_naive_over_inputs() { + let prices: Vec = (1..=30).map(|i| f64::from(i) * 1.7 - 5.0).collect(); + let mut swma = SineWeightedMa::new(7).unwrap(); + let got = swma.batch(&prices); + let want = swma_naive(&prices, 7); + for (i, (g, w)) in got.iter().zip(want.iter()).enumerate() { + assert_eq!(g.is_some(), w.is_some(), "warmup mismatch at index {i}"); + if let (Some(a), Some(b)) = (g, w) { + assert_relative_eq!(*a, *b, epsilon = 1e-9); + } + } + } + + #[test] + fn reset_clears_state() { + let mut swma = SineWeightedMa::new(4).unwrap(); + swma.batch(&[1.0, 2.0, 3.0, 4.0, 5.0]); + assert!(swma.is_ready()); + swma.reset(); + assert!(!swma.is_ready()); + assert_eq!(swma.update(10.0), None); + } + + #[test] + fn batch_equals_streaming() { + let prices: Vec = (1..=20).map(|i| f64::from(i) * 0.5).collect(); + let mut a = SineWeightedMa::new(5).unwrap(); + let mut b = SineWeightedMa::new(5).unwrap(); + assert_eq!( + a.batch(&prices), + prices.iter().map(|p| b.update(*p)).collect::>() + ); + } + + #[test] + fn ignores_non_finite_input_but_keeps_state() { + let mut swma = SineWeightedMa::new(3).unwrap(); + swma.update(1.0); + swma.update(2.0); + let ready = swma.update(3.0).expect("SWMA(3) ready after three inputs"); + assert_eq!(swma.update(f64::NAN), Some(ready)); + assert_eq!(swma.update(f64::INFINITY), Some(ready)); + // The window still holds 1, 2, 3 -> next real input slides it to 2, 3, 4. + let s = std::f64::consts::FRAC_1_SQRT_2; + let total = s + 1.0 + s; + let want = (s * 2.0 + 1.0 * 3.0 + s * 4.0) / total; + assert_relative_eq!(swma.update(4.0).unwrap(), want, epsilon = 1e-12); + } + + proptest::proptest! { + #![proptest_config(proptest::test_runner::Config::with_cases(48))] + #[test] + fn proptest_matches_naive( + period in 1usize..15, + prices in proptest::collection::vec(-500.0_f64..500.0, 0..120), + ) { + let mut swma = SineWeightedMa::new(period).unwrap(); + let got = swma.batch(&prices); + let want = swma_naive(&prices, period); + proptest::prop_assert_eq!(got.len(), want.len()); + for (g, w) in got.iter().zip(want.iter()) { + match (g, w) { + (None, None) => {} + (Some(a), Some(b)) => proptest::prop_assert!( + (a - b).abs() < 1e-7, + "got={a} want={b}" + ), + _ => proptest::prop_assert!(false, "warmup mismatch"), + } + } + } + } +} diff --git a/crates/wickra-core/src/lib.rs b/crates/wickra-core/src/lib.rs index 8deb5ff7..6128fc2b 100644 --- a/crates/wickra-core/src/lib.rs +++ b/crates/wickra-core/src/lib.rs @@ -57,37 +57,39 @@ pub use derivatives::DerivativesTick; pub use error::{Error, Result}; pub use indicators::{ AbandonedBaby, Abcd, AbsoluteBreadthIndex, AccelerationBands, AccelerationBandsOutput, - AcceleratorOscillator, AdOscillator, AdVolumeLine, AdaptiveCycle, Adl, AdvanceBlock, - AdvanceDecline, AdvanceDeclineRatio, Adx, AdxOutput, Adxr, Alligator, AlligatorOutput, Alma, - Alpha, AmihudIlliquidity, AnchoredRsi, AnchoredVwap, Apo, Aroon, AroonOscillator, AroonOutput, - Atr, AtrBands, AtrBandsOutput, AtrTrailingStop, AutoFib, AutoFibOutput, Autocorrelation, - AverageDailyRange, AverageDrawdown, AvgPrice, AwesomeOscillator, AwesomeOscillatorHistogram, - BalanceOfPower, Bat, BeltHold, Beta, BetaNeutralSpread, BodySizePct, BollingerBands, - BollingerBandwidth, BollingerOutput, BreadthThrust, Breakaway, BullishPercentIndex, Butterfly, - CalendarSpread, CalmarRatio, Camarilla, CamarillaPivotsOutput, Cci, CenterOfGravity, Cfo, - ChaikinMoneyFlow, ChaikinOscillator, ChaikinVolatility, ChandeKrollStop, ChandeKrollStopOutput, - ChandelierExit, ChandelierExitOutput, ChoppinessIndex, ClassicPivots, ClassicPivotsOutput, - CloseVsOpen, ClosingMarubozu, Cmo, CoefficientOfVariation, Cointegration, CointegrationOutput, + AcceleratorOscillator, AdOscillator, AdVolumeLine, AdaptiveCycle, AdaptiveLaguerreFilter, Adl, + AdvanceBlock, AdvanceDecline, AdvanceDeclineRatio, Adx, AdxOutput, Adxr, Alligator, + AlligatorOutput, Alma, Alpha, AmihudIlliquidity, AnchoredRsi, AnchoredVwap, Apo, Aroon, + AroonOscillator, AroonOutput, Atr, AtrBands, AtrBandsOutput, AtrTrailingStop, AutoFib, + AutoFibOutput, Autocorrelation, AverageDailyRange, AverageDrawdown, AvgPrice, + AwesomeOscillator, AwesomeOscillatorHistogram, BalanceOfPower, Bat, BeltHold, Beta, + BetaNeutralSpread, BodySizePct, BollingerBands, BollingerBandwidth, BollingerOutput, + BreadthThrust, Breakaway, BullishPercentIndex, Butterfly, CalendarSpread, CalmarRatio, + Camarilla, CamarillaPivotsOutput, Cci, CenterOfGravity, Cfo, ChaikinMoneyFlow, + ChaikinOscillator, ChaikinVolatility, ChandeKrollStop, ChandeKrollStopOutput, ChandelierExit, + ChandelierExitOutput, ChoppinessIndex, ClassicPivots, ClassicPivotsOutput, CloseVsOpen, + ClosingMarubozu, Cmo, CoefficientOfVariation, Cointegration, CointegrationOutput, ConcealingBabySwallow, ConditionalValueAtRisk, ConnorsRsi, Coppock, Counterattack, Crab, CumulativeVolumeDelta, CumulativeVolumeIndex, CupAndHandle, CyberneticCycle, Cypher, DayOfWeekProfile, DayOfWeekProfileOutput, Decycler, DecyclerOscillator, Dema, DemandIndex, DemarkPivots, DemarkPivotsOutput, DepthSlope, DetrendedStdDev, DistanceSsd, Doji, DojiStar, Donchian, DonchianOutput, DonchianStop, DonchianStopOutput, DoubleBollinger, DoubleBollingerOutput, DoubleTopBottom, DownsideGapThreeMethods, Dpo, DragonflyDoji, - DrawdownDuration, Dx, EaseOfMovement, EffectiveSpread, EhlersStochastic, ElderImpulse, Ema, - EmpiricalModeDecomposition, Engulfing, EveningDojiStar, Evwma, Expectancy, FallingThreeMethods, - Fama, FibArcs, FibArcsOutput, FibChannel, FibChannelOutput, FibConfluence, FibConfluenceOutput, - FibExtension, FibExtensionOutput, FibFan, FibFanOutput, FibProjection, FibProjectionOutput, - FibRetracement, FibRetracementOutput, FibTimeZones, FibTimeZonesOutput, FibonacciPivots, - FibonacciPivotsOutput, FisherTransform, FlagPennant, Footprint, FootprintOutput, ForceIndex, - FractalChaosBands, FractalChaosBandsOutput, Frama, FundingBasis, FundingRate, FundingRateMean, - FundingRateZScore, GainLossRatio, GapSideBySideWhite, GarmanKlassVolatility, Gartley, - GoldenPocket, GoldenPocketOutput, GrangerCausality, GravestoneDoji, Hammer, HangingMan, Harami, - HeadAndShoulders, HeikinAshi, HeikinAshiOutput, HiLoActivator, HighLowIndex, HighLowRange, - HighWave, Hikkake, HikkakeModified, HilbertDominantCycle, HistoricalVolatility, Hma, - HomingPigeon, HtDcPhase, HtPhasor, HtPhasorOutput, HtTrendMode, HurstChannel, - HurstChannelOutput, HurstExponent, Ichimoku, IchimokuOutput, IdenticalThreeCrows, InNeck, - Inertia, InformationRatio, InitialBalance, InitialBalanceOutput, InstantaneousTrendline, + DrawdownDuration, Dx, EaseOfMovement, EffectiveSpread, EhlersStochastic, Ehma, ElderImpulse, + Ema, EmpiricalModeDecomposition, Engulfing, EveningDojiStar, Evwma, Expectancy, + FallingThreeMethods, Fama, FibArcs, FibArcsOutput, FibChannel, FibChannelOutput, FibConfluence, + FibConfluenceOutput, FibExtension, FibExtensionOutput, FibFan, FibFanOutput, FibProjection, + FibProjectionOutput, FibRetracement, FibRetracementOutput, FibTimeZones, FibTimeZonesOutput, + FibonacciPivots, FibonacciPivotsOutput, FisherTransform, FlagPennant, Footprint, + FootprintOutput, ForceIndex, FractalChaosBands, FractalChaosBandsOutput, Frama, FundingBasis, + FundingRate, FundingRateMean, FundingRateZScore, GainLossRatio, GapSideBySideWhite, + GarmanKlassVolatility, Gartley, GeneralizedDema, GeometricMa, GoldenPocket, GoldenPocketOutput, + GrangerCausality, GravestoneDoji, Hammer, HangingMan, Harami, HeadAndShoulders, HeikinAshi, + HeikinAshiOutput, HiLoActivator, HighLowIndex, HighLowRange, HighWave, Hikkake, + HikkakeModified, HilbertDominantCycle, HistoricalVolatility, Hma, HoltWinters, HomingPigeon, + HtDcPhase, HtPhasor, HtPhasorOutput, HtTrendMode, HurstChannel, HurstChannelOutput, + HurstExponent, Ichimoku, IchimokuOutput, IdenticalThreeCrows, InNeck, Inertia, + InformationRatio, InitialBalance, InitialBalanceOutput, InstantaneousTrendline, IntradayVolatilityProfile, IntradayVolatilityProfileOutput, InverseFisherTransform, InvertedHammer, Jma, JumpIndicator, KagiBars, KalmanHedgeRatio, KalmanHedgeRatioOutput, Kama, KellyCriterion, Keltner, KeltnerOutput, Kicking, KickingByLength, Kst, KstOutput, Kurtosis, @@ -97,27 +99,28 @@ pub use indicators::{ LogReturn, LongLeggedDoji, LongLine, LongShortRatio, MaEnvelope, MaEnvelopeOutput, MacdExt, MacdFix, MacdIndicator, MacdOutput, Mama, MamaOutput, MarketFacilitationIndex, Marubozu, MassIndex, MatHold, MatchingLow, MaxDrawdown, McClellanOscillator, McClellanSummationIndex, - McGinleyDynamic, MedianAbsoluteDeviation, MedianPrice, Mfi, Microprice, MidPoint, MidPrice, - MinusDi, MinusDm, Mom, MorningDojiStar, MorningEveningStar, Natr, NewHighsNewLows, Nvi, - OIPriceDivergence, OIWeighted, Obv, OmegaRatio, OnNeck, OpenInterestDelta, OpeningMarubozu, - OpeningRange, OpeningRangeOutput, OrderBookImbalanceFull, OrderBookImbalanceTop1, - OrderBookImbalanceTopN, OrderFlowImbalance, OuHalfLife, OvernightGap, OvernightIntradayReturn, - OvernightIntradayReturnOutput, PainIndex, PairSpreadZScore, PairwiseBeta, ParkinsonVolatility, - PearsonCorrelation, PercentAboveMa, PercentB, PercentageTrailingStop, Pgo, PiercingDarkCloud, - PlusDi, PlusDm, Pmo, PointAndFigureBars, Ppo, ProfitFactor, Psar, Pvi, QuotedSpread, RSquared, - RealizedSpread, RealizedVolatility, RecoveryFactor, RectangleRange, RegimeLabel, - RelativeStrengthAB, RelativeStrengthOutput, RenkoBars, RenkoTrailingStop, RickshawMan, - RisingThreeMethods, Roc, Rocp, Rocr, Rocr100, RogersSatchellVolatility, RollMeasure, - RollingCorrelation, RollingCovariance, RollingIqr, RollingPercentileRank, RollingQuantile, - RollingVwap, RoofingFilter, Rsi, Rvi, RviVolatility, Rwi, RwiOutput, SarExt, SeasonalZScore, - SeparatingLines, SessionHighLow, SessionHighLowOutput, SessionRange, SessionRangeOutput, - SessionVwap, Shark, SharpeRatio, ShootingStar, ShortLine, SignedVolume, SineWave, Skewness, - Sma, Smi, Smma, SortinoRatio, SpearmanCorrelation, SpinningTop, SpreadAr1Coefficient, - SpreadBollingerBands, SpreadBollingerBandsOutput, SpreadHurst, StalledPattern, StandardError, - StandardErrorBands, StandardErrorBandsOutput, StarcBands, StarcBandsOutput, Stc, StdDev, - StepTrailingStop, StickSandwich, StochRsi, Stochastic, StochasticOutput, SuperSmoother, - SuperTrend, SuperTrendOutput, TakerBuySellRatio, Takuri, TasukiGap, TdCombo, TdCountdown, - TdDeMarker, TdDifferential, TdLines, TdLinesOutput, TdOpen, TdPressure, TdRangeProjection, + McGinleyDynamic, MedianAbsoluteDeviation, MedianMa, MedianPrice, Mfi, Microprice, MidPoint, + MidPrice, MinusDi, MinusDm, Mom, MorningDojiStar, MorningEveningStar, Natr, NewHighsNewLows, + Nvi, OIPriceDivergence, OIWeighted, Obv, OmegaRatio, OnNeck, OpenInterestDelta, + OpeningMarubozu, OpeningRange, OpeningRangeOutput, OrderBookImbalanceFull, + OrderBookImbalanceTop1, OrderBookImbalanceTopN, OrderFlowImbalance, OuHalfLife, OvernightGap, + OvernightIntradayReturn, OvernightIntradayReturnOutput, PainIndex, PairSpreadZScore, + PairwiseBeta, ParkinsonVolatility, PearsonCorrelation, PercentAboveMa, PercentB, + PercentageTrailingStop, Pgo, PiercingDarkCloud, PlusDi, PlusDm, Pmo, PointAndFigureBars, Ppo, + ProfitFactor, Psar, Pvi, QuotedSpread, RSquared, RealizedSpread, RealizedVolatility, + RecoveryFactor, RectangleRange, RegimeLabel, RelativeStrengthAB, RelativeStrengthOutput, + RenkoBars, RenkoTrailingStop, RickshawMan, RisingThreeMethods, Roc, Rocp, Rocr, Rocr100, + RogersSatchellVolatility, RollMeasure, RollingCorrelation, RollingCovariance, RollingIqr, + RollingPercentileRank, RollingQuantile, RollingVwap, RoofingFilter, Rsi, Rvi, RviVolatility, + Rwi, RwiOutput, SarExt, SeasonalZScore, SeparatingLines, SessionHighLow, SessionHighLowOutput, + SessionRange, SessionRangeOutput, SessionVwap, Shark, SharpeRatio, ShootingStar, ShortLine, + SignedVolume, SineWave, SineWeightedMa, Skewness, Sma, Smi, Smma, SortinoRatio, + SpearmanCorrelation, SpinningTop, SpreadAr1Coefficient, SpreadBollingerBands, + SpreadBollingerBandsOutput, SpreadHurst, StalledPattern, StandardError, StandardErrorBands, + StandardErrorBandsOutput, StarcBands, StarcBandsOutput, Stc, StdDev, StepTrailingStop, + StickSandwich, StochRsi, Stochastic, StochasticOutput, SuperSmoother, SuperTrend, + SuperTrendOutput, TakerBuySellRatio, Takuri, TasukiGap, TdCombo, TdCountdown, TdDeMarker, + TdDifferential, TdLines, TdLinesOutput, TdOpen, TdPressure, TdRangeProjection, TdRangeProjectionOutput, TdRei, TdRiskLevel, TdRiskLevelOutput, TdSequential, TdSequentialOutput, TdSetup, Tema, TermStructureBasis, ThreeDrives, ThreeInside, ThreeLineStrike, ThreeOutside, ThreeSoldiersOrCrows, ThreeStarsInSouth, Thrusting, TickIndex, diff --git a/docs/README.md b/docs/README.md index 47912449..3160347e 100644 --- a/docs/README.md +++ b/docs/README.md @@ -8,7 +8,7 @@ That includes: [Python](https://docs.wickra.org/Quickstart-Python), [Node](https://docs.wickra.org/Quickstart-Node), and [WASM](https://docs.wickra.org/Quickstart-WASM). -- A per-indicator deep dive for every one of the **396 indicators** across +- A per-indicator deep dive for every one of the **403 indicators** across the sixteen families (Moving Averages, Momentum Oscillators, Trend & Directional, Price Oscillators, Volatility & Bands, Bands & Channels, Trailing Stops, Volume, Price Statistics, Ehlers / Cycle DSP, Pivots & diff --git a/fuzz/fuzz_targets/indicator_update.rs b/fuzz/fuzz_targets/indicator_update.rs index de5e926c..a214fe3f 100644 --- a/fuzz/fuzz_targets/indicator_update.rs +++ b/fuzz/fuzz_targets/indicator_update.rs @@ -14,7 +14,7 @@ //! `Ema(20)`. This target now covers every scalar indicator in the catalogue. use libfuzzer_sys::fuzz_target; -use wickra_core::{AdaptiveCycle, Alma, AnchoredRsi, Apo, Autocorrelation, AverageDrawdown, BatchExt, Beta, BollingerBands, CalmarRatio, CenterOfGravity, Cfo, Cmo, CoefficientOfVariation, ConditionalValueAtRisk, ConnorsRsi, Coppock, CyberneticCycle, Decycler, DecyclerOscillator, Dema, DetrendedStdDev, DoubleBollinger, Dpo, DrawdownDuration, EhlersStochastic, ElderImpulse, Ema, EmpiricalModeDecomposition, Expectancy, Fama, FisherTransform, Frama, GainLossRatio, HilbertDominantCycle, HistoricalVolatility, Hma, HtDcPhase, HtPhasor, HtTrendMode, HurstExponent, Indicator, InstantaneousTrendline, InverseFisherTransform, Jma, JumpIndicator, Kama, KellyCriterion, Kst, Kurtosis, LaguerreRsi, LinRegAngle, LinRegChannel, LinRegIntercept, LinRegSlope, LinearRegression, LogReturn, MaEnvelope, MaType, MacdExt, MacdFix, MacdIndicator, Mama, MaxDrawdown, McGinleyDynamic, MedianAbsoluteDeviation, MidPoint, Mom, OmegaRatio, PainIndex, PearsonCorrelation, PercentageTrailingStop, Pmo, Ppo, ProfitFactor, RSquared, RealizedVolatility, RecoveryFactor, RegimeLabel, RenkoTrailingStop, Roc, Rocp, Rocr, Rocr100, RollingIqr, RollingPercentileRank, RollingQuantile, RoofingFilter, Rsi, RviVolatility, SharpeRatio, SineWave, Skewness, Sma, Smma, SortinoRatio, SpearmanCorrelation, StandardError, StandardErrorBands, Stc, StdDev, StepTrailingStop, StochRsi, SuperSmoother, Tema, Tii, TrendLabel, Trima, Trix, Tsf, Tsi, UlcerIndex, ValueAtRisk, Variance, VerticalHorizontalFilter, Vidya, WinRate, Wma, ZScore, ZeroLagMacd, Zlema, T3}; +use wickra_core::{AdaptiveCycle, AdaptiveLaguerreFilter, Alma, AnchoredRsi, Apo, Autocorrelation, AverageDrawdown, BatchExt, Beta, BollingerBands, CalmarRatio, CenterOfGravity, Cfo, Cmo, CoefficientOfVariation, ConditionalValueAtRisk, ConnorsRsi, Coppock, CyberneticCycle, Decycler, DecyclerOscillator, Dema, DetrendedStdDev, DoubleBollinger, Dpo, DrawdownDuration, EhlersStochastic, Ehma, ElderImpulse, Ema, EmpiricalModeDecomposition, Expectancy, Fama, FisherTransform, Frama, GainLossRatio, GeneralizedDema, GeometricMa, HilbertDominantCycle, HistoricalVolatility, Hma, HoltWinters, HtDcPhase, HtPhasor, HtTrendMode, HurstExponent, Indicator, InstantaneousTrendline, InverseFisherTransform, Jma, JumpIndicator, Kama, KellyCriterion, Kst, Kurtosis, LaguerreRsi, LinRegAngle, LinRegChannel, LinRegIntercept, LinRegSlope, LinearRegression, LogReturn, MaEnvelope, MaType, MacdExt, MacdFix, MacdIndicator, Mama, MaxDrawdown, McGinleyDynamic, MedianAbsoluteDeviation, MedianMa, MidPoint, Mom, OmegaRatio, PainIndex, PearsonCorrelation, PercentageTrailingStop, Pmo, Ppo, ProfitFactor, RSquared, RealizedVolatility, RecoveryFactor, RegimeLabel, RenkoTrailingStop, Roc, Rocp, Rocr, Rocr100, RollingIqr, RollingPercentileRank, RollingQuantile, RoofingFilter, Rsi, RviVolatility, SharpeRatio, SineWave, SineWeightedMa, Skewness, Sma, Smma, SortinoRatio, SpearmanCorrelation, StandardError, StandardErrorBands, Stc, StdDev, StepTrailingStop, StochRsi, SuperSmoother, Tema, Tii, TrendLabel, Trima, Trix, Tsf, Tsi, UlcerIndex, ValueAtRisk, Variance, VerticalHorizontalFilter, Vidya, WinRate, Wma, ZScore, ZeroLagMacd, Zlema, T3}; /// Drive a single streaming + batch run through one scalar indicator. Marked /// `#[inline(never)]` so a panic backtrace pin-points the specific indicator. @@ -43,6 +43,13 @@ fuzz_target!(|data: Vec| { drive(|| Dema::new(14).unwrap(), &data); drive(|| Tema::new(14).unwrap(), &data); drive(|| Hma::new(14).unwrap(), &data); + drive(|| SineWeightedMa::new(14).unwrap(), &data); + drive(|| GeometricMa::new(14).unwrap(), &data); + drive(|| Ehma::new(9).unwrap(), &data); + drive(|| MedianMa::new(14).unwrap(), &data); + drive(|| AdaptiveLaguerreFilter::new(13).unwrap(), &data); + drive(|| GeneralizedDema::new(5, 0.7).unwrap(), &data); + drive(|| HoltWinters::new(0.2, 0.1).unwrap(), &data); drive(|| Roc::new(14).unwrap(), &data); drive(|| Rocp::new(14).unwrap(), &data); drive(|| Rocr::new(14).unwrap(), &data);