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 @@
-
+
[](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);