From 389200f8552ea6deaf1b22c390f69b2dc533e1ea Mon Sep 17 00:00:00 2001 From: kingchenc Date: Sun, 7 Jun 2026 03:08:53 +0200 Subject: [PATCH] Add B9 Price Statistics deepening (5 indicators) (#197) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Deepens the **Price Statistics** family (B9) with five rolling-statistics indicators (447 -> 452): - **ShannonEntropy** — Shannon entropy of a binned rolling value distribution. - **SampleEntropy** — Richman-Moorman sample entropy (regularity/complexity of a window). - **KendallTau** — Kendall rank correlation (tau-b) over paired observations (pairwise; distinct from Pearson/Spearman). - **JarqueBera** — Jarque-Bera normality test statistic over a rolling window. - **RollingMinMaxScaler** — maps the latest value to 0..1 over a rolling window. All scalar f64 input except KendallTau (pairwise). Multi-arg scalars (Shannon/Sample entropy) use hand-written Python/Node bindings + the variadic wasm macro; KendallTau uses the pair macros. Verified locally: 3668 core lib + 410 doc tests, clippy clean, 527 node tests, 871 pytest, counter 452. --- CHANGELOG.md | 5 + README.md | 12 +- bindings/node/__tests__/indicators.test.js | 5 + bindings/node/index.d.ts | 49 +++ bindings/node/index.js | 7 +- bindings/node/src/lib.rs | 81 +++++ bindings/python/python/wickra/__init__.py | 10 + bindings/python/src/lib.rs | 271 ++++++++++++++ bindings/python/tests/test_new_indicators.py | 9 + bindings/wasm/src/lib.rs | 5 + .../wickra-core/src/indicators/jarque_bera.rs | 263 ++++++++++++++ .../wickra-core/src/indicators/kendall_tau.rs | 296 +++++++++++++++ crates/wickra-core/src/indicators/mod.rs | 17 +- .../src/indicators/rolling_min_max_scaler.rs | 261 ++++++++++++++ .../src/indicators/sample_entropy.rs | 337 ++++++++++++++++++ .../src/indicators/shannon_entropy.rs | 261 ++++++++++++++ crates/wickra-core/src/lib.rs | 65 ++-- docs/README.md | 2 +- fuzz/fuzz_targets/indicator_update.rs | 6 +- fuzz/fuzz_targets/indicator_update_pair.rs | 3 +- 20 files changed, 1922 insertions(+), 43 deletions(-) create mode 100644 crates/wickra-core/src/indicators/jarque_bera.rs create mode 100644 crates/wickra-core/src/indicators/kendall_tau.rs create mode 100644 crates/wickra-core/src/indicators/rolling_min_max_scaler.rs create mode 100644 crates/wickra-core/src/indicators/sample_entropy.rs create mode 100644 crates/wickra-core/src/indicators/shannon_entropy.rs diff --git a/CHANGELOG.md b/CHANGELOG.md index 55ecbd21..72dd055d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,11 @@ 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] +- **Kendall Tau** — Kendall rank correlation (tau-b) over a rolling window of paired observations (`KENDALLTAU`). +- **Sample Entropy** — Sample entropy: regularity/complexity of a rolling series (Richman-Moorman) (`SAMPLEENT`). +- **Shannon Entropy** — Shannon entropy of a rolling value distribution over fixed bins (`SHANNONENT`). +- **Rolling Min-Max Scaler** — Rolling min-max scaler mapping the latest value to 0..1 over a rolling window (`ROLLINGMINMAX`). +- **Jarque-Bera** — Jarque-Bera normality test statistic over a rolling window (`JARQUEBERA`). ## [0.6.3] - 2026-06-07 - **Volume-Weighted MACD** — Volume-Weighted MACD: MACD computed on VWMA instead of EMA, with signal line and histogram (`VWMACD`). diff --git a/README.md b/README.md index 7e0042a4..eb15ce55 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,5 @@

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

[![CI](https://github.com/wickra-lib/wickra/actions/workflows/ci.yml/badge.svg)](https://github.com/wickra-lib/wickra/actions/workflows/ci.yml) @@ -48,7 +48,7 @@ Full documentation lives at **[docs.wickra.org](https://docs.wickra.org)**: [Node](https://docs.wickra.org/Quickstart-Node), [WASM](https://docs.wickra.org/Quickstart-WASM). - **Indicators** — a per-indicator deep dive (formula, parameters, warmup) for - every one of the 447 indicators; start at the + every one of the 452 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), @@ -79,7 +79,7 @@ Plenty of TA libraries are fast. Each one forces a trade-off Wickra does not: | finta | clean | no | Python | ~80 | stale | | talipp | clean | yes | Python | ~40 | yes | -Wickra's edge is **breadth with reach**: 447 indicators that all update in O(1) +Wickra's edge is **breadth with reach**: 452 indicators that all update in O(1) per tick and ship natively to Python, Node.js, WebAssembly and Rust from a single engine. @@ -188,7 +188,7 @@ python -m benchmarks.compare_libraries ## Indicators -447 streaming-first indicators across twenty-four families. Every one passes the +452 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). @@ -203,7 +203,7 @@ warmup) at [docs.wickra.org](https://docs.wickra.org/Indicators-Overview). | Bands & Channels | MA Envelope, Acceleration Bands, STARC Bands, ATR Bands, Hurst Channel, LinReg Channel, Standard Error Bands, Double Bollinger Bands, TTM Squeeze, Fractal Chaos Bands, VWAP StdDev Bands, Quartile Bands, Bomar Bands, Median Channel, Projection Bands, Projection Oscillator | | Trailing Stops | Parabolic SAR, Parabolic SAR Extended (SAREXT), SuperTrend, Chandelier Exit, Chande Kroll Stop, ATR Trailing Stop, HiLo Activator, Volty Stop, Yo-Yo Exit, Donchian Channel Stop, Percentage Trailing Stop, Step Trailing Stop, Renko Trailing Stop, Kase DevStop, Elder SafeZone, ATR Ratchet, NRTR, Time-Based Stop, Modified MA Stop | | Volume | OBV, VWAP (cumulative + rolling), ADL, Volume-Price Trend, Chaikin Money Flow, Chaikin Oscillator, Force Index, Ease of Movement, Klinger Volume Oscillator, Volume Oscillator, NVI, PVI, Williams A/D, Anchored VWAP, Demand Index, TSV, VZO, Market Facilitation Index, Volume RSI, Williams Accumulation/Distribution, Twiggs Money Flow, Trade Volume Index, Intraday Intensity Index, Better Volume, Volume-Weighted MACD | -| Price Statistics | Typical Price, Median Price, Weighted Close, Linear Regression, Linear Regression Slope, Z-Score, Linear Regression Angle, Variance, Coefficient of Variation, Skewness, Kurtosis, Standard Error, Detrended StdDev, R², Median Absolute Deviation, Autocorrelation, Hurst Exponent, Pearson Correlation, Beta, Pairwise Beta, Pair Spread Z-Score, Lead-Lag Cross-Correlation, Cointegration, Relative Strength A-vs-B, Spearman Correlation, Mid Price, Mid Point, Average Price, Linear Regression Intercept, Time Series Forecast, Rolling Correlation, Rolling Covariance, OU Half-Life, Spread Hurst, Distance SSD, Beta-Neutral Spread, Variance Ratio, Granger Causality, Kalman Hedge Ratio, Spread Bollinger Bands, Spread AR(1) Coefficient | +| Price Statistics | Typical Price, Median Price, Weighted Close, Linear Regression, Linear Regression Slope, Z-Score, Linear Regression Angle, Variance, Coefficient of Variation, Skewness, Kurtosis, Standard Error, Detrended StdDev, R², Median Absolute Deviation, Autocorrelation, Hurst Exponent, Pearson Correlation, Beta, Pairwise Beta, Pair Spread Z-Score, Lead-Lag Cross-Correlation, Cointegration, Relative Strength A-vs-B, Spearman Correlation, Mid Price, Mid Point, Average Price, Linear Regression Intercept, Time Series Forecast, Rolling Correlation, Rolling Covariance, OU Half-Life, Spread Hurst, Distance SSD, Beta-Neutral Spread, Variance Ratio, Granger Causality, Kalman Hedge Ratio, Spread Bollinger Bands, Spread AR(1) Coefficient, Jarque-Bera, Rolling Min-Max Scaler, Shannon Entropy, Sample Entropy, Kendall Tau | | Ehlers / Cycle (DSP) | MAMA, FAMA, Fisher Transform, Inverse Fisher Transform, SuperSmoother, Hilbert Dominant Cycle, Hilbert Phasor, Hilbert DC Phase, Hilbert Trend Mode, Sine Wave, Decycler, Decycler Oscillator, Roofing Filter, Center of Gravity, Cybernetic Cycle, Adaptive Cycle, Empirical Mode Decomposition, Ehlers Stochastic, Instantaneous Trendline | | Pivots & S/R | Classic Pivots, Fibonacci Pivots, Camarilla, Woodie Pivots, DeMark Pivots, Williams Fractals, ZigZag | | DeMark | TD Setup, TD Sequential, TD DeMarker, TD REI, TD Pressure, TD Combo, TD Countdown, TD Lines, TD Range Projection, TD Differential, TD Open, TD Risk Level | @@ -297,7 +297,7 @@ A Python live-trading example using the public `websockets` package lives at ``` wickra/ ├── crates/ -│ ├── wickra-core/ core engine + all 447 indicators +│ ├── wickra-core/ core engine + all 452 indicators │ ├── wickra/ top-level facade crate (publishes on crates.io) + benches/ │ ├── wickra-data/ CSV reader, tick aggregator, live exchange feeds │ └── wickra-bench/ internal cross-library benchmark harness (not published) diff --git a/bindings/node/__tests__/indicators.test.js b/bindings/node/__tests__/indicators.test.js index 9759d541..0e393afc 100644 --- a/bindings/node/__tests__/indicators.test.js +++ b/bindings/node/__tests__/indicators.test.js @@ -28,6 +28,10 @@ function num(v) { // --- Scalar indicators: update(value) vs batch(prices) --- const scalarFactories = { + SAMPLEENT: () => new wickra.SAMPLEENT(20, 2, 0.2), + SHANNONENT: () => new wickra.SHANNONENT(20, 8), + ROLLINGMINMAX: () => new wickra.ROLLINGMINMAX(20), + JARQUEBERA: () => new wickra.JARQUEBERA(20), BipowerVariation: () => new wickra.BipowerVariation(20), VolatilityOfVolatility: () => new wickra.VolatilityOfVolatility(20, 20), Garch11: () => new wickra.Garch11(0.000002, 0.1, 0.88), @@ -629,6 +633,7 @@ const pairFactories = { VarianceRatio: () => new wickra.VarianceRatio(60, 2), GrangerCausality: () => new wickra.GrangerCausality(60, 1), SpreadAr1Coefficient: () => new wickra.SpreadAr1Coefficient(40), + KendallTau: () => new wickra.KendallTau(20), }; for (const [name, make] of Object.entries(pairFactories)) { diff --git a/bindings/node/index.d.ts b/bindings/node/index.d.ts index efbf982c..5e8ff70d 100644 --- a/bindings/node/index.d.ts +++ b/bindings/node/index.d.ts @@ -1052,6 +1052,42 @@ export declare class BipowerVariation { isReady(): boolean warmupPeriod(): number } +export type JarqueBeraNode = JARQUEBERA +export declare class JARQUEBERA { + constructor(period: number) + update(value: number): number | null + batch(prices: Array): Array + reset(): void + isReady(): boolean + warmupPeriod(): number +} +export type RollingMinMaxScalerNode = ROLLINGMINMAX +export declare class ROLLINGMINMAX { + constructor(period: number) + update(value: number): number | null + batch(prices: Array): Array + reset(): void + isReady(): boolean + warmupPeriod(): number +} +export type ShannonEntropyNode = SHANNONENT +export declare class SHANNONENT { + constructor(period: number, bins: number) + update(value: number): number | null + batch(prices: Array): Array + reset(): void + isReady(): boolean + warmupPeriod(): number +} +export type SampleEntropyNode = SAMPLEENT +export declare class SAMPLEENT { + constructor(period: number, m: number, rFactor: number) + update(value: number): number | null + batch(prices: Array): Array + reset(): void + isReady(): boolean + warmupPeriod(): number +} export type EwmaVolatilityNode = EwmaVolatility export declare class EwmaVolatility { constructor(lambda: number) @@ -1263,6 +1299,19 @@ export declare class DistanceSsd { isReady(): boolean warmupPeriod(): number } +export type KendallTauNode = KendallTau +export declare class KendallTau { + constructor(period: number) + update(x: number, y: number): number | null + /** + * Batch over two equally-sized arrays. Returns a length-`n` array + * with `NaN` for warmup positions. + */ + batch(x: Array, y: Array): Array + reset(): void + isReady(): boolean + warmupPeriod(): number +} export type BetaNeutralSpreadNode = BetaNeutralSpread export declare class BetaNeutralSpread { constructor(period: number) diff --git a/bindings/node/index.js b/bindings/node/index.js index 03b978a9..4a681381 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, SWMA, GMA, EHMA, MedianMA, AdaptiveLaguerre, DisparityIndex, FisherRSI, RSX, DynamicMomentumIndex, TREND_STRENGTH_INDEX, TsfOscillator, BipowerVariation, EwmaVolatility, Garch11, VolatilityOfVolatility, VolatilityCone, 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, StochasticCCI, IMI, QQE, ElderRay, TTM_TREND, Qstick, POLARIZED_FRACTAL_EFFICIENCY, WAVE_PM, GatorOscillator, KasePermissionStochastic, VolatilityRatio, ProjectionOscillator, TimeBasedStop, 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, RMI, DerivativeOscillator, MacdHistogram, PpoHistogram, 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, KaseDevStop, ElderSafeZone, AtrRatchet, Nrtr, ModifiedMaStop, 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, QuartileBands, BomarBands, MedianChannel, ProjectionBands, 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, VolumeRsi, Wad, TwiggsMoneyFlow, TradeVolumeIndex, IntradayIntensity, BetterVolume, VolumeWeightedMacd } = 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, DisparityIndex, FisherRSI, RSX, DynamicMomentumIndex, TREND_STRENGTH_INDEX, TsfOscillator, BipowerVariation, JARQUEBERA, ROLLINGMINMAX, SHANNONENT, SAMPLEENT, EwmaVolatility, Garch11, VolatilityOfVolatility, VolatilityCone, JumpIndicator, RegimeLabel, RollingQuantile, Autocorrelation, HurstExponent, PearsonCorrelation, Beta, PairwiseBeta, SpreadAr1Coefficient, SpearmanCorrelation, RollingCorrelation, RollingCovariance, OuHalfLife, SpreadHurst, DistanceSsd, KendallTau, 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, StochasticCCI, IMI, QQE, ElderRay, TTM_TREND, Qstick, POLARIZED_FRACTAL_EFFICIENCY, WAVE_PM, GatorOscillator, KasePermissionStochastic, VolatilityRatio, ProjectionOscillator, TimeBasedStop, 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, RMI, DerivativeOscillator, MacdHistogram, PpoHistogram, 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, KaseDevStop, ElderSafeZone, AtrRatchet, Nrtr, ModifiedMaStop, 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, QuartileBands, BomarBands, MedianChannel, ProjectionBands, 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, VolumeRsi, Wad, TwiggsMoneyFlow, TradeVolumeIndex, IntradayIntensity, BetterVolume, VolumeWeightedMacd } = nativeBinding module.exports.version = version module.exports.SMA = SMA @@ -375,6 +375,10 @@ module.exports.DynamicMomentumIndex = DynamicMomentumIndex module.exports.TREND_STRENGTH_INDEX = TREND_STRENGTH_INDEX module.exports.TsfOscillator = TsfOscillator module.exports.BipowerVariation = BipowerVariation +module.exports.JARQUEBERA = JARQUEBERA +module.exports.ROLLINGMINMAX = ROLLINGMINMAX +module.exports.SHANNONENT = SHANNONENT +module.exports.SAMPLEENT = SAMPLEENT module.exports.EwmaVolatility = EwmaVolatility module.exports.Garch11 = Garch11 module.exports.VolatilityOfVolatility = VolatilityOfVolatility @@ -394,6 +398,7 @@ module.exports.RollingCovariance = RollingCovariance module.exports.OuHalfLife = OuHalfLife module.exports.SpreadHurst = SpreadHurst module.exports.DistanceSsd = DistanceSsd +module.exports.KendallTau = KendallTau module.exports.BetaNeutralSpread = BetaNeutralSpread module.exports.PairSpreadZScore = PairSpreadZScore module.exports.LeadLagCrossCorrelation = LeadLagCrossCorrelation diff --git a/bindings/node/src/lib.rs b/bindings/node/src/lib.rs index 837cc0f1..b723163e 100644 --- a/bindings/node/src/lib.rs +++ b/bindings/node/src/lib.rs @@ -225,6 +225,86 @@ node_scalar_indicator!( "BipowerVariation", wc::BipowerVariation ); +node_scalar_indicator!(JarqueBeraNode, "JARQUEBERA", wc::JarqueBera); +node_scalar_indicator!( + RollingMinMaxScalerNode, + "ROLLINGMINMAX", + wc::RollingMinMaxScaler +); + +// Shannon Entropy / Sample Entropy: multi-arg scalar ctors, hand-written +// (node_scalar_indicator! only generates a single-period constructor). + +#[napi(js_name = "SHANNONENT")] +pub struct ShannonEntropyNode { + inner: wc::ShannonEntropy, +} + +#[napi] +impl ShannonEntropyNode { + #[napi(constructor)] + pub fn new(period: u32, bins: u32) -> napi::Result { + Ok(Self { + inner: wc::ShannonEntropy::new(period as usize, bins as usize).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 + } +} + +#[napi(js_name = "SAMPLEENT")] +pub struct SampleEntropyNode { + inner: wc::SampleEntropy, +} + +#[napi] +impl SampleEntropyNode { + #[napi(constructor)] + pub fn new(period: u32, m: u32, r_factor: f64) -> napi::Result { + Ok(Self { + inner: wc::SampleEntropy::new(period as usize, m as usize, r_factor) + .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 + } +} #[napi(js_name = "EwmaVolatility")] pub struct EwmaVolatilityNode { @@ -675,6 +755,7 @@ node_pair_indicator!( node_pair_indicator!(OuHalfLifeNode, "OuHalfLife", wc::OuHalfLife); node_pair_indicator!(SpreadHurstNode, "SpreadHurst", wc::SpreadHurst); node_pair_indicator!(DistanceSsdNode, "DistanceSsd", wc::DistanceSsd); +node_pair_indicator!(KendallTauNode, "KendallTau", wc::KendallTau); node_pair_indicator!( BetaNeutralSpreadNode, "BetaNeutralSpread", diff --git a/bindings/python/python/wickra/__init__.py b/bindings/python/python/wickra/__init__.py index a17aed38..d150c6d9 100644 --- a/bindings/python/python/wickra/__init__.py +++ b/bindings/python/python/wickra/__init__.py @@ -25,6 +25,10 @@ from __future__ import annotations from ._wickra import ( __version__, + SAMPLEENT, + SHANNONENT, + ROLLINGMINMAX, + JARQUEBERA, TimeBasedStop, ProjectionOscillator, VolatilityCone, @@ -221,6 +225,7 @@ from ._wickra import ( MarketFacilitationIndex, EaseOfMovement, # Statistics + KendallTau, SpreadBollingerBands, KalmanHedgeRatio, GrangerCausality, @@ -501,6 +506,10 @@ from ._wickra import ( ) __all__ = [ + "SAMPLEENT", + "SHANNONENT", + "ROLLINGMINMAX", + "JARQUEBERA", "TimeBasedStop", "ProjectionOscillator", "VolatilityCone", @@ -698,6 +707,7 @@ __all__ = [ "MarketFacilitationIndex", "EaseOfMovement", # Statistics + "KendallTau", "SpreadBollingerBands", "KalmanHedgeRatio", "GrangerCausality", diff --git a/bindings/python/src/lib.rs b/bindings/python/src/lib.rs index 02a3a277..feca41f4 100644 --- a/bindings/python/src/lib.rs +++ b/bindings/python/src/lib.rs @@ -3700,6 +3700,102 @@ impl PyTimeBasedStop { } } +// ============================== JarqueBera ============================== + +#[pyclass(name = "JARQUEBERA", module = "wickra._wickra", skip_from_py_object)] +#[derive(Clone)] +struct PyJarqueBera { + inner: wc::JarqueBera, +} + +#[pymethods] +impl PyJarqueBera { + #[new] + #[pyo3(signature = (period=20))] + fn new(period: usize) -> PyResult { + Ok(Self { + inner: wc::JarqueBera::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!("JARQUEBERA(period={})", self.inner.period()) + } +} + +// ============================== RollingMinMaxScaler ============================== + +#[pyclass(name = "ROLLINGMINMAX", module = "wickra._wickra", skip_from_py_object)] +#[derive(Clone)] +struct PyRollingMinMaxScaler { + inner: wc::RollingMinMaxScaler, +} + +#[pymethods] +impl PyRollingMinMaxScaler { + #[new] + #[pyo3(signature = (period=20))] + fn new(period: usize) -> PyResult { + Ok(Self { + inner: wc::RollingMinMaxScaler::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!("ROLLINGMINMAX(period={})", self.inner.period()) + } +} + // ============================== Stochastic ============================== #[pyclass(name = "IMI", module = "wickra._wickra", skip_from_py_object)] @@ -22735,6 +22831,176 @@ impl PyVolumeWeightedMacd { } } +// ============================== Shannon Entropy ============================== + +#[pyclass(name = "SHANNONENT", module = "wickra._wickra", skip_from_py_object)] +#[derive(Clone)] +struct PyShannonEntropy { + inner: wc::ShannonEntropy, +} + +#[pymethods] +impl PyShannonEntropy { + #[new] + #[pyo3(signature = (period=20, bins=8))] + fn new(period: usize, bins: usize) -> PyResult { + Ok(Self { + inner: wc::ShannonEntropy::new(period, bins).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 params(&self) -> (usize, usize) { + self.inner.params() + } + #[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 { + let (period, bins) = self.inner.params(); + format!("SHANNONENT(period={period}, bins={bins})") + } +} + +// ============================== Sample Entropy ============================== + +#[pyclass(name = "SAMPLEENT", module = "wickra._wickra", skip_from_py_object)] +#[derive(Clone)] +struct PySampleEntropy { + inner: wc::SampleEntropy, +} + +#[pymethods] +impl PySampleEntropy { + #[new] + #[pyo3(signature = (period=20, m=2, r_factor=0.2))] + fn new(period: usize, m: usize, r_factor: f64) -> PyResult { + Ok(Self { + inner: wc::SampleEntropy::new(period, m, r_factor).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 params(&self) -> (usize, usize, f64) { + self.inner.params() + } + #[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 { + let (period, m, r_factor) = self.inner.params(); + format!("SAMPLEENT(period={period}, m={m}, r_factor={r_factor})") + } +} + +// ============================== Kendall Tau ============================== + +#[pyclass(name = "KendallTau", module = "wickra._wickra", skip_from_py_object)] +#[derive(Clone)] +struct PyKendallTau { + inner: wc::KendallTau, +} + +#[pymethods] +impl PyKendallTau { + #[new] + #[pyo3(signature = (period=20))] + fn new(period: usize) -> PyResult { + Ok(Self { + inner: wc::KendallTau::new(period).map_err(map_err)?, + }) + } + fn update(&mut self, x: f64, y: f64) -> Option { + self.inner.update((x, y)) + } + /// Batch over two equally-sized numpy arrays. + fn batch<'py>( + &mut self, + py: Python<'py>, + x: PyReadonlyArray1<'py, f64>, + y: PyReadonlyArray1<'py, f64>, + ) -> PyResult>> { + let xs = x + .as_slice() + .map_err(|_| PyValueError::new_err(NON_CONTIGUOUS))?; + let ys = y + .as_slice() + .map_err(|_| PyValueError::new_err(NON_CONTIGUOUS))?; + if xs.len() != ys.len() { + return Err(PyValueError::new_err("x and y must be equal length")); + } + let mut out = Vec::with_capacity(xs.len()); + for i in 0..xs.len() { + out.push(self.inner.update((xs[i], ys[i])).unwrap_or(f64::NAN)); + } + Ok(out.into_pyarray(py)) + } + #[getter] + fn period(&self) -> usize { + self.inner.period() + } + #[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!("KendallTau(period={})", self.inner.period()) + } +} + #[pymodule] #[allow(clippy::too_many_lines)] fn _wickra(_py: Python<'_>, m: &Bound<'_, PyModule>) -> PyResult<()> { @@ -23198,5 +23464,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 5daca0f4..eb7f0287 100644 --- a/bindings/python/tests/test_new_indicators.py +++ b/bindings/python/tests/test_new_indicators.py @@ -45,6 +45,10 @@ def ohlcv() -> tuple[np.ndarray, np.ndarray, np.ndarray, np.ndarray]: # --- Scalar (f64 -> f64) indicators --------------------------------------- SCALAR = [ + (ta.SAMPLEENT, (20, 2, 0.2)), + (ta.SHANNONENT, (20, 8)), + (ta.ROLLINGMINMAX, (20,)), + (ta.JARQUEBERA, (20,)), (ta.BipowerVariation, (20,)), (ta.VolatilityOfVolatility, (20, 20)), (ta.Garch11, (0.000002, 0.1, 0.88)), @@ -204,6 +208,7 @@ def test_scalar_streaming_matches_batch(cls, args, sine_prices): # --- Two-series (asset, benchmark) indicators ----------------------------- PAIR = [ + (ta.KendallTau, (20,)), (ta.SpreadAr1Coefficient, (40,)), (ta.GrangerCausality, (60, 1)), (ta.VarianceRatio, (60, 2)), @@ -3093,6 +3098,10 @@ def test_better_volume_reference(): def test_volume_weighted_macd_reference(): t = ta.VolumeWeightedMacd(12, 26, 9) + +def test_kendall_tau_reference(): + t = ta.KendallTau(20) + # --- Lifecycle ------------------------------------------------------------ diff --git a/bindings/wasm/src/lib.rs b/bindings/wasm/src/lib.rs index 0abee086..2c0e0ff5 100644 --- a/bindings/wasm/src/lib.rs +++ b/bindings/wasm/src/lib.rs @@ -557,6 +557,7 @@ wasm_pair_indicator!( wasm_pair_indicator!(WasmOuHalfLife, "OuHalfLife", wc::OuHalfLife); wasm_pair_indicator!(WasmSpreadHurst, "SpreadHurst", wc::SpreadHurst); wasm_pair_indicator!(WasmDistanceSsd, "DistanceSsd", wc::DistanceSsd); +wasm_pair_indicator!(WasmKendallTau, "KendallTau", wc::KendallTau); wasm_pair_indicator!( WasmBetaNeutralSpread, "BetaNeutralSpread", @@ -11256,6 +11257,10 @@ wasm_scalar_indicator!(WasmBipowerVariation, "BipowerVariation", wc::BipowerVari wasm_scalar_indicator!(WasmEwmaVolatility, "EwmaVolatility", wc::EwmaVolatility, lambda: f64); wasm_scalar_indicator!(WasmGarch11, "Garch11", wc::Garch11, omega: f64, alpha: f64, beta: f64); wasm_scalar_indicator!(WasmVolatilityOfVolatility, "VolatilityOfVolatility", wc::VolatilityOfVolatility, vol_window: usize, vov_window: usize); +wasm_scalar_indicator!(WasmJarqueBera, "JARQUEBERA", wc::JarqueBera, period: usize); +wasm_scalar_indicator!(WasmRollingMinMaxScaler, "ROLLINGMINMAX", wc::RollingMinMaxScaler, period: usize); +wasm_scalar_indicator!(WasmShannonEntropy, "SHANNONENT", wc::ShannonEntropy, period: usize, bins: usize); +wasm_scalar_indicator!(WasmSampleEntropy, "SAMPLEENT", wc::SampleEntropy, period: usize, m: usize, r_factor: f64); // --- VolatilityCone: Candle in, struct out (current/min/median/max/percentile) --- diff --git a/crates/wickra-core/src/indicators/jarque_bera.rs b/crates/wickra-core/src/indicators/jarque_bera.rs new file mode 100644 index 00000000..97647b9c --- /dev/null +++ b/crates/wickra-core/src/indicators/jarque_bera.rs @@ -0,0 +1,263 @@ +//! Jarque-Bera — a normality-test statistic on a rolling window. + +use std::collections::VecDeque; + +use crate::error::{Error, Result}; +use crate::traits::Indicator; + +/// Jarque-Bera — the Jarque-Bera test statistic measuring how far a window's +/// distribution departs from normal, via its **skewness** and **excess +/// kurtosis**. +/// +/// ```text +/// S = skewness = m3 / m2^(3/2) +/// K = excess kurtosis = m4 / m2² − 3 +/// JB = (period / 6) · ( S² + K²/4 ) +/// ``` +/// +/// where `m2`, `m3`, `m4` are the second, third and fourth central moments of the +/// window. A perfectly normal sample has zero skew and zero excess kurtosis, so +/// `JB = 0`; the statistic grows as the distribution becomes asymmetric (non-zero +/// skew) or fat- or thin-tailed (non-zero excess kurtosis). Under the null of +/// normality `JB` is asymptotically χ² with two degrees of freedom, so values +/// above roughly `6` reject normality at the 95% level — a useful streaming flag +/// for fat-tail / crash-risk regimes in a return series. +/// +/// The statistic is `≥ 0`. A degenerate window with zero variance (`m2 == 0`) +/// returns `0`. The first value lands after `period` inputs; each `update` +/// recomputes the four moments over the window in O(`period`). +/// +/// # Example +/// +/// ``` +/// use wickra_core::{Indicator, JarqueBera}; +/// +/// let mut indicator = JarqueBera::new(50).unwrap(); +/// let mut last = None; +/// for i in 0..80 { +/// last = indicator.update((f64::from(i) * 0.3).sin()); +/// } +/// assert!(last.is_some()); +/// ``` +#[derive(Debug, Clone)] +pub struct JarqueBera { + period: usize, + window: VecDeque, + last: Option, +} + +impl JarqueBera { + /// Construct a rolling Jarque-Bera over `period` values. + /// + /// # Errors + /// + /// Returns [`Error::PeriodZero`] if `period == 0` and + /// [`Error::InvalidPeriod`] if `period < 4` (the statistic is degenerate on + /// fewer than four points). + pub fn new(period: usize) -> Result { + if period == 0 { + return Err(Error::PeriodZero); + } + if period < 4 { + return Err(Error::InvalidPeriod { + message: "Jarque-Bera needs period >= 4", + }); + } + Ok(Self { + period, + window: VecDeque::with_capacity(period), + last: None, + }) + } + + /// Configured window length. + pub const fn period(&self) -> usize { + self.period + } + + /// Current value if available. + pub const fn value(&self) -> Option { + self.last + } + + fn compute(&self) -> f64 { + let n = self.period as f64; + let mean = self.window.iter().sum::() / n; + let mut m2 = 0.0; + let mut m3 = 0.0; + let mut m4 = 0.0; + for &v in &self.window { + let d = v - mean; + let d2 = d * d; + m2 += d2; + m3 += d2 * d; + m4 += d2 * d2; + } + m2 /= n; + m3 /= n; + m4 /= n; + if m2 == 0.0 { + return 0.0; + } + let skew = m3 / m2.powf(1.5); + let excess_kurt = m4 / (m2 * m2) - 3.0; + (n / 6.0) * (skew * skew + excess_kurt * excess_kurt / 4.0) + } +} + +impl Indicator for JarqueBera { + type Input = f64; + type Output = f64; + + fn update(&mut self, input: f64) -> Option { + if !input.is_finite() { + return self.last; + } + if self.window.len() == self.period { + self.window.pop_front(); + } + self.window.push_back(input); + if self.window.len() < self.period { + return None; + } + let out = self.compute(); + self.last = Some(out); + Some(out) + } + + fn reset(&mut self) { + self.window.clear(); + self.last = None; + } + + fn warmup_period(&self) -> usize { + self.period + } + + fn is_ready(&self) -> bool { + self.last.is_some() + } + + fn name(&self) -> &'static str { + "JarqueBera" + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::traits::BatchExt; + use approx::assert_relative_eq; + + #[test] + fn rejects_invalid_period() { + assert!(matches!(JarqueBera::new(0), Err(Error::PeriodZero))); + assert!(matches!( + JarqueBera::new(3), + Err(Error::InvalidPeriod { .. }) + )); + assert!(JarqueBera::new(4).is_ok()); + } + + #[test] + fn accessors_and_metadata() { + let jb = JarqueBera::new(50).unwrap(); + assert_eq!(jb.period(), 50); + assert_eq!(jb.warmup_period(), 50); + assert_eq!(jb.name(), "JarqueBera"); + assert!(!jb.is_ready()); + assert_eq!(jb.value(), None); + } + + #[test] + fn first_emission_at_warmup_period() { + let mut jb = JarqueBera::new(4).unwrap(); + let out = jb.batch(&[1.0, 2.0, 3.0, 4.0, 5.0]); + for v in out.iter().take(3) { + assert!(v.is_none()); + } + assert!(out[3].is_some()); + } + + #[test] + fn constant_window_is_zero() { + let mut jb = JarqueBera::new(8).unwrap(); + let last = jb.batch(&[5.0; 12]).into_iter().flatten().last().unwrap(); + assert_relative_eq!(last, 0.0, epsilon = 1e-12); + } + + #[test] + fn output_is_non_negative() { + let mut jb = JarqueBera::new(30).unwrap(); + for v in jb + .batch( + &(0..200) + .map(|i| (f64::from(i) * 0.3).sin() * 5.0) + .collect::>(), + ) + .into_iter() + .flatten() + { + assert!(v >= 0.0, "JB must be non-negative, got {v}"); + } + } + + #[test] + fn skewed_window_exceeds_symmetric() { + // A symmetric window vs. one with a heavy outlier (high skew + kurtosis). + let symmetric: Vec = vec![-3.0, -1.0, 0.0, 1.0, 3.0, -2.0, 2.0, 0.0]; + let skewed: Vec = vec![0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 20.0]; + let jb_sym = JarqueBera::new(8) + .unwrap() + .batch(&symmetric) + .into_iter() + .flatten() + .last() + .unwrap(); + let jb_skew = JarqueBera::new(8) + .unwrap() + .batch(&skewed) + .into_iter() + .flatten() + .last() + .unwrap(); + assert!( + jb_skew > jb_sym, + "skewed ({jb_skew}) should exceed symmetric ({jb_sym})" + ); + } + + #[test] + fn ignores_non_finite() { + let mut jb = JarqueBera::new(4).unwrap(); + let ready = jb + .batch(&[1.0, 2.0, 3.0, 5.0]) + .into_iter() + .flatten() + .last() + .unwrap(); + assert_eq!(jb.update(f64::NAN), Some(ready)); + } + + #[test] + fn reset_clears_state() { + let mut jb = JarqueBera::new(4).unwrap(); + jb.batch(&[1.0, 2.0, 3.0, 5.0]); + assert!(jb.is_ready()); + jb.reset(); + assert!(!jb.is_ready()); + assert_eq!(jb.value(), None); + assert_eq!(jb.update(1.0), None); + } + + #[test] + fn batch_equals_streaming() { + let xs: Vec = (0..120) + .map(|i| (f64::from(i) * 0.25).sin() * 9.0) + .collect(); + let batch = JarqueBera::new(30).unwrap().batch(&xs); + let mut b = JarqueBera::new(30).unwrap(); + let streamed: Vec<_> = xs.iter().map(|x| b.update(*x)).collect(); + assert_eq!(batch, streamed); + } +} diff --git a/crates/wickra-core/src/indicators/kendall_tau.rs b/crates/wickra-core/src/indicators/kendall_tau.rs new file mode 100644 index 00000000..ccf4dc2f --- /dev/null +++ b/crates/wickra-core/src/indicators/kendall_tau.rs @@ -0,0 +1,296 @@ +//! Kendall's tau-b — rank correlation by concordant vs. discordant pairs. + +use std::collections::VecDeque; + +use crate::error::{Error, Result}; +use crate::traits::Indicator; + +/// `+1` / `0` / `-1` sign of `a − b`. +fn sign(a: f64, b: f64) -> i32 { + if a > b { + 1 + } else if a < b { + -1 + } else { + 0 + } +} + +/// Kendall's tau-b — a rank correlation between two synchronised series based on +/// the balance of **concordant** and **discordant** pairs, with a tie correction. +/// +/// ```text +/// over all pairs (i < j) in the window: +/// concordant if (x_j − x_i) and (y_j − y_i) share a sign +/// discordant if they have opposite signs +/// tie_x / tie_y if the respective difference is zero +/// n0 = N(N−1)/2 +/// tau_b = (n_concordant − n_discordant) / sqrt((n0 − tie_x)(n0 − tie_y)) +/// ``` +/// +/// Where [`PearsonCorrelation`](crate::PearsonCorrelation) measures *linear* +/// co-movement and [`SpearmanCorrelation`](crate::SpearmanCorrelation) correlates +/// ranks via their differences, Kendall's tau counts how often the two series move +/// the **same direction** between every pair of observations. It is the most +/// robust of the three to outliers and to non-linear-but-monotonic +/// relationships, and the tau-b form corrects for ties so repeated values do not +/// bias it. The output is in `[−1, +1]`: `+1` perfectly concordant, `−1` +/// perfectly discordant, `0` no monotonic association. +/// +/// The window holds the last `period` pairs and is recomputed each bar in +/// O(`period²`). A window with no untied pairs on one side returns `0`. The first +/// value lands after `period` inputs. +/// +/// # Example +/// +/// ``` +/// use wickra_core::{Indicator, KendallTau}; +/// +/// let mut indicator = KendallTau::new(20).unwrap(); +/// let mut last = None; +/// for i in 0..40 { +/// let x = f64::from(i); +/// last = indicator.update((x, 2.0 * x)); // perfectly concordant +/// } +/// assert!((last.unwrap() - 1.0).abs() < 1e-9); +/// ``` +#[derive(Debug, Clone)] +pub struct KendallTau { + period: usize, + window: VecDeque<(f64, f64)>, + last: Option, +} + +impl KendallTau { + /// Construct a rolling Kendall's tau-b over `period` pairs. + /// + /// # Errors + /// + /// Returns [`Error::InvalidPeriod`] if `period < 2` (a correlation needs at + /// least two pairs). + pub fn new(period: usize) -> Result { + if period < 2 { + return Err(Error::InvalidPeriod { + message: "Kendall tau needs period >= 2", + }); + } + Ok(Self { + period, + window: VecDeque::with_capacity(period), + last: None, + }) + } + + /// Configured window of pairs. + pub const fn period(&self) -> usize { + self.period + } + + /// Current value if available. + pub const fn value(&self) -> Option { + self.last + } + + fn compute(&self) -> f64 { + let pairs: Vec<(f64, f64)> = self.window.iter().copied().collect(); + let len = pairs.len(); + let mut concordant: i64 = 0; + let mut discordant: i64 = 0; + let mut tie_x: i64 = 0; + let mut tie_y: i64 = 0; + for i in 0..len { + for j in (i + 1)..len { + let sx = sign(pairs[j].0, pairs[i].0); + let sy = sign(pairs[j].1, pairs[i].1); + if sx == 0 { + tie_x += 1; + } + if sy == 0 { + tie_y += 1; + } + let prod = sx * sy; + if prod > 0 { + concordant += 1; + } else if prod < 0 { + discordant += 1; + } + } + } + let n0 = (len * (len - 1) / 2) as f64; + let denom = ((n0 - tie_x as f64) * (n0 - tie_y as f64)).sqrt(); + if denom == 0.0 { + return 0.0; + } + ((concordant - discordant) as f64 / denom).clamp(-1.0, 1.0) + } +} + +impl Indicator for KendallTau { + type Input = (f64, f64); + type Output = f64; + + fn update(&mut self, input: (f64, f64)) -> Option { + if self.window.len() == self.period { + self.window.pop_front(); + } + self.window.push_back(input); + if self.window.len() < self.period { + return None; + } + let out = self.compute(); + self.last = Some(out); + Some(out) + } + + fn reset(&mut self) { + self.window.clear(); + self.last = None; + } + + fn warmup_period(&self) -> usize { + self.period + } + + fn is_ready(&self) -> bool { + self.last.is_some() + } + + fn name(&self) -> &'static str { + "KendallTau" + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::traits::BatchExt; + use approx::assert_relative_eq; + + #[test] + fn rejects_period_below_two() { + assert!(matches!( + KendallTau::new(1), + Err(Error::InvalidPeriod { .. }) + )); + assert!(KendallTau::new(2).is_ok()); + } + + #[test] + fn accessors_and_metadata() { + let k = KendallTau::new(20).unwrap(); + assert_eq!(k.period(), 20); + assert_eq!(k.warmup_period(), 20); + assert_eq!(k.name(), "KendallTau"); + assert!(!k.is_ready()); + assert_eq!(k.value(), None); + } + + #[test] + fn first_emission_at_warmup_period() { + let mut k = KendallTau::new(4).unwrap(); + let out = k.batch(&[(1.0, 1.0), (2.0, 2.0), (3.0, 3.0), (4.0, 4.0), (5.0, 5.0)]); + for v in out.iter().take(3) { + assert!(v.is_none()); + } + assert!(out[3].is_some()); + } + + #[test] + fn monotone_increasing_is_one() { + let pairs: Vec<(f64, f64)> = (0..20) + .map(|i| (f64::from(i), 2.0 * f64::from(i) + 1.0)) + .collect(); + let last = KendallTau::new(10) + .unwrap() + .batch(&pairs) + .into_iter() + .flatten() + .last() + .unwrap(); + assert_relative_eq!(last, 1.0, epsilon = 1e-9); + } + + #[test] + fn monotone_decreasing_is_minus_one() { + let pairs: Vec<(f64, f64)> = (0..20) + .map(|i| (f64::from(i), -3.0 * f64::from(i))) + .collect(); + let last = KendallTau::new(10) + .unwrap() + .batch(&pairs) + .into_iter() + .flatten() + .last() + .unwrap(); + assert_relative_eq!(last, -1.0, epsilon = 1e-9); + } + + #[test] + fn constant_channel_yields_zero() { + // y constant -> every y-difference is a tie -> denom 0 -> 0. + let pairs: Vec<(f64, f64)> = (0..20).map(|i| (f64::from(i), 7.0)).collect(); + let last = KendallTau::new(8) + .unwrap() + .batch(&pairs) + .into_iter() + .flatten() + .last() + .unwrap(); + assert_relative_eq!(last, 0.0, epsilon = 1e-12); + } + + #[test] + fn output_in_range() { + let pairs: Vec<(f64, f64)> = (0..80) + .map(|i| { + let t = f64::from(i); + (100.0 + t.sin() * 5.0, 50.0 + (t * 0.3).cos() * 3.0) + }) + .collect(); + for v in KendallTau::new(20) + .unwrap() + .batch(&pairs) + .into_iter() + .flatten() + { + assert!((-1.0..=1.0).contains(&v)); + } + } + + #[test] + fn reset_clears_state() { + let mut k = KendallTau::new(4).unwrap(); + k.batch(&[(1.0, 1.0), (2.0, 2.0), (3.0, 3.0), (4.0, 4.0)]); + assert!(k.is_ready()); + k.reset(); + assert!(!k.is_ready()); + assert_eq!(k.value(), None); + assert_eq!(k.update((1.0, 1.0)), None); + } + + #[test] + fn batch_equals_streaming() { + let pairs: Vec<(f64, f64)> = (0..60) + .map(|i| { + let t = f64::from(i); + (t.sin(), (t * 0.5).cos()) + }) + .collect(); + let batch = KendallTau::new(14).unwrap().batch(&pairs); + let mut b = KendallTau::new(14).unwrap(); + let streamed: Vec<_> = pairs.iter().map(|p| b.update(*p)).collect(); + assert_eq!(batch, streamed); + } + + #[test] + fn ties_are_corrected() { + // Tied x values (points 0 and 1) and tied y values (points 1 and 2) + // exercise the tie_x / tie_y correction counters. + let mut k = KendallTau::new(4).unwrap(); + assert_eq!(k.update((1.0, 1.0)), None); + assert_eq!(k.update((1.0, 2.0)), None); + assert_eq!(k.update((2.0, 2.0)), None); + let v = k.update((3.0, 3.0)).unwrap(); + assert!((-1.0..=1.0).contains(&v), "got {v}"); + } +} diff --git a/crates/wickra-core/src/indicators/mod.rs b/crates/wickra-core/src/indicators/mod.rs index 42b80efc..e3f564a0 100644 --- a/crates/wickra-core/src/indicators/mod.rs +++ b/crates/wickra-core/src/indicators/mod.rs @@ -191,6 +191,7 @@ mod intraday_momentum_index; mod intraday_volatility_profile; mod inverse_fisher_transform; mod inverted_hammer; +mod jarque_bera; mod jma; mod jump_indicator; mod kagi_bars; @@ -200,6 +201,7 @@ mod kase_devstop; mod kase_permission_stochastic; mod kelly_criterion; mod keltner; +mod kendall_tau; mod kicking; mod kicking_by_length; mod kst; @@ -314,6 +316,7 @@ mod roll_measure; mod rolling_correlation; mod rolling_covariance; mod rolling_iqr; +mod rolling_min_max_scaler; mod rolling_percentile_rank; mod rolling_quantile; mod roofing_filter; @@ -322,12 +325,14 @@ mod rsx; mod rvi; mod rvi_volatility; mod rwi; +mod sample_entropy; mod sar_ext; mod seasonal_z_score; mod separating_lines; mod session_high_low; mod session_range; mod session_vwap; +mod shannon_entropy; mod shark; mod sharpe_ratio; mod shooting_star; @@ -638,6 +643,7 @@ pub use intraday_momentum_index::IntradayMomentumIndex; pub use intraday_volatility_profile::{IntradayVolatilityProfile, IntradayVolatilityProfileOutput}; pub use inverse_fisher_transform::InverseFisherTransform; pub use inverted_hammer::InvertedHammer; +pub use jarque_bera::JarqueBera; pub use jma::Jma; pub use jump_indicator::JumpIndicator; pub use kagi_bars::{KagiBar, KagiBars}; @@ -647,6 +653,7 @@ pub use kase_devstop::{KaseDevStop, KaseDevStopOutput}; pub use kase_permission_stochastic::{KasePermissionStochastic, KasePermissionStochasticOutput}; pub use kelly_criterion::KellyCriterion; pub use keltner::{Keltner, KeltnerOutput}; +pub use kendall_tau::KendallTau; pub use kicking::Kicking; pub use kicking_by_length::KickingByLength; pub use kst::{Kst, KstOutput}; @@ -761,6 +768,7 @@ pub use roll_measure::RollMeasure; pub use rolling_correlation::RollingCorrelation; pub use rolling_covariance::RollingCovariance; pub use rolling_iqr::RollingIqr; +pub use rolling_min_max_scaler::RollingMinMaxScaler; pub use rolling_percentile_rank::RollingPercentileRank; pub use rolling_quantile::RollingQuantile; pub use roofing_filter::RoofingFilter; @@ -769,12 +777,14 @@ pub use rsx::Rsx; pub use rvi::Rvi; pub use rvi_volatility::RviVolatility; pub use rwi::{Rwi, RwiOutput}; +pub use sample_entropy::SampleEntropy; pub use sar_ext::SarExt; pub use seasonal_z_score::SeasonalZScore; pub use separating_lines::SeparatingLines; pub use session_high_low::{SessionHighLow, SessionHighLowOutput}; pub use session_range::{SessionRange, SessionRangeOutput}; pub use session_vwap::SessionVwap; +pub use shannon_entropy::ShannonEntropy; pub use shark::Shark; pub use sharpe_ratio::SharpeRatio; pub use shooting_star::ShootingStar; @@ -1191,6 +1201,11 @@ pub const FAMILIES: &[(&str, &[&str])] = &[ "BodySizePct", "WickRatio", "HighLowRange", + "JarqueBera", + "RollingMinMaxScaler", + "ShannonEntropy", + "SampleEntropy", + "KendallTau", ], ), ( @@ -1495,6 +1510,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, 447, "FAMILIES total drifted from indicator count"); + assert_eq!(total, 452, "FAMILIES total drifted from indicator count"); } } diff --git a/crates/wickra-core/src/indicators/rolling_min_max_scaler.rs b/crates/wickra-core/src/indicators/rolling_min_max_scaler.rs new file mode 100644 index 00000000..5a365f06 --- /dev/null +++ b/crates/wickra-core/src/indicators/rolling_min_max_scaler.rs @@ -0,0 +1,261 @@ +//! Rolling Min-Max Scaler — normalises the latest value to `[0, 1]` over a window. + +use std::collections::VecDeque; + +use crate::error::{Error, Result}; +use crate::traits::Indicator; + +/// Rolling Min-Max Scaler — maps the current value onto `[0, 1]` relative to the +/// minimum and maximum of the trailing window. +/// +/// ```text +/// scaled = (x − min(window)) / (max(window) − min(window)) +/// ``` +/// +/// This is the streaming form of scikit-learn's `MinMaxScaler` applied over a +/// sliding window: `0` means the value is the lowest in the window, `1` the +/// highest, `0.5` the midpoint of the range. It is the engine behind oscillators +/// like the Stochastic %K and a handy normaliser for feeding any indicator into a +/// bounded model input. Because it rescales to the window's own range it is +/// scale-free across instruments. +/// +/// The output is in `[0, 1]`. A flat window (`max == min`) has no range to scale +/// against and returns the neutral `0.5`. The first value lands after `period` +/// inputs; each `update` scans the window in O(`period`). +/// +/// # Example +/// +/// ``` +/// use wickra_core::{Indicator, RollingMinMaxScaler}; +/// +/// let mut indicator = RollingMinMaxScaler::new(14).unwrap(); +/// let mut last = None; +/// for i in 0..40 { +/// last = indicator.update(100.0 + (f64::from(i) * 0.3).sin() * 5.0); +/// } +/// assert!(last.is_some()); +/// ``` +#[derive(Debug, Clone)] +pub struct RollingMinMaxScaler { + period: usize, + window: VecDeque, + last: Option, +} + +impl RollingMinMaxScaler { + /// Construct a rolling min-max scaler over `period` values. + /// + /// # Errors + /// + /// Returns [`Error::PeriodZero`] if `period == 0` and + /// [`Error::InvalidPeriod`] if `period < 2` (a range needs two points). + pub fn new(period: usize) -> Result { + if period == 0 { + return Err(Error::PeriodZero); + } + if period < 2 { + return Err(Error::InvalidPeriod { + message: "min-max scaler needs period >= 2", + }); + } + Ok(Self { + period, + window: VecDeque::with_capacity(period), + last: None, + }) + } + + /// Configured window length. + pub const fn period(&self) -> usize { + self.period + } + + /// Current value if available. + pub const fn value(&self) -> Option { + self.last + } +} + +impl Indicator for RollingMinMaxScaler { + type Input = f64; + type Output = f64; + + fn update(&mut self, input: f64) -> Option { + if !input.is_finite() { + return self.last; + } + if self.window.len() == self.period { + self.window.pop_front(); + } + self.window.push_back(input); + if self.window.len() < self.period { + return None; + } + let mut min = f64::INFINITY; + let mut max = f64::NEG_INFINITY; + for &v in &self.window { + min = min.min(v); + max = max.max(v); + } + let range = max - min; + let scaled = if range > 0.0 { + (input - min) / range + } else { + 0.5 + }; + self.last = Some(scaled); + Some(scaled) + } + + fn reset(&mut self) { + self.window.clear(); + self.last = None; + } + + fn warmup_period(&self) -> usize { + self.period + } + + fn is_ready(&self) -> bool { + self.last.is_some() + } + + fn name(&self) -> &'static str { + "RollingMinMaxScaler" + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::traits::BatchExt; + use approx::assert_relative_eq; + + #[test] + fn rejects_invalid_period() { + assert!(matches!( + RollingMinMaxScaler::new(0), + Err(Error::PeriodZero) + )); + assert!(matches!( + RollingMinMaxScaler::new(1), + Err(Error::InvalidPeriod { .. }) + )); + assert!(RollingMinMaxScaler::new(2).is_ok()); + } + + #[test] + fn accessors_and_metadata() { + let s = RollingMinMaxScaler::new(14).unwrap(); + assert_eq!(s.period(), 14); + assert_eq!(s.warmup_period(), 14); + assert_eq!(s.name(), "RollingMinMaxScaler"); + assert!(!s.is_ready()); + assert_eq!(s.value(), None); + } + + #[test] + fn first_emission_at_warmup_period() { + let mut s = RollingMinMaxScaler::new(4).unwrap(); + let out = s.batch(&[1.0, 2.0, 3.0, 4.0, 5.0]); + for v in out.iter().take(3) { + assert!(v.is_none()); + } + assert!(out[3].is_some()); + } + + #[test] + fn highest_in_window_is_one() { + let mut s = RollingMinMaxScaler::new(4).unwrap(); + // last value is the highest -> 1.0. + let last = s + .batch(&[1.0, 2.0, 3.0, 4.0]) + .into_iter() + .flatten() + .last() + .unwrap(); + assert_relative_eq!(last, 1.0, epsilon = 1e-12); + } + + #[test] + fn lowest_in_window_is_zero() { + let mut s = RollingMinMaxScaler::new(4).unwrap(); + let last = s + .batch(&[4.0, 3.0, 2.0, 1.0]) + .into_iter() + .flatten() + .last() + .unwrap(); + assert_relative_eq!(last, 0.0, epsilon = 1e-12); + } + + #[test] + fn midpoint_is_half() { + let mut s = RollingMinMaxScaler::new(3).unwrap(); + // window [0, 2, 1]: min 0, max 2, current 1 -> 0.5. + let last = s + .batch(&[0.0, 2.0, 1.0]) + .into_iter() + .flatten() + .last() + .unwrap(); + assert_relative_eq!(last, 0.5, epsilon = 1e-12); + } + + #[test] + fn flat_window_is_half() { + let mut s = RollingMinMaxScaler::new(4).unwrap(); + let last = s.batch(&[7.0; 8]).into_iter().flatten().last().unwrap(); + assert_relative_eq!(last, 0.5, epsilon = 1e-12); + } + + #[test] + fn output_in_range() { + let mut s = RollingMinMaxScaler::new(14).unwrap(); + for v in s + .batch( + &(0..200) + .map(|i| (f64::from(i) * 0.3).sin() * 10.0) + .collect::>(), + ) + .into_iter() + .flatten() + { + assert!((0.0..=1.0).contains(&v)); + } + } + + #[test] + fn ignores_non_finite() { + let mut s = RollingMinMaxScaler::new(4).unwrap(); + let ready = s + .batch(&[1.0, 2.0, 3.0, 4.0]) + .into_iter() + .flatten() + .last() + .unwrap(); + assert_eq!(s.update(f64::NAN), Some(ready)); + } + + #[test] + fn reset_clears_state() { + let mut s = RollingMinMaxScaler::new(4).unwrap(); + s.batch(&[1.0, 2.0, 3.0, 4.0]); + assert!(s.is_ready()); + s.reset(); + assert!(!s.is_ready()); + assert_eq!(s.value(), None); + assert_eq!(s.update(1.0), None); + } + + #[test] + fn batch_equals_streaming() { + let xs: Vec = (0..120) + .map(|i| (f64::from(i) * 0.25).sin() * 9.0) + .collect(); + let batch = RollingMinMaxScaler::new(14).unwrap().batch(&xs); + let mut b = RollingMinMaxScaler::new(14).unwrap(); + let streamed: Vec<_> = xs.iter().map(|x| b.update(*x)).collect(); + assert_eq!(batch, streamed); + } +} diff --git a/crates/wickra-core/src/indicators/sample_entropy.rs b/crates/wickra-core/src/indicators/sample_entropy.rs new file mode 100644 index 00000000..b5367afb --- /dev/null +++ b/crates/wickra-core/src/indicators/sample_entropy.rs @@ -0,0 +1,337 @@ +//! Sample Entropy (`SampEn`) — the regularity / predictability of a window. + +use std::collections::VecDeque; + +use crate::error::{Error, Result}; +use crate::traits::Indicator; + +/// Population standard deviation of a slice (used for the matching tolerance). +fn population_stddev(window: &[f64]) -> f64 { + let n = window.len() as f64; + let mean = window.iter().sum::() / n; + let var = window.iter().map(|&v| (v - mean) * (v - mean)).sum::() / n; + var.max(0.0).sqrt() +} + +/// Whether two length-`len` templates starting at `i` and `j` match within the +/// Chebyshev tolerance `tol`. +fn templates_match(window: &[f64], i: usize, j: usize, len: usize, tol: f64) -> bool { + for k in 0..len { + if (window[i + k] - window[j + k]).abs() > tol { + return false; + } + } + true +} + +/// Sample Entropy (`SampEn`) — Richman & Moorman's measure of how *regular* (i.e. +/// predictable) a series is: the negative log conditional probability that two +/// sub-sequences similar for `m` points stay similar at the next point. +/// +/// ```text +/// tol = r_factor · stddev(window) +/// B = # template pairs of length m within tol (i < j) +/// A = # template pairs of length m+1 within tol (i < j) +/// `SampEn` = − ln(A / B) +/// ``` +/// +/// Low `SampEn` means the window is **regular** — patterns of length `m` reliably +/// extend to length `m + 1`, the fingerprint of a trending or cyclic market. High +/// `SampEn` means the series is **irregular** — knowing the last `m` points tells +/// you little about the next, the fingerprint of noise. Unlike the older +/// approximate entropy (`ApEn`), `SampEn` excludes self-matches, so it is far less +/// biased on short windows. +/// +/// The tolerance is `r_factor` times the window's standard deviation, so the +/// measure self-scales. A perfectly flat window (`stddev == 0`) is maximally +/// regular and returns `0`. If no length-`m` pairs match, the entropy is +/// undefined and `0` is returned; if length-`m` pairs match but none extend, the +/// estimator falls back to treating the unseen count as one (`−ln(1/B) = ln(B)`). +/// The first value lands after `period` inputs; each `update` is O(`period²`). +/// +/// # Example +/// +/// ``` +/// use wickra_core::{Indicator, SampleEntropy}; +/// +/// let mut indicator = SampleEntropy::new(50, 2, 0.2).unwrap(); +/// let mut last = None; +/// for i in 0..80 { +/// last = indicator.update((f64::from(i) * 0.3).sin() * 5.0); +/// } +/// assert!(last.is_some()); +/// ``` +#[derive(Debug, Clone)] +pub struct SampleEntropy { + period: usize, + emb_dim: usize, + r_factor: f64, + window: VecDeque, + last: Option, +} + +impl SampleEntropy { + /// Construct a Sample Entropy over `period` values with embedding dimension + /// `m` and tolerance factor `r_factor`. + /// + /// # Errors + /// + /// Returns [`Error::PeriodZero`] if `period` or `m` is `0`, + /// [`Error::InvalidPeriod`] if `period < m + 2` (no length-`m+1` template + /// pairs otherwise), and [`Error::InvalidParameter`] if `r_factor` is not + /// finite and positive. + pub fn new(period: usize, m: usize, r_factor: f64) -> Result { + if period == 0 || m == 0 { + return Err(Error::PeriodZero); + } + if period < m + 2 { + return Err(Error::InvalidPeriod { + message: "sample entropy needs period >= m + 2", + }); + } + if !r_factor.is_finite() || r_factor <= 0.0 { + return Err(Error::InvalidParameter { + message: "sample entropy r_factor must be finite and positive", + }); + } + Ok(Self { + period, + emb_dim: m, + r_factor, + window: VecDeque::with_capacity(period), + last: None, + }) + } + + /// Configured `(period, m, r_factor)`. + pub const fn params(&self) -> (usize, usize, f64) { + (self.period, self.emb_dim, self.r_factor) + } + + /// Current value if available. + pub const fn value(&self) -> Option { + self.last + } + + fn compute(&self) -> f64 { + let window: Vec = self.window.iter().copied().collect(); + let std = population_stddev(&window); + if std == 0.0 { + return 0.0; + } + let tol = self.r_factor * std; + let m = self.emb_dim; + // Restrict both template lengths to the same index range so A and B share + // their candidate pairs: there are `period − m` length-(m+1) templates. + let count = self.period - m; + let mut matches_m = 0u64; + let mut matches_m1 = 0u64; + for i in 0..count { + for j in (i + 1)..count { + if templates_match(&window, i, j, m, tol) { + matches_m += 1; + if templates_match(&window, i, j, m + 1, tol) { + matches_m1 += 1; + } + } + } + } + if matches_m == 0 { + return 0.0; + } + if matches_m1 == 0 { + // No length-(m+1) matches: fall back to one unseen count. + return (matches_m as f64).ln(); + } + -((matches_m1 as f64) / (matches_m as f64)).ln() + } +} + +impl Indicator for SampleEntropy { + type Input = f64; + type Output = f64; + + fn update(&mut self, input: f64) -> Option { + if !input.is_finite() { + return self.last; + } + if self.window.len() == self.period { + self.window.pop_front(); + } + self.window.push_back(input); + if self.window.len() < self.period { + return None; + } + let out = self.compute(); + self.last = Some(out); + Some(out) + } + + fn reset(&mut self) { + self.window.clear(); + self.last = None; + } + + fn warmup_period(&self) -> usize { + self.period + } + + fn is_ready(&self) -> bool { + self.last.is_some() + } + + fn name(&self) -> &'static str { + "SampleEntropy" + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::traits::BatchExt; + use approx::assert_relative_eq; + + #[test] + fn rejects_invalid_params() { + assert!(matches!( + SampleEntropy::new(0, 2, 0.2), + Err(Error::PeriodZero) + )); + assert!(matches!( + SampleEntropy::new(50, 0, 0.2), + Err(Error::PeriodZero) + )); + assert!(matches!( + SampleEntropy::new(3, 2, 0.2), + Err(Error::InvalidPeriod { .. }) + )); + assert!(matches!( + SampleEntropy::new(50, 2, 0.0), + Err(Error::InvalidParameter { .. }) + )); + } + + #[test] + fn accessors_and_metadata() { + let s = SampleEntropy::new(50, 2, 0.2).unwrap(); + assert_eq!(s.params(), (50, 2, 0.2)); + assert_eq!(s.warmup_period(), 50); + assert_eq!(s.name(), "SampleEntropy"); + assert!(!s.is_ready()); + assert_eq!(s.value(), None); + } + + #[test] + fn first_emission_at_warmup_period() { + let mut s = SampleEntropy::new(10, 2, 0.2).unwrap(); + let xs: Vec = (0..14).map(|i| (f64::from(i) * 0.5).sin()).collect(); + let out = s.batch(&xs); + for v in out.iter().take(9) { + assert!(v.is_none()); + } + assert!(out[9].is_some()); + } + + #[test] + fn constant_window_is_zero() { + let mut s = SampleEntropy::new(20, 2, 0.2).unwrap(); + let last = s.batch(&[5.0; 30]).into_iter().flatten().last().unwrap(); + assert_relative_eq!(last, 0.0, epsilon = 1e-12); + } + + #[test] + fn output_is_non_negative() { + let mut s = SampleEntropy::new(40, 2, 0.2).unwrap(); + for v in s + .batch( + &(0..200) + .map(|i| (f64::from(i) * 0.3).sin() * 5.0) + .collect::>(), + ) + .into_iter() + .flatten() + { + assert!(v >= 0.0, "sample entropy must be non-negative, got {v}"); + } + } + + #[test] + fn regular_below_irregular() { + // A smooth sine is far more regular (lower `SampEn`) than a chaotic + // logistic-map series. (An *alternating* series would be periodic, hence + // regular too -- chaos is what makes the window genuinely unpredictable.) + let smooth: Vec = (0..60).map(|i| (f64::from(i) * 0.2).sin() * 5.0).collect(); + let mut x = 0.37_f64; + let chaotic: Vec = (0..60) + .map(|_| { + x = 3.99 * x * (1.0 - x); + x * 5.0 + }) + .collect(); + let s_smooth = SampleEntropy::new(50, 2, 0.2) + .unwrap() + .batch(&smooth) + .into_iter() + .flatten() + .last() + .unwrap(); + let s_chaotic = SampleEntropy::new(50, 2, 0.2) + .unwrap() + .batch(&chaotic) + .into_iter() + .flatten() + .last() + .unwrap(); + assert!( + s_smooth <= s_chaotic, + "smooth ({s_smooth}) should be <= chaotic ({s_chaotic})" + ); + } + + #[test] + fn ignores_non_finite() { + let mut s = SampleEntropy::new(10, 2, 0.2).unwrap(); + let xs: Vec = (0..10).map(|i| (f64::from(i) * 0.5).sin()).collect(); + let ready = s.batch(&xs).into_iter().flatten().last().unwrap(); + assert_eq!(s.update(f64::NAN), Some(ready)); + } + + #[test] + fn reset_clears_state() { + let mut s = SampleEntropy::new(10, 2, 0.2).unwrap(); + let xs: Vec = (0..10).map(|i| (f64::from(i) * 0.5).sin()).collect(); + s.batch(&xs); + assert!(s.is_ready()); + s.reset(); + assert!(!s.is_ready()); + assert_eq!(s.value(), None); + assert_eq!(s.update(1.0), None); + } + + #[test] + fn batch_equals_streaming() { + let xs: Vec = (0..120) + .map(|i| (f64::from(i) * 0.25).sin() * 9.0) + .collect(); + let batch = SampleEntropy::new(40, 2, 0.2).unwrap().batch(&xs); + let mut b = SampleEntropy::new(40, 2, 0.2).unwrap(); + let streamed: Vec<_> = xs.iter().map(|x| b.update(*x)).collect(); + assert_eq!(batch, streamed); + } + + #[test] + fn falls_back_when_no_m_plus_one_matches() { + // `[1, 1, 1, 5]` with m = 2: the length-2 template `(1, 1)` repeats + // (matches_m > 0) but no length-3 template repeats (matches_m1 == 0), + // so SampEn takes the `ln(matches_m)` fallback branch. + let xs = [1.0, 1.0, 1.0, 5.0]; + let v = SampleEntropy::new(4, 2, 0.2) + .unwrap() + .batch(&xs) + .into_iter() + .flatten() + .last() + .unwrap(); + assert!(v.is_finite() && v >= 0.0, "got {v}"); + } +} diff --git a/crates/wickra-core/src/indicators/shannon_entropy.rs b/crates/wickra-core/src/indicators/shannon_entropy.rs new file mode 100644 index 00000000..143cd15c --- /dev/null +++ b/crates/wickra-core/src/indicators/shannon_entropy.rs @@ -0,0 +1,261 @@ +//! Shannon Entropy — the information content of a price window's distribution. + +use std::collections::VecDeque; + +use crate::error::{Error, Result}; +use crate::traits::Indicator; + +/// Shannon Entropy — the Shannon information entropy (in **bits**) of the +/// distribution of values in a rolling window, after binning them into a fixed +/// number of equal-width buckets. +/// +/// ```text +/// bucket each of the last `period` values into `bins` equal-width bins over +/// [min, max] of the window +/// p_i = count_i / period +/// H = − Σ p_i · log2(p_i) (over non-empty bins) +/// ``` +/// +/// Entropy measures how *spread out* and unpredictable the recent values are. A +/// window concentrated in one bin (a flat or tightly-ranging market) has low +/// entropy near `0`; a window whose values are spread evenly across all bins (a +/// noisy, directionless market) approaches the maximum `log2(bins)`. Traders use +/// it as a **regime filter**: low entropy favours trend/breakout strategies, high +/// entropy favours mean-reversion or standing aside. +/// +/// The output lies in `[0, log2(bins)]`. A degenerate window where every value is +/// identical (`max == min`) returns `0`. The first value lands after `period` +/// inputs; each `update` rebins the window in O(`period`). +/// +/// # Example +/// +/// ``` +/// use wickra_core::{Indicator, ShannonEntropy}; +/// +/// let mut indicator = ShannonEntropy::new(32, 8).unwrap(); +/// let mut last = None; +/// for i in 0..64 { +/// last = indicator.update((f64::from(i) * 0.7).sin() * 10.0); +/// } +/// assert!(last.is_some()); +/// ``` +#[derive(Debug, Clone)] +pub struct ShannonEntropy { + period: usize, + bins: usize, + window: VecDeque, + last: Option, +} + +impl ShannonEntropy { + /// Construct a Shannon entropy over `period` values binned into `bins` + /// buckets. + /// + /// # Errors + /// + /// Returns [`Error::PeriodZero`] if either argument is `0`, or + /// [`Error::InvalidPeriod`] if `bins < 2` (entropy needs at least two bins). + pub fn new(period: usize, bins: usize) -> Result { + if period == 0 || bins == 0 { + return Err(Error::PeriodZero); + } + if bins < 2 { + return Err(Error::InvalidPeriod { + message: "Shannon entropy needs bins >= 2", + }); + } + Ok(Self { + period, + bins, + window: VecDeque::with_capacity(period), + last: None, + }) + } + + /// Configured `(period, bins)`. + pub const fn params(&self) -> (usize, usize) { + (self.period, self.bins) + } + + /// Current value if available. + pub const fn value(&self) -> Option { + self.last + } +} + +impl Indicator for ShannonEntropy { + type Input = f64; + type Output = f64; + + fn update(&mut self, input: f64) -> Option { + if !input.is_finite() { + return self.last; + } + if self.window.len() == self.period { + self.window.pop_front(); + } + self.window.push_back(input); + if self.window.len() < self.period { + return None; + } + + let mut min = f64::INFINITY; + let mut max = f64::NEG_INFINITY; + for &v in &self.window { + min = min.min(v); + max = max.max(v); + } + if max <= min { + // Degenerate window: all values identical -> zero entropy. + self.last = Some(0.0); + return Some(0.0); + } + let width = (max - min) / self.bins as f64; + let mut counts = vec![0usize; self.bins]; + for &v in &self.window { + // `(v - min) / width` is in [0, bins]; the cast truncates toward zero + // (intended) and the value is non-negative, then clamped to the last + // bin so the index is always valid. + #[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)] + let raw = ((v - min) / width) as usize; + let idx = raw.min(self.bins - 1); + counts[idx] += 1; + } + let n = self.period as f64; + let mut h = 0.0; + for &count in &counts { + if count > 0 { + let p = count as f64 / n; + h -= p * p.log2(); + } + } + self.last = Some(h); + Some(h) + } + + fn reset(&mut self) { + self.window.clear(); + self.last = None; + } + + fn warmup_period(&self) -> usize { + self.period + } + + fn is_ready(&self) -> bool { + self.last.is_some() + } + + fn name(&self) -> &'static str { + "ShannonEntropy" + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::traits::BatchExt; + use approx::assert_relative_eq; + + #[test] + fn rejects_invalid_params() { + assert!(matches!(ShannonEntropy::new(0, 8), Err(Error::PeriodZero))); + assert!(matches!(ShannonEntropy::new(32, 0), Err(Error::PeriodZero))); + assert!(matches!( + ShannonEntropy::new(32, 1), + Err(Error::InvalidPeriod { .. }) + )); + } + + #[test] + fn accessors_and_metadata() { + let e = ShannonEntropy::new(32, 8).unwrap(); + assert_eq!(e.params(), (32, 8)); + assert_eq!(e.warmup_period(), 32); + assert_eq!(e.name(), "ShannonEntropy"); + assert!(!e.is_ready()); + assert_eq!(e.value(), None); + } + + #[test] + fn first_emission_at_warmup_period() { + let mut e = ShannonEntropy::new(4, 4).unwrap(); + let out = e.batch(&[1.0, 2.0, 3.0, 4.0, 5.0]); + for v in out.iter().take(3) { + assert!(v.is_none()); + } + assert!(out[3].is_some()); + } + + #[test] + fn constant_window_is_zero() { + let mut e = ShannonEntropy::new(8, 4).unwrap(); + let last = e.batch(&[5.0; 12]).into_iter().flatten().last().unwrap(); + assert_relative_eq!(last, 0.0, epsilon = 1e-12); + } + + #[test] + fn uniform_window_is_max_entropy() { + // One value per bin -> uniform distribution -> H = log2(bins). + let mut e = ShannonEntropy::new(4, 4).unwrap(); + // Values 0,1,2,3 with min=0,max=3,width=0.75 -> bins 0,1,2,3. + let last = e + .batch(&[0.0, 1.0, 2.0, 3.0]) + .into_iter() + .flatten() + .last() + .unwrap(); + assert_relative_eq!(last, 2.0, epsilon = 1e-9); // log2(4) = 2 + } + + #[test] + fn output_in_range() { + let mut e = ShannonEntropy::new(32, 8).unwrap(); + let max_h = 8f64.log2(); + for v in e + .batch( + &(0..200) + .map(|i| (f64::from(i) * 0.3).sin() * 10.0) + .collect::>(), + ) + .into_iter() + .flatten() + { + assert!((0.0..=max_h + 1e-9).contains(&v)); + } + } + + #[test] + fn ignores_non_finite() { + let mut e = ShannonEntropy::new(4, 4).unwrap(); + let ready = e + .batch(&[1.0, 2.0, 3.0, 4.0]) + .into_iter() + .flatten() + .last() + .unwrap(); + assert_eq!(e.update(f64::NAN), Some(ready)); + } + + #[test] + fn reset_clears_state() { + let mut e = ShannonEntropy::new(4, 4).unwrap(); + e.batch(&[1.0, 2.0, 3.0, 4.0]); + assert!(e.is_ready()); + e.reset(); + assert!(!e.is_ready()); + assert_eq!(e.value(), None); + assert_eq!(e.update(1.0), None); + } + + #[test] + fn batch_equals_streaming() { + let xs: Vec = (0..120) + .map(|i| (f64::from(i) * 0.25).sin() * 9.0) + .collect(); + let batch = ShannonEntropy::new(32, 8).unwrap().batch(&xs); + let mut b = ShannonEntropy::new(32, 8).unwrap(); + let streamed: Vec<_> = xs.iter().map(|x| b.update(*x)).collect(); + assert_eq!(batch, streamed); + } +} diff --git a/crates/wickra-core/src/lib.rs b/crates/wickra-core/src/lib.rs index 41237ffe..d2182604 100644 --- a/crates/wickra-core/src/lib.rs +++ b/crates/wickra-core/src/lib.rs @@ -93,39 +93,40 @@ pub use indicators::{ HtTrendMode, HurstChannel, HurstChannelOutput, HurstExponent, Ichimoku, IchimokuOutput, IdenticalThreeCrows, InNeck, Inertia, InformationRatio, InitialBalance, InitialBalanceOutput, InstantaneousTrendline, IntradayIntensity, IntradayMomentumIndex, IntradayVolatilityProfile, - IntradayVolatilityProfileOutput, InverseFisherTransform, InvertedHammer, Jma, JumpIndicator, - KagiBars, KalmanHedgeRatio, KalmanHedgeRatioOutput, Kama, KaseDevStop, KaseDevStopOutput, - KasePermissionStochastic, KasePermissionStochasticOutput, KellyCriterion, Keltner, - KeltnerOutput, Kicking, KickingByLength, Kst, KstOutput, Kurtosis, Kvo, KylesLambda, - LadderBottom, LaguerreRsi, LeadLagCrossCorrelation, LeadLagCrossCorrelationOutput, LinRegAngle, - LinRegChannel, LinRegChannelOutput, LinRegIntercept, LinRegSlope, LinearRegression, - LiquidationFeatures, LiquidationFeaturesOutput, LogReturn, LongLeggedDoji, LongLine, - LongShortRatio, MaEnvelope, MaEnvelopeOutput, MacdExt, MacdFix, MacdHistogram, MacdIndicator, - MacdOutput, Mama, MamaOutput, MarketFacilitationIndex, Marubozu, MassIndex, MatHold, - MatchingLow, MaxDrawdown, McClellanOscillator, McClellanSummationIndex, McGinleyDynamic, - MedianAbsoluteDeviation, MedianChannel, MedianChannelOutput, MedianMa, MedianPrice, Mfi, - Microprice, MidPoint, MidPrice, MinusDi, MinusDm, ModifiedMaStop, ModifiedMaStopOutput, Mom, - MorningDojiStar, MorningEveningStar, Natr, NewHighsNewLows, Nrtr, NrtrOutput, 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, PolarizedFractalEfficiency, Ppo, PpoHistogram, - ProfitFactor, ProjectionBands, ProjectionBandsOutput, ProjectionOscillator, Psar, Pvi, Qqe, - QqeOutput, Qstick, QuartileBands, QuartileBandsOutput, QuotedSpread, RSquared, RealizedSpread, - RealizedVolatility, RecoveryFactor, RectangleRange, RegimeLabel, RelativeStrengthAB, - RelativeStrengthOutput, RenkoBars, RenkoTrailingStop, RickshawMan, RisingThreeMethods, Rmi, - Roc, Rocp, Rocr, Rocr100, RogersSatchellVolatility, RollMeasure, RollingCorrelation, - RollingCovariance, RollingIqr, RollingPercentileRank, RollingQuantile, RollingVwap, - RoofingFilter, Rsi, Rsx, Rvi, RviVolatility, Rwi, RwiOutput, SarExt, SeasonalZScore, + IntradayVolatilityProfileOutput, InverseFisherTransform, InvertedHammer, JarqueBera, Jma, + JumpIndicator, KagiBars, KalmanHedgeRatio, KalmanHedgeRatioOutput, Kama, KaseDevStop, + KaseDevStopOutput, KasePermissionStochastic, KasePermissionStochasticOutput, KellyCriterion, + Keltner, KeltnerOutput, KendallTau, Kicking, KickingByLength, Kst, KstOutput, Kurtosis, Kvo, + KylesLambda, LadderBottom, LaguerreRsi, LeadLagCrossCorrelation, LeadLagCrossCorrelationOutput, + LinRegAngle, LinRegChannel, LinRegChannelOutput, LinRegIntercept, LinRegSlope, + LinearRegression, LiquidationFeatures, LiquidationFeaturesOutput, LogReturn, LongLeggedDoji, + LongLine, LongShortRatio, MaEnvelope, MaEnvelopeOutput, MacdExt, MacdFix, MacdHistogram, + MacdIndicator, MacdOutput, Mama, MamaOutput, MarketFacilitationIndex, Marubozu, MassIndex, + MatHold, MatchingLow, MaxDrawdown, McClellanOscillator, McClellanSummationIndex, + McGinleyDynamic, MedianAbsoluteDeviation, MedianChannel, MedianChannelOutput, MedianMa, + MedianPrice, Mfi, Microprice, MidPoint, MidPrice, MinusDi, MinusDm, ModifiedMaStop, + ModifiedMaStopOutput, Mom, MorningDojiStar, MorningEveningStar, Natr, NewHighsNewLows, Nrtr, + NrtrOutput, 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, + PolarizedFractalEfficiency, Ppo, PpoHistogram, ProfitFactor, ProjectionBands, + ProjectionBandsOutput, ProjectionOscillator, Psar, Pvi, Qqe, QqeOutput, Qstick, QuartileBands, + QuartileBandsOutput, QuotedSpread, RSquared, RealizedSpread, RealizedVolatility, + RecoveryFactor, RectangleRange, RegimeLabel, RelativeStrengthAB, RelativeStrengthOutput, + RenkoBars, RenkoTrailingStop, RickshawMan, RisingThreeMethods, Rmi, Roc, Rocp, Rocr, Rocr100, + RogersSatchellVolatility, RollMeasure, RollingCorrelation, RollingCovariance, RollingIqr, + RollingMinMaxScaler, RollingPercentileRank, RollingQuantile, RollingVwap, RoofingFilter, Rsi, + Rsx, Rvi, RviVolatility, Rwi, RwiOutput, SampleEntropy, 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, - StochasticCci, StochasticOutput, SuperSmoother, SuperTrend, SuperTrendOutput, + SessionVwap, ShannonEntropy, 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, StochasticCci, StochasticOutput, SuperSmoother, SuperTrend, SuperTrendOutput, TakerBuySellRatio, Takuri, TasukiGap, TdCombo, TdCountdown, TdDeMarker, TdDifferential, TdLines, TdLinesOutput, TdOpen, TdPressure, TdRangeProjection, TdRangeProjectionOutput, TdRei, TdRiskLevel, TdRiskLevelOutput, TdSequential, TdSequentialOutput, TdSetup, Tema, diff --git a/docs/README.md b/docs/README.md index 7a80f485..cfa5b3da 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 **447 indicators** across +- A per-indicator deep dive for every one of the **452 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 8c1e304c..a35fd955 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, AdaptiveLaguerreFilter, Alma, AnchoredRsi, Apo, Autocorrelation, AverageDrawdown, BatchExt, Beta, BipowerVariation, BollingerBands, BomarBands, CalmarRatio, CenterOfGravity, Cfo, Cmo, CoefficientOfVariation, ConditionalValueAtRisk, ConnorsRsi, Coppock, CyberneticCycle, Decycler, DecyclerOscillator, Dema, DerivativeOscillator, DetrendedStdDev, DisparityIndex, DoubleBollinger, Dpo, DrawdownDuration, DynamicMomentumIndex, EhlersStochastic, Ehma, ElderImpulse, Ema, EmpiricalModeDecomposition, EwmaVolatility, Expectancy, Fama, FisherRsi, FisherTransform, Frama, GainLossRatio, Garch11, 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, MacdHistogram, MacdIndicator, Mama, MaxDrawdown, McGinleyDynamic, MedianAbsoluteDeviation, MedianChannel, MedianMa, MidPoint, Mom, OmegaRatio, PainIndex, PearsonCorrelation, PercentageTrailingStop, Pmo, PolarizedFractalEfficiency, Ppo, PpoHistogram, ProfitFactor, Qqe, QuartileBands, RSquared, RealizedVolatility, RecoveryFactor, RegimeLabel, RenkoTrailingStop, Rmi, Roc, Rocp, Rocr, Rocr100, RollingIqr, RollingPercentileRank, RollingQuantile, RoofingFilter, Rsi, Rsx, RviVolatility, SharpeRatio, SineWave, SineWeightedMa, Skewness, Sma, Smma, SortinoRatio, SpearmanCorrelation, StandardError, StandardErrorBands, Stc, StdDev, StepTrailingStop, StochRsi, SuperSmoother, Tema, Tii, TrendLabel, TrendStrengthIndex, Trima, Trix, Tsf, TsfOscillator, Tsi, UlcerIndex, ValueAtRisk, Variance, VerticalHorizontalFilter, Vidya, VolatilityOfVolatility, WavePm, WinRate, Wma, ZScore, ZeroLagMacd, Zlema, T3}; +use wickra_core::{AdaptiveCycle, AdaptiveLaguerreFilter, Alma, AnchoredRsi, Apo, Autocorrelation, AverageDrawdown, BatchExt, Beta, BipowerVariation, BollingerBands, BomarBands, CalmarRatio, CenterOfGravity, Cfo, Cmo, CoefficientOfVariation, ConditionalValueAtRisk, ConnorsRsi, Coppock, CyberneticCycle, Decycler, DecyclerOscillator, Dema, DerivativeOscillator, DetrendedStdDev, DisparityIndex, DoubleBollinger, Dpo, DrawdownDuration, DynamicMomentumIndex, EhlersStochastic, Ehma, ElderImpulse, Ema, EmpiricalModeDecomposition, EwmaVolatility, Expectancy, Fama, FisherRsi, FisherTransform, Frama, GainLossRatio, Garch11, GeneralizedDema, GeometricMa, HilbertDominantCycle, HistoricalVolatility, Hma, HoltWinters, HtDcPhase, HtPhasor, HtTrendMode, HurstExponent, Indicator, InstantaneousTrendline, InverseFisherTransform, JarqueBera, Jma, JumpIndicator, Kama, KellyCriterion, Kst, Kurtosis, LaguerreRsi, LinRegAngle, LinRegChannel, LinRegIntercept, LinRegSlope, LinearRegression, LogReturn, MaEnvelope, MaType, MacdExt, MacdFix, MacdHistogram, MacdIndicator, Mama, MaxDrawdown, McGinleyDynamic, MedianAbsoluteDeviation, MedianChannel, MedianMa, MidPoint, Mom, OmegaRatio, PainIndex, PearsonCorrelation, PercentageTrailingStop, Pmo, PolarizedFractalEfficiency, Ppo, PpoHistogram, ProfitFactor, Qqe, QuartileBands, RSquared, RealizedVolatility, RecoveryFactor, RegimeLabel, RenkoTrailingStop, Rmi, Roc, Rocp, Rocr, Rocr100, RollingIqr, RollingMinMaxScaler, RollingPercentileRank, RollingQuantile, RoofingFilter, Rsi, Rsx, RviVolatility, SampleEntropy, ShannonEntropy, SharpeRatio, SineWave, SineWeightedMa, Skewness, Sma, Smma, SortinoRatio, SpearmanCorrelation, StandardError, StandardErrorBands, Stc, StdDev, StepTrailingStop, StochRsi, SuperSmoother, Tema, Tii, TrendLabel, TrendStrengthIndex, Trima, Trix, Tsf, TsfOscillator, Tsi, UlcerIndex, ValueAtRisk, Variance, VerticalHorizontalFilter, Vidya, VolatilityOfVolatility, WavePm, 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. @@ -122,6 +122,10 @@ fuzz_target!(|data: Vec| { drive(|| RollingQuantile::new(20, 0.5).unwrap(), &data); drive(|| RollingIqr::new(14).unwrap(), &data); drive(|| RollingPercentileRank::new(14).unwrap(), &data); + drive(|| JarqueBera::new(20).unwrap(), &data); + drive(|| RollingMinMaxScaler::new(20).unwrap(), &data); + drive(|| ShannonEntropy::new(20, 8).unwrap(), &data); + drive(|| SampleEntropy::new(20, 2, 0.2).unwrap(), &data); drive(|| TrendLabel::new(14).unwrap(), &data); drive(|| JumpIndicator::new(20, 3.0).unwrap(), &data); drive(|| RegimeLabel::new(5, 20).unwrap(), &data); diff --git a/fuzz/fuzz_targets/indicator_update_pair.rs b/fuzz/fuzz_targets/indicator_update_pair.rs index be0286fa..2e259e55 100644 --- a/fuzz/fuzz_targets/indicator_update_pair.rs +++ b/fuzz/fuzz_targets/indicator_update_pair.rs @@ -8,7 +8,7 @@ //! panic. use libfuzzer_sys::fuzz_target; -use wickra_core::{Alpha, BatchExt, BetaNeutralSpread, Cointegration, DistanceSsd, GrangerCausality, Indicator, InformationRatio, KalmanHedgeRatio, LeadLagCrossCorrelation, OuHalfLife, PairSpreadZScore, PairwiseBeta, RelativeStrengthAB, RollingCorrelation, RollingCovariance, SpreadAr1Coefficient, SpreadBollingerBands, SpreadHurst, TreynorRatio, VarianceRatio}; +use wickra_core::{Alpha, BatchExt, BetaNeutralSpread, Cointegration, DistanceSsd, GrangerCausality, Indicator, InformationRatio, KalmanHedgeRatio, KendallTau, LeadLagCrossCorrelation, OuHalfLife, PairSpreadZScore, PairwiseBeta, RelativeStrengthAB, RollingCorrelation, RollingCovariance, SpreadAr1Coefficient, SpreadBollingerBands, SpreadHurst, TreynorRatio, VarianceRatio}; #[inline(never)] fn drive(make: impl Fn() -> I, data: &[(f64, f64)]) @@ -47,6 +47,7 @@ fuzz_target!(|data: &[u8]| { drive(|| VarianceRatio::new(60, 2).unwrap(), &pairs); drive(|| GrangerCausality::new(60, 1).unwrap(), &pairs); drive(|| SpreadAr1Coefficient::new(40).unwrap(), &pairs); + drive(|| KendallTau::new(20).unwrap(), &pairs); // Struct-output pair indicator: drive update + batch directly (the generic // `drive` above only covers `Output = f64`).