From 05fcdd9a5e899621f0881f98522f0399cebcb02e Mon Sep 17 00:00:00 2001 From: kingchenc Date: Mon, 25 May 2026 23:42:05 +0200 Subject: [PATCH] feat(family-12): add 13 Statistik/Regression indicators (#51) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(family-12): add 13 Statistik/Regression indicators Brings the Price Statistics family to 20 indicators (7 → 20) and the total catalogue to 84 (71 → 84). Every indicator ships in the Rust core plus Python, Node, and WASM bindings with full streaming ↔ batch parity, fuzz coverage, and benches. Scalar (f64 → f64): - Variance, CoefficientOfVariation: rolling population variance and its dimensionless ratio with the mean. O(1) updates. - Skewness, Kurtosis: rolling Pearson skewness and excess kurtosis, derived from running sums of x, x², x³, x⁴ via the binomial identities — also O(1) per bar. - StandardError, DetrendedStdDev: standard error of estimate (n − 2) and population StdDev (n) of OLS residuals, sharing the LinReg O(1) sliding sums. - RSquared: coefficient of determination of the rolling OLS fit; the trend-quality filter, clamped to [0, 1]. - MedianAbsoluteDeviation: robust dispersion estimator; O(period log period) per emission via two in-place sorts of a reusable scratch buffer. - Autocorrelation(period, lag): rolling lag-k Pearson autocorrelation. - HurstExponent(period, chunks): R/S-analysis trend-persistence estimator clamped to [0, 1]. Pair indicators (Input = (f64, f64)): - PearsonCorrelation: rolling cross-series Pearson, O(1). - Beta: rolling OLS slope of asset vs. benchmark (CAPM). - SpearmanCorrelation: rolling rank correlation with mid-rank tie handling; O(period log period). Touchpoints: - crates/wickra-core: 13 new indicator modules + mod.rs / lib.rs re-exports. - bindings/python: pyclasses + add_class registration + __init__.py import & __all__ updates. The pair indicators expose update(x, y) and batch(x, y) over two equally-sized numpy arrays. - bindings/node: scalar indicators via node_scalar_indicator! macro; pair indicators via new node_pair_indicator! macro; explicit structs for Autocorrelation and HurstExponent (two-arg ctors). index.js extended with the new exports. - bindings/wasm: scalar wrappers via wasm_scalar_indicator!; pair wrappers via new wasm_pair_indicator! macro. - fuzz: every scalar drove through the generic helper; pair indicators stress-tested by pairing adjacent samples of the fuzz input. - Python tests (test_new_indicators.py): added to SCALAR parametrisation, plus algebraic reference values (variance of [2,4,6] = 8/3, MAD ignoring outlier = 0, monotone non-linear Spearman = 1, two-to-one Beta = 2, etc.) and a streaming-vs-batch test for the pair indicators. - Node tests (indicators.test.js): extended the scalar factories map and added a pair-indicator section with the same algebraic reference values. - crates/wickra/benches: bench_scalar entries for all 10 single- input new indicators. - README: counter 71 → 84; Price Statistics family-table row expanded with the 13 new indicators. - CHANGELOG: Unreleased section documents the family addition. Wiki drafts (ghost-ignored, manual sync to wickra.wiki at release time): indicator-ideas/families/wiki/family-12-statistik-regression/ contains 13 deep-dive pages plus _Sidebar / Indicators-Overview / Warmup-Periods / Home fragments for the curator merge. cargo check --workspace --all-features: clean. * fix(family-12): remove unreachable defensive guards in hurst_exponent The three guards (m < 2 continue, end > buf.len() break, denom == 0.0 return) are by-construction unreachable given the constructor invariant period >= 2 * chunks: m = period / k for k in 1..=chunks always satisfies m >= 2 and end = (c+1) * m <= k * m <= period = buf.len(), and m_1 = period and m_2 = period / 2 are always distinct so the slope denominator is strictly positive. Removing them brings codecov/patch back to 100%. --- CHANGELOG.md | 31 + README.md | 6 +- bindings/node/__tests__/indicators.test.js | 85 +++ bindings/node/index.js | 15 +- bindings/node/src/lib.rs | 156 ++++ bindings/python/python/wickra/__init__.py | 26 + bindings/python/src/lib.rs | 713 ++++++++++++++++++ bindings/python/tests/test_new_indicators.py | 116 +++ bindings/wasm/src/lib.rs | 69 ++ .../src/indicators/autocorrelation.rs | 221 ++++++ crates/wickra-core/src/indicators/beta.rs | 228 ++++++ .../indicators/coefficient_of_variation.rs | 184 +++++ .../src/indicators/detrended_std_dev.rs | 221 ++++++ .../src/indicators/hurst_exponent.rs | 299 ++++++++ crates/wickra-core/src/indicators/kurtosis.rs | 203 +++++ .../indicators/median_absolute_deviation.rs | 201 +++++ crates/wickra-core/src/indicators/mod.rs | 26 + .../src/indicators/pearson_correlation.rs | 246 ++++++ .../wickra-core/src/indicators/r_squared.rs | 216 ++++++ crates/wickra-core/src/indicators/skewness.rs | 202 +++++ .../src/indicators/spearman_correlation.rs | 314 ++++++++ .../src/indicators/standard_error.rs | 244 ++++++ crates/wickra-core/src/indicators/variance.rs | 192 +++++ crates/wickra-core/src/lib.rs | 32 +- crates/wickra/benches/indicators.rs | 52 +- fuzz/fuzz_targets/indicator_update.rs | 47 +- 26 files changed, 4303 insertions(+), 42 deletions(-) create mode 100644 crates/wickra-core/src/indicators/autocorrelation.rs create mode 100644 crates/wickra-core/src/indicators/beta.rs create mode 100644 crates/wickra-core/src/indicators/coefficient_of_variation.rs create mode 100644 crates/wickra-core/src/indicators/detrended_std_dev.rs create mode 100644 crates/wickra-core/src/indicators/hurst_exponent.rs create mode 100644 crates/wickra-core/src/indicators/kurtosis.rs create mode 100644 crates/wickra-core/src/indicators/median_absolute_deviation.rs create mode 100644 crates/wickra-core/src/indicators/pearson_correlation.rs create mode 100644 crates/wickra-core/src/indicators/r_squared.rs create mode 100644 crates/wickra-core/src/indicators/skewness.rs create mode 100644 crates/wickra-core/src/indicators/spearman_correlation.rs create mode 100644 crates/wickra-core/src/indicators/standard_error.rs create mode 100644 crates/wickra-core/src/indicators/variance.rs diff --git a/CHANGELOG.md b/CHANGELOG.md index d26cc63a..08f1f43f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,37 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] ### Added +- **Family 12 — Statistik / Regression (13 indicators).** A complete + statistical toolkit for analysing rolling price distributions and + cross-series relationships. Every indicator ships in the Rust core + plus all three bindings (Python, Node, WASM), with full streaming + + batch parity, fuzz coverage, and benches against the BTCUSDT + dataset: + - **Variance** — rolling population variance (`StdDev` squared). + - **CoefficientOfVariation** — `StdDev / Mean`, dimensionless dispersion. + - **Skewness** — rolling third standardised moment (Pearson skewness). + - **Kurtosis** — rolling excess kurtosis (fourth moment minus `3`). + - **StandardError** — standard error of estimate for the rolling OLS + fit, with `n − 2` residual degrees of freedom. + - **DetrendedStdDev** — population standard deviation of OLS + residuals (the StdDev that remains after subtracting the linear + trend). + - **RSquared** — coefficient of determination of the rolling OLS + fit; the trend-quality filter. + - **MedianAbsoluteDeviation** — robust dispersion measure that + survives outliers (median of absolute deviations from the median). + - **Autocorrelation** — rolling lag-`k` Pearson autocorrelation; + detects periodicity and tests for white-noise behaviour. + - **HurstExponent** — R/S-analysis estimator of trend-persistence + vs. mean-reversion regime (`0.5` is random walk). + - **PearsonCorrelation** — rolling correlation between two + synchronised series; takes `(x, y)` pairs. + - **Beta** — rolling OLS slope of an asset on a benchmark; the CAPM + sensitivity coefficient. + - **SpearmanCorrelation** — rolling rank correlation (monotone, + outlier-robust analogue of Pearson). + + Indicator count: 71 → 84. - **Family 13 — Ichimoku & alternative charts.** Two new indicators: - `Ichimoku` (Ichimoku Kinko Hyo) — the full five-line cloud system (Tenkan-sen, Kijun-sen, Senkou Span A/B, Chikou Span) with the diff --git a/README.md b/README.md index 6a968c22..780b13ff 100644 --- a/README.md +++ b/README.md @@ -109,7 +109,7 @@ python -m benchmarks.compare_libraries ## Indicators -165 streaming-first indicators across thirteen families. Every one passes the +178 streaming-first indicators across thirteen families. Every one passes the `batch == streaming` equivalence test, reference-value tests, and reset semantics tests. @@ -123,7 +123,7 @@ semantics tests. | 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 | | Trailing Stops | Parabolic SAR, SuperTrend, Chandelier Exit, Chande Kroll Stop, ATR Trailing Stop, HiLo Activator, Volty Stop, Yo-Yo Exit, Donchian Channel Stop, Percentage Trailing Stop, Step Trailing Stop, Renko Trailing Stop | | Volume | OBV, VWAP (cumulative + rolling), ADL, Volume-Price Trend, Chaikin Money Flow, Chaikin Oscillator, Force Index, Ease of Movement, Klinger Volume Oscillator, Volume Oscillator, NVI, PVI, Williams A/D, Anchored VWAP, Demand Index, TSV, VZO, Market Facilitation Index | -| Price Statistics | Typical Price, Median Price, Weighted Close, Linear Regression, Linear Regression Slope, Z-Score, Linear Regression Angle | +| 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, Spearman Correlation | | Ehlers / Cycle (DSP) | MAMA, FAMA, Fisher Transform, Inverse Fisher Transform, SuperSmoother, Hilbert Dominant Cycle, Sine Wave, Decycler, Decycler Oscillator, Roofing Filter, Center of Gravity, Cybernetic Cycle, Adaptive Cycle, Empirical Mode Decomposition, Ehlers Stochastic, Instantaneous Trendline | | Pivots & S/R | Classic Pivots, Fibonacci Pivots, Camarilla, Woodie Pivots, DeMark Pivots, Williams Fractals, ZigZag | | DeMark | TD Setup, TD Sequential, TD DeMarker, TD REI, TD Pressure, TD Combo, TD Countdown, TD Lines, TD Range Projection, TD Differential, TD Open, TD Risk Level | @@ -200,7 +200,7 @@ A Python live-trading example using the public `websockets` package lives at ``` wickra/ ├── crates/ -│ ├── wickra-core/ core engine + all 165 indicators +│ ├── wickra-core/ core engine + all 178 indicators │ ├── wickra/ top-level facade crate (publishes on crates.io) + benches/ │ └── wickra-data/ CSV reader, tick aggregator, live exchange feeds ├── bindings/ diff --git a/bindings/node/__tests__/indicators.test.js b/bindings/node/__tests__/indicators.test.js index a6129870..d617b632 100644 --- a/bindings/node/__tests__/indicators.test.js +++ b/bindings/node/__tests__/indicators.test.js @@ -91,6 +91,17 @@ const scalarFactories = { AdaptiveCycle: () => new wickra.AdaptiveCycle(), SineWave: () => new wickra.SineWave(), FAMA: () => new wickra.FAMA(0.5, 0.05), + // Family 12 — Statistik / Regression + Variance: () => new wickra.Variance(20), + CoefficientOfVariation: () => new wickra.CoefficientOfVariation(20), + Skewness: () => new wickra.Skewness(20), + Kurtosis: () => new wickra.Kurtosis(20), + StandardError: () => new wickra.StandardError(14), + DetrendedStdDev: () => new wickra.DetrendedStdDev(14), + RSquared: () => new wickra.RSquared(14), + MedianAbsoluteDeviation: () => new wickra.MedianAbsoluteDeviation(20), + Autocorrelation: () => new wickra.Autocorrelation(20, 1), + HurstExponent: () => new wickra.HurstExponent(40, 4), }; for (const [name, make] of Object.entries(scalarFactories)) { @@ -360,6 +371,80 @@ test('LinRegAngle of a unit-slope series is 45 degrees', () => { assert.ok(Math.abs(out[4] - 45) < 1e-9); }); +// --- Family 12: two-series indicators (Pearson / Beta / Spearman) --- + +const pairFactories = { + PearsonCorrelation: () => new wickra.PearsonCorrelation(14), + Beta: () => new wickra.Beta(14), + SpearmanCorrelation: () => new wickra.SpearmanCorrelation(14), +}; + +for (const [name, make] of Object.entries(pairFactories)) { + test(`${name}: streaming update matches batch over a pair of series`, () => { + const xs = Array.from({ length: N }, (_, i) => Math.sin(i * 0.2) + 0.05 * i); + const ys = Array.from({ length: N }, (_, i) => Math.cos(i * 0.3) + 0.02 * i); + const batch = make().batch(xs, ys); + const streaming = make(); + assert.equal(batch.length, N); + for (let i = 0; i < N; i++) { + const s = num(streaming.update(xs[i], ys[i])); + assert.ok(eq(s, batch[i]), `${name} mismatch at ${i}: ${s} vs ${batch[i]}`); + } + }); +} + +test('PearsonCorrelation perfect positive is 1', () => { + const x = Array.from({ length: 10 }, (_, i) => i); + const y = x.map((v) => 2 * v + 3); + const out = new wickra.PearsonCorrelation(5).batch(x, y); + assert.ok(Math.abs(out[out.length - 1] - 1) < 1e-9); +}); + +test('Beta perfect two-to-one', () => { + const bench = Array.from({ length: 10 }, (_, i) => i); + const asset = bench.map((v) => 2 * v); + const out = new wickra.Beta(5).batch(asset, bench); + assert.ok(Math.abs(out[out.length - 1] - 2) < 1e-9); +}); + +test('SpearmanCorrelation monotone non-linear is 1', () => { + const x = Array.from({ length: 10 }, (_, i) => i + 1); + const y = x.map((v) => v ** 3); + const out = new wickra.SpearmanCorrelation(5).batch(x, y); + assert.ok(Math.abs(out[out.length - 1] - 1) < 1e-9); +}); + +test('Variance(3) of [2, 4, 6] equals 8/3', () => { + const out = new wickra.Variance(3).batch([2, 4, 6]); + assert.ok(Math.abs(out[2] - 8 / 3) < 1e-12); +}); + +test('RSquared on a perfect line is 1', () => { + const xs = Array.from({ length: 20 }, (_, i) => 2 * i + 5); + const out = new wickra.RSquared(5).batch(xs); + for (let i = 5; i < out.length; i++) { + assert.ok(Math.abs(out[i] - 1) < 1e-9); + } +}); + +test('MedianAbsoluteDeviation ignores a single huge outlier', () => { + const xs = Array(9).fill(5).concat([1000]); + const out = new wickra.MedianAbsoluteDeviation(10).batch(xs); + assert.ok(Math.abs(out[9]) < 1e-12); +}); + +test('Autocorrelation of an alternating series is strongly negative at lag 1', () => { + const xs = Array.from({ length: 20 }, (_, i) => (i % 2 === 0 ? -1 : 1)); + const out = new wickra.Autocorrelation(10, 1).batch(xs); + assert.ok(out[out.length - 1] < -0.5); +}); + +test('HurstExponent of a monotone ramp is above 0.5', () => { + const xs = Array.from({ length: 200 }, (_, i) => i); + const out = new wickra.HurstExponent(100, 4).batch(xs); + assert.ok(out[out.length - 1] > 0.5); +}); + test('Ichimoku classic warmup is 77 and tenkan emits at bar 9', () => { const ichi = new wickra.Ichimoku(9, 26, 52, 26); assert.equal(ichi.warmupPeriod(), 77); diff --git a/bindings/node/index.js b/bindings/node/index.js index 1d1d8840..04026514 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, MACD, BollingerBands, ATR, Stochastic, OBV, ADX, ADXR, CCI, WilliamsR, MFI, PSAR, Keltner, Donchian, VWAP, RollingVWAP, AwesomeOscillator, Aroon, KAMA, RVI, PGO, KST, SMI, LaguerreRSI, ConnorsRSI, Inertia, ALMA, McGinleyDynamic, FRAMA, VIDYA, JMA, Alligator, EVWMA, APO, AwesomeOscillatorHistogram, CFO, ZeroLagMACD, ElderImpulse, STC, T3, TSI, PMO, TII, ADL, VolumePriceTrend, ChaikinMoneyFlow, ChaikinOscillator, ForceIndex, EaseOfMovement, KVO, VolumeOscillator, NVI, PVI, WilliamsAD, AnchoredVWAP, DemandIndex, TSV, VZO, MarketFacilitationIndex, SuperTrend, ChandelierExit, ChandeKrollStop, AtrTrailingStop, HiLoActivator, VoltyStop, YoyoExit, DonchianStop, PercentageTrailingStop, StepTrailingStop, RenkoTrailingStop, TypicalPrice, MedianPrice, WeightedClose, LinearRegression, LinRegSlope, AcceleratorOscillator, BalanceOfPower, ChoppinessIndex, TrueRange, ChaikinVolatility, LinRegAngle, BollingerBandwidth, PercentB, NATR, HistoricalVolatility, AroonOscillator, Vortex, RWI, WaveTrend, MassIndex, StochRSI, UltimateOscillator, PPO, Coppock, VWMA, RVIVolatility, ParkinsonVolatility, GarmanKlassVolatility, RogersSatchellVolatility, YangZhangVolatility, MaEnvelope, AccelerationBands, StarcBands, AtrBands, HurstChannel, LinRegChannel, StandardErrorBands, DoubleBollinger, TtmSqueeze, FractalChaosBands, VwapStdDevBands, ClassicPivots, FibonacciPivots, Camarilla, WoodiePivots, DemarkPivots, WilliamsFractals, ZigZag, TDSetup, TDSequential, TDDeMarker, TDREI, TDPressure, TDCombo, TDCountdown, TDLines, TDRangeProjection, TDDifferential, TDOpen, TDRiskLevel, SuperSmoother, FisherTransform, InverseFisherTransform, Decycler, DecyclerOscillator, RoofingFilter, CenterOfGravity, CyberneticCycle, InstantaneousTrendline, EhlersStochastic, EmpiricalModeDecomposition, HilbertDominantCycle, AdaptiveCycle, SineWave, MAMA, FAMA, Ichimoku, HeikinAshi } = nativeBinding +const { version, SMA, EMA, WMA, RSI, DEMA, TEMA, HMA, ROC, TRIX, SMMA, TRIMA, ZLEMA, MOM, CMO, DPO, StdDev, UlcerIndex, VerticalHorizontalFilter, ZScore, MACD, BollingerBands, ATR, Stochastic, OBV, ADX, ADXR, CCI, WilliamsR, MFI, PSAR, Keltner, Donchian, VWAP, RollingVWAP, AwesomeOscillator, Aroon, KAMA, RVI, PGO, KST, SMI, LaguerreRSI, ConnorsRSI, Inertia, ALMA, McGinleyDynamic, FRAMA, VIDYA, JMA, Alligator, EVWMA, APO, AwesomeOscillatorHistogram, CFO, ZeroLagMACD, ElderImpulse, STC, T3, TSI, PMO, TII, ADL, VolumePriceTrend, ChaikinMoneyFlow, ChaikinOscillator, ForceIndex, EaseOfMovement, KVO, VolumeOscillator, NVI, PVI, WilliamsAD, AnchoredVWAP, DemandIndex, TSV, VZO, MarketFacilitationIndex, SuperTrend, ChandelierExit, ChandeKrollStop, AtrTrailingStop, HiLoActivator, VoltyStop, YoyoExit, DonchianStop, PercentageTrailingStop, StepTrailingStop, RenkoTrailingStop, TypicalPrice, MedianPrice, WeightedClose, LinearRegression, LinRegSlope, AcceleratorOscillator, BalanceOfPower, ChoppinessIndex, TrueRange, ChaikinVolatility, LinRegAngle, BollingerBandwidth, PercentB, NATR, HistoricalVolatility, AroonOscillator, Vortex, RWI, WaveTrend, MassIndex, StochRSI, UltimateOscillator, PPO, Coppock, VWMA, RVIVolatility, ParkinsonVolatility, GarmanKlassVolatility, RogersSatchellVolatility, YangZhangVolatility, MaEnvelope, AccelerationBands, StarcBands, AtrBands, HurstChannel, LinRegChannel, StandardErrorBands, DoubleBollinger, TtmSqueeze, FractalChaosBands, VwapStdDevBands, ClassicPivots, FibonacciPivots, Camarilla, WoodiePivots, DemarkPivots, WilliamsFractals, ZigZag, TDSetup, TDSequential, TDDeMarker, TDREI, TDPressure, TDCombo, TDCountdown, TDLines, TDRangeProjection, TDDifferential, TDOpen, TDRiskLevel, SuperSmoother, FisherTransform, InverseFisherTransform, Decycler, DecyclerOscillator, RoofingFilter, CenterOfGravity, CyberneticCycle, InstantaneousTrendline, EhlersStochastic, EmpiricalModeDecomposition, HilbertDominantCycle, AdaptiveCycle, SineWave, MAMA, FAMA, Ichimoku, HeikinAshi, Variance, CoefficientOfVariation, Skewness, Kurtosis, StandardError, DetrendedStdDev, RSquared, MedianAbsoluteDeviation, Autocorrelation, HurstExponent, PearsonCorrelation, Beta, SpearmanCorrelation } = nativeBinding module.exports.version = version module.exports.SMA = SMA @@ -479,3 +479,16 @@ module.exports.MAMA = MAMA module.exports.FAMA = FAMA module.exports.Ichimoku = Ichimoku module.exports.HeikinAshi = HeikinAshi +module.exports.Variance = Variance +module.exports.CoefficientOfVariation = CoefficientOfVariation +module.exports.Skewness = Skewness +module.exports.Kurtosis = Kurtosis +module.exports.StandardError = StandardError +module.exports.DetrendedStdDev = DetrendedStdDev +module.exports.RSquared = RSquared +module.exports.MedianAbsoluteDeviation = MedianAbsoluteDeviation +module.exports.Autocorrelation = Autocorrelation +module.exports.HurstExponent = HurstExponent +module.exports.PearsonCorrelation = PearsonCorrelation +module.exports.Beta = Beta +module.exports.SpearmanCorrelation = SpearmanCorrelation diff --git a/bindings/node/src/lib.rs b/bindings/node/src/lib.rs index dfb9585b..642fb2b0 100644 --- a/bindings/node/src/lib.rs +++ b/bindings/node/src/lib.rs @@ -177,6 +177,162 @@ impl RviVolatilityNode { } } +node_scalar_indicator!(VarianceNode, "Variance", wc::Variance); +node_scalar_indicator!( + CoefficientOfVariationNode, + "CoefficientOfVariation", + wc::CoefficientOfVariation +); +node_scalar_indicator!(SkewnessNode, "Skewness", wc::Skewness); +node_scalar_indicator!(KurtosisNode, "Kurtosis", wc::Kurtosis); +node_scalar_indicator!(StandardErrorNode, "StandardError", wc::StandardError); +node_scalar_indicator!(DetrendedStdDevNode, "DetrendedStdDev", wc::DetrendedStdDev); +node_scalar_indicator!(RSquaredNode, "RSquared", wc::RSquared); +node_scalar_indicator!( + MedianAbsoluteDeviationNode, + "MedianAbsoluteDeviation", + wc::MedianAbsoluteDeviation +); + +// ============================== Autocorrelation (period + lag) ============================== + +#[napi(js_name = "Autocorrelation")] +pub struct AutocorrelationNode { + inner: wc::Autocorrelation, +} + +#[napi] +impl AutocorrelationNode { + #[napi(constructor)] + pub fn new(period: u32, lag: u32) -> napi::Result { + Ok(Self { + inner: wc::Autocorrelation::new(period as usize, lag 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 + } +} + +// ============================== HurstExponent (period + chunks) ============================== + +#[napi(js_name = "HurstExponent")] +pub struct HurstExponentNode { + inner: wc::HurstExponent, +} + +#[napi] +impl HurstExponentNode { + #[napi(constructor)] + pub fn new(period: u32, chunks: u32) -> napi::Result { + Ok(Self { + inner: wc::HurstExponent::new(period as usize, chunks 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 + } +} + +// ============================== Two-series indicators (Pearson / Beta / Spearman) ============================== + +macro_rules! node_pair_indicator { + ($wrapper:ident, $node_name:literal, $rust_ty:ty) => { + #[napi(js_name = $node_name)] + pub struct $wrapper { + inner: $rust_ty, + } + + #[napi] + impl $wrapper { + #[napi(constructor)] + pub fn new(period: u32) -> napi::Result { + Ok(Self { + inner: <$rust_ty>::new(period as usize).map_err(map_err)?, + }) + } + #[napi] + pub fn update(&mut self, x: f64, y: f64) -> Option { + self.inner.update((x, y)) + } + /// Batch over two equally-sized arrays. Returns a length-`n` array + /// with `NaN` for warmup positions. + #[napi] + pub fn batch(&mut self, x: Vec, y: Vec) -> napi::Result> { + if x.len() != y.len() { + return Err(NapiError::new( + Status::InvalidArg, + "x and y must be equal length".to_string(), + )); + } + let mut out = Vec::with_capacity(x.len()); + for i in 0..x.len() { + out.push(self.inner.update((x[i], y[i])).unwrap_or(f64::NAN)); + } + Ok(out) + } + #[napi] + pub fn reset(&mut self) { + self.inner.reset(); + } + #[napi(js_name = "isReady")] + pub fn is_ready(&self) -> bool { + self.inner.is_ready() + } + #[napi(js_name = "warmupPeriod")] + pub fn warmup_period(&self) -> u32 { + self.inner.warmup_period() as u32 + } + } + }; +} + +node_pair_indicator!( + PearsonCorrelationNode, + "PearsonCorrelation", + wc::PearsonCorrelation +); +node_pair_indicator!(BetaNode, "Beta", wc::Beta); +node_pair_indicator!( + SpearmanCorrelationNode, + "SpearmanCorrelation", + wc::SpearmanCorrelation +); + // ============================== MACD ============================== /// MACD triple: macd line, signal line, histogram. diff --git a/bindings/python/python/wickra/__init__.py b/bindings/python/python/wickra/__init__.py index 8bb7ae82..8aba987d 100644 --- a/bindings/python/python/wickra/__init__.py +++ b/bindings/python/python/wickra/__init__.py @@ -149,6 +149,19 @@ from ._wickra import ( LinRegSlope, ZScore, LinRegAngle, + Variance, + CoefficientOfVariation, + Skewness, + Kurtosis, + StandardError, + DetrendedStdDev, + RSquared, + Autocorrelation, + MedianAbsoluteDeviation, + HurstExponent, + PearsonCorrelation, + Beta, + SpearmanCorrelation, # Ehlers / Cycle SuperSmoother, FisherTransform, @@ -330,6 +343,19 @@ __all__ = [ "LinRegSlope", "ZScore", "LinRegAngle", + "Variance", + "CoefficientOfVariation", + "Skewness", + "Kurtosis", + "StandardError", + "DetrendedStdDev", + "RSquared", + "Autocorrelation", + "MedianAbsoluteDeviation", + "HurstExponent", + "PearsonCorrelation", + "Beta", + "SpearmanCorrelation", # Ehlers / Cycle "SuperSmoother", "FisherTransform", diff --git a/bindings/python/src/lib.rs b/bindings/python/src/lib.rs index 1720aec5..917377bd 100644 --- a/bindings/python/src/lib.rs +++ b/bindings/python/src/lib.rs @@ -10116,6 +10116,706 @@ impl PyHeikinAshi { } } +#[pyclass(name = "Variance", module = "wickra._wickra", skip_from_py_object)] +#[derive(Clone)] +struct PyVariance { + inner: wc::Variance, +} + +#[pymethods] +impl PyVariance { + #[new] + #[pyo3(signature = (period=20))] + fn new(period: usize) -> PyResult { + Ok(Self { + inner: wc::Variance::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!("Variance(period={})", self.inner.period()) + } +} + +// ============================== CoefficientOfVariation ============================== + +#[pyclass( + name = "CoefficientOfVariation", + module = "wickra._wickra", + skip_from_py_object +)] +#[derive(Clone)] +struct PyCoefficientOfVariation { + inner: wc::CoefficientOfVariation, +} + +#[pymethods] +impl PyCoefficientOfVariation { + #[new] + #[pyo3(signature = (period=20))] + fn new(period: usize) -> PyResult { + Ok(Self { + inner: wc::CoefficientOfVariation::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!("CoefficientOfVariation(period={})", self.inner.period()) + } +} + +// ============================== Skewness ============================== + +#[pyclass(name = "Skewness", module = "wickra._wickra", skip_from_py_object)] +#[derive(Clone)] +struct PySkewness { + inner: wc::Skewness, +} + +#[pymethods] +impl PySkewness { + #[new] + #[pyo3(signature = (period=20))] + fn new(period: usize) -> PyResult { + Ok(Self { + inner: wc::Skewness::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!("Skewness(period={})", self.inner.period()) + } +} + +// ============================== Kurtosis ============================== + +#[pyclass(name = "Kurtosis", module = "wickra._wickra", skip_from_py_object)] +#[derive(Clone)] +struct PyKurtosis { + inner: wc::Kurtosis, +} + +#[pymethods] +impl PyKurtosis { + #[new] + #[pyo3(signature = (period=20))] + fn new(period: usize) -> PyResult { + Ok(Self { + inner: wc::Kurtosis::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!("Kurtosis(period={})", self.inner.period()) + } +} + +// ============================== StandardError ============================== + +#[pyclass(name = "StandardError", module = "wickra._wickra", skip_from_py_object)] +#[derive(Clone)] +struct PyStandardError { + inner: wc::StandardError, +} + +#[pymethods] +impl PyStandardError { + #[new] + #[pyo3(signature = (period=14))] + fn new(period: usize) -> PyResult { + Ok(Self { + inner: wc::StandardError::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!("StandardError(period={})", self.inner.period()) + } +} + +// ============================== DetrendedStdDev ============================== + +#[pyclass( + name = "DetrendedStdDev", + module = "wickra._wickra", + skip_from_py_object +)] +#[derive(Clone)] +struct PyDetrendedStdDev { + inner: wc::DetrendedStdDev, +} + +#[pymethods] +impl PyDetrendedStdDev { + #[new] + #[pyo3(signature = (period=14))] + fn new(period: usize) -> PyResult { + Ok(Self { + inner: wc::DetrendedStdDev::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!("DetrendedStdDev(period={})", self.inner.period()) + } +} + +// ============================== RSquared ============================== + +#[pyclass(name = "RSquared", module = "wickra._wickra", skip_from_py_object)] +#[derive(Clone)] +struct PyRSquared { + inner: wc::RSquared, +} + +#[pymethods] +impl PyRSquared { + #[new] + #[pyo3(signature = (period=14))] + fn new(period: usize) -> PyResult { + Ok(Self { + inner: wc::RSquared::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!("RSquared(period={})", self.inner.period()) + } +} + +// ============================== Autocorrelation ============================== + +#[pyclass( + name = "Autocorrelation", + module = "wickra._wickra", + skip_from_py_object +)] +#[derive(Clone)] +struct PyAutocorrelation { + inner: wc::Autocorrelation, +} + +#[pymethods] +impl PyAutocorrelation { + #[new] + #[pyo3(signature = (period=20, lag=1))] + fn new(period: usize, lag: usize) -> PyResult { + Ok(Self { + inner: wc::Autocorrelation::new(period, lag).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() + } + #[getter] + fn lag(&self) -> usize { + self.inner.lag() + } + 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!( + "Autocorrelation(period={}, lag={})", + self.inner.period(), + self.inner.lag() + ) + } +} + +// ============================== MedianAbsoluteDeviation ============================== + +#[pyclass( + name = "MedianAbsoluteDeviation", + module = "wickra._wickra", + skip_from_py_object +)] +#[derive(Clone)] +struct PyMedianAbsoluteDeviation { + inner: wc::MedianAbsoluteDeviation, +} + +#[pymethods] +impl PyMedianAbsoluteDeviation { + #[new] + #[pyo3(signature = (period=20))] + fn new(period: usize) -> PyResult { + Ok(Self { + inner: wc::MedianAbsoluteDeviation::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!("MedianAbsoluteDeviation(period={})", self.inner.period()) + } +} + +// ============================== HurstExponent ============================== + +#[pyclass(name = "HurstExponent", module = "wickra._wickra", skip_from_py_object)] +#[derive(Clone)] +struct PyHurstExponent { + inner: wc::HurstExponent, +} + +#[pymethods] +impl PyHurstExponent { + #[new] + #[pyo3(signature = (period=100, chunks=4))] + fn new(period: usize, chunks: usize) -> PyResult { + Ok(Self { + inner: wc::HurstExponent::new(period, chunks).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() + } + #[getter] + fn chunks(&self) -> usize { + self.inner.chunks() + } + 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!( + "HurstExponent(period={}, chunks={})", + self.inner.period(), + self.inner.chunks() + ) + } +} + +// ============================== PearsonCorrelation ============================== + +#[pyclass( + name = "PearsonCorrelation", + module = "wickra._wickra", + skip_from_py_object +)] +#[derive(Clone)] +struct PyPearsonCorrelation { + inner: wc::PearsonCorrelation, +} + +#[pymethods] +impl PyPearsonCorrelation { + #[new] + #[pyo3(signature = (period=20))] + fn new(period: usize) -> PyResult { + Ok(Self { + inner: wc::PearsonCorrelation::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() + } + 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!("PearsonCorrelation(period={})", self.inner.period()) + } +} + +// ============================== Beta ============================== + +#[pyclass(name = "Beta", module = "wickra._wickra", skip_from_py_object)] +#[derive(Clone)] +struct PyBeta { + inner: wc::Beta, +} + +#[pymethods] +impl PyBeta { + #[new] + #[pyo3(signature = (period=20))] + fn new(period: usize) -> PyResult { + Ok(Self { + inner: wc::Beta::new(period).map_err(map_err)?, + }) + } + fn update(&mut self, asset: f64, benchmark: f64) -> Option { + self.inner.update((asset, benchmark)) + } + /// Batch over two equally-sized numpy arrays: asset and benchmark. + fn batch<'py>( + &mut self, + py: Python<'py>, + asset: PyReadonlyArray1<'py, f64>, + benchmark: PyReadonlyArray1<'py, f64>, + ) -> PyResult>> { + let a = asset + .as_slice() + .map_err(|_| PyValueError::new_err(NON_CONTIGUOUS))?; + let b = benchmark + .as_slice() + .map_err(|_| PyValueError::new_err(NON_CONTIGUOUS))?; + if a.len() != b.len() { + return Err(PyValueError::new_err( + "asset and benchmark must be equal length", + )); + } + let mut out = Vec::with_capacity(a.len()); + for i in 0..a.len() { + out.push(self.inner.update((a[i], b[i])).unwrap_or(f64::NAN)); + } + Ok(out.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!("Beta(period={})", self.inner.period()) + } +} + +// ============================== SpearmanCorrelation ============================== + +#[pyclass( + name = "SpearmanCorrelation", + module = "wickra._wickra", + skip_from_py_object +)] +#[derive(Clone)] +struct PySpearmanCorrelation { + inner: wc::SpearmanCorrelation, +} + +#[pymethods] +impl PySpearmanCorrelation { + #[new] + #[pyo3(signature = (period=20))] + fn new(period: usize) -> PyResult { + Ok(Self { + inner: wc::SpearmanCorrelation::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() + } + 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!("SpearmanCorrelation(period={})", self.inner.period()) + } +} + // ============================== Module ============================== #[pymodule] @@ -10291,5 +10991,18 @@ fn _wickra(_py: Python<'_>, m: &Bound<'_, PyModule>) -> PyResult<()> { // Family 13 — Ichimoku & alternative charts m.add_class::()?; m.add_class::()?; + m.add_class::()?; + m.add_class::()?; + m.add_class::()?; + m.add_class::()?; + m.add_class::()?; + 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 bee8fb16..c6bb6105 100644 --- a/bindings/python/tests/test_new_indicators.py +++ b/bindings/python/tests/test_new_indicators.py @@ -95,6 +95,17 @@ SCALAR = [ (ta.AdaptiveCycle, ()), (ta.SineWave, ()), (ta.FAMA, (0.5, 0.05)), + # Family 12 — Statistik / Regression + (ta.Variance, (20,)), + (ta.CoefficientOfVariation, (20,)), + (ta.Skewness, (20,)), + (ta.Kurtosis, (20,)), + (ta.StandardError, (14,)), + (ta.DetrendedStdDev, (14,)), + (ta.RSquared, (14,)), + (ta.MedianAbsoluteDeviation, (20,)), + (ta.Autocorrelation, (20, 1)), + (ta.HurstExponent, (40, 4)), ] @@ -908,6 +919,111 @@ def test_z_score_reference(): assert out[1] == pytest.approx(1.0) +# --- Family 12: Statistik / Regression reference values ------------------ + + +def test_variance_reference(): + # Variance(3) of [2, 4, 6]: mean 4, variance (4 + 0 + 4) / 3 = 8/3. + out = ta.Variance(3).batch(np.array([2.0, 4.0, 6.0])) + assert math.isnan(out[1]) + assert out[2] == pytest.approx(8.0 / 3.0) + + +def test_coefficient_of_variation_reference(): + # CV(3) of [2, 4, 6]: sd / mean = sqrt(8/3) / 4. + out = ta.CoefficientOfVariation(3).batch(np.array([2.0, 4.0, 6.0])) + assert out[2] == pytest.approx(math.sqrt(8.0 / 3.0) / 4.0) + + +def test_skewness_symmetric_window_is_zero(): + # Symmetric window has zero Pearson skewness. + out = ta.Skewness(5).batch(np.array([-2.0, -1.0, 0.0, 1.0, 2.0])) + assert out[4] == pytest.approx(0.0, abs=1e-9) + + +def test_kurtosis_two_point_distribution_minimum(): + # Alternating {-1, 1} has m4/m2² = 1, so excess kurtosis = -2. + out = ta.Kurtosis(4).batch(np.array([-1.0, 1.0, -1.0, 1.0])) + assert out[3] == pytest.approx(-2.0, abs=1e-9) + + +def test_standard_error_perfect_line_is_zero(): + # Residuals are zero on a perfectly linear series. + out = ta.StandardError(5).batch(np.linspace(1.0, 20.0, num=20, dtype=np.float64)) + finite = out[~np.isnan(out)] + assert np.allclose(finite, 0.0, atol=1e-9) + + +def test_detrended_std_dev_perfect_line_is_zero(): + out = ta.DetrendedStdDev(5).batch(np.linspace(1.0, 20.0, num=20, dtype=np.float64)) + finite = out[~np.isnan(out)] + assert np.allclose(finite, 0.0, atol=1e-9) + + +def test_r_squared_perfect_line_is_one(): + out = ta.RSquared(5).batch(np.linspace(1.0, 20.0, num=20, dtype=np.float64)) + finite = out[~np.isnan(out)] + assert np.allclose(finite, 1.0, atol=1e-9) + + +def test_median_absolute_deviation_ignores_single_outlier(): + # 9 equal values + 1 huge outlier: MAD is still 0 (more than half agree). + prices = np.array([5.0] * 9 + [1000.0], dtype=np.float64) + out = ta.MedianAbsoluteDeviation(10).batch(prices) + assert out[9] == pytest.approx(0.0, abs=1e-12) + + +def test_autocorrelation_alternating_series_negative(): + # ±1 alternating: lag-1 ACF must be strongly negative. + prices = np.array([-1.0 if i % 2 == 0 else 1.0 for i in range(20)], dtype=np.float64) + out = ta.Autocorrelation(10, 1).batch(prices) + assert out[-1] < -0.5 + + +def test_hurst_exponent_trending_above_half(): + # A clean monotone ramp is the textbook persistent series. + prices = np.arange(200, dtype=np.float64) + out = ta.HurstExponent(100, 4).batch(prices) + assert out[-1] > 0.5 + + +def test_pearson_correlation_perfect_positive_is_one(): + x = np.arange(10, dtype=np.float64) + y = 2.0 * x + 3.0 + out = ta.PearsonCorrelation(5).batch(x, y) + assert out[-1] == pytest.approx(1.0, abs=1e-9) + + +def test_beta_perfect_two_to_one(): + benchmark = np.arange(10, dtype=np.float64) + asset = 2.0 * benchmark + out = ta.Beta(5).batch(asset, benchmark) + assert out[-1] == pytest.approx(2.0, abs=1e-9) + + +def test_spearman_correlation_monotone_nonlinear_is_one(): + # y = x^3 is monotone non-linear; Spearman = 1 (Pearson would not be). + x = np.arange(1.0, 11.0, dtype=np.float64) + y = x**3 + out = ta.SpearmanCorrelation(5).batch(x, y) + assert out[-1] == pytest.approx(1.0, abs=1e-9) + + +def test_pair_indicators_streaming_matches_batch(): + rng = np.linspace(0.0, 10.0, num=60, dtype=np.float64) + x = np.sin(rng) + 0.1 * rng + y = np.cos(rng * 0.3) + 0.05 * rng + + for cls, args in [(ta.PearsonCorrelation, (14,)), (ta.Beta, (14,)), (ta.SpearmanCorrelation, (14,))]: + batch = cls(*args).batch(x, y) + streamer = cls(*args) + streamed = [] + for i in range(x.size): + v = streamer.update(float(x[i]), float(y[i])) + streamed.append(math.nan if v is None else float(v)) + assert _eq_nan(batch, np.array(streamed, dtype=np.float64)), f"{cls.__name__} mismatch" + + # --- Family 10 — Ehlers / Cycle --- diff --git a/bindings/wasm/src/lib.rs b/bindings/wasm/src/lib.rs index d51ba4a5..7f87d35b 100644 --- a/bindings/wasm/src/lib.rs +++ b/bindings/wasm/src/lib.rs @@ -227,6 +227,16 @@ wasm_scalar_indicator!(WasmLinRegSlope, "LinRegSlope", wc::LinRegSlope, period: wasm_scalar_indicator!(WasmVerticalHorizontalFilter, "VerticalHorizontalFilter", wc::VerticalHorizontalFilter, period: usize); wasm_scalar_indicator!(WasmZScore, "ZScore", wc::ZScore, period: usize); wasm_scalar_indicator!(WasmLinRegAngle, "LinRegAngle", wc::LinRegAngle, period: usize); +wasm_scalar_indicator!(WasmVariance, "Variance", wc::Variance, period: usize); +wasm_scalar_indicator!(WasmCoefficientOfVariation, "CoefficientOfVariation", wc::CoefficientOfVariation, period: usize); +wasm_scalar_indicator!(WasmSkewness, "Skewness", wc::Skewness, period: usize); +wasm_scalar_indicator!(WasmKurtosis, "Kurtosis", wc::Kurtosis, period: usize); +wasm_scalar_indicator!(WasmStandardError, "StandardError", wc::StandardError, period: usize); +wasm_scalar_indicator!(WasmDetrendedStdDev, "DetrendedStdDev", wc::DetrendedStdDev, period: usize); +wasm_scalar_indicator!(WasmRSquared, "RSquared", wc::RSquared, period: usize); +wasm_scalar_indicator!(WasmMedianAbsoluteDeviation, "MedianAbsoluteDeviation", wc::MedianAbsoluteDeviation, period: usize); +wasm_scalar_indicator!(WasmAutocorrelation, "Autocorrelation", wc::Autocorrelation, period: usize, lag: usize); +wasm_scalar_indicator!(WasmHurstExponent, "HurstExponent", wc::HurstExponent, period: usize, chunks: usize); wasm_scalar_indicator!(WasmRviVolatility, "RVIVolatility", wc::RviVolatility, period: usize); wasm_scalar_indicator!(WasmLaguerreRsi, "LaguerreRSI", wc::LaguerreRsi, gamma: f64); wasm_scalar_indicator!(WasmConnorsRsi, "ConnorsRSI", wc::ConnorsRsi, period_rsi: usize, period_streak: usize, period_rank: usize); @@ -462,6 +472,65 @@ wasm_scalar_indicator!(WasmEhlersStochastic, "EhlersStochastic", wc::EhlersStoch wasm_scalar_indicator!(WasmEmpiricalModeDecomposition, "EmpiricalModeDecomposition", wc::EmpiricalModeDecomposition, period: usize, fraction: f64); wasm_scalar_indicator!(WasmFama, "FAMA", wc::Fama, fast_limit: f64, slow_limit: f64); +// ---------- Family 12: Two-series indicators (Pearson / Beta / Spearman) ---------- + +macro_rules! wasm_pair_indicator { + ($name:ident, $js_name:literal, $rust_ty:ty) => { + #[wasm_bindgen(js_name = $js_name)] + pub struct $name { + inner: $rust_ty, + } + + #[wasm_bindgen(js_class = $js_name)] + impl $name { + #[wasm_bindgen(constructor)] + pub fn new(period: usize) -> Result<$name, JsError> { + Ok($name { + inner: <$rust_ty>::new(period).map_err(map_err)?, + }) + } + pub fn update(&mut self, x: f64, y: f64) -> Option { + self.inner.update((x, y)) + } + /// Batch over two equally-sized arrays. Returns one `f64` per + /// input position (`NaN` during warmup). + pub fn batch(&mut self, x: &[f64], y: &[f64]) -> Result { + if x.len() != y.len() { + return Err(JsError::new("x and y must be equal length")); + } + let mut out = Vec::with_capacity(x.len()); + for i in 0..x.len() { + out.push(self.inner.update((x[i], y[i])).unwrap_or(f64::NAN)); + } + Ok(Float64Array::from(out.as_slice())) + } + pub fn reset(&mut self) { + self.inner.reset(); + } + #[wasm_bindgen(js_name = isReady)] + pub fn is_ready(&self) -> bool { + self.inner.is_ready() + } + #[wasm_bindgen(js_name = warmupPeriod)] + pub fn warmup_period(&self) -> usize { + self.inner.warmup_period() + } + } + }; +} + +wasm_pair_indicator!( + WasmPearsonCorrelation, + "PearsonCorrelation", + wc::PearsonCorrelation +); +wasm_pair_indicator!(WasmBeta, "Beta", wc::Beta); +wasm_pair_indicator!( + WasmSpearmanCorrelation, + "SpearmanCorrelation", + wc::SpearmanCorrelation +); + // ---------- KAMA (three params) ---------- #[wasm_bindgen(js_name = KAMA)] diff --git a/crates/wickra-core/src/indicators/autocorrelation.rs b/crates/wickra-core/src/indicators/autocorrelation.rs new file mode 100644 index 00000000..a6763adf --- /dev/null +++ b/crates/wickra-core/src/indicators/autocorrelation.rs @@ -0,0 +1,221 @@ +//! Rolling lag-`k` autocorrelation. + +use std::collections::VecDeque; + +use crate::error::{Error, Result}; +use crate::traits::Indicator; + +/// Rolling lag-`lag` autocorrelation of the last `period` inputs. +/// +/// Over the trailing window the Pearson correlation between the series and +/// itself shifted by `lag` is computed: +/// +/// ```text +/// y_i for i = 0..period − 1 +/// ACF(lag) = Σ ( (y_i − ȳ) · (y_{i + lag} − ȳ) ) / Σ ( y_i − ȳ )² +/// ``` +/// +/// `+1` means a perfectly repeating pattern at the given lag; `−1` means a +/// perfect alternation. Values near `0` mean the series at `t` and `t − +/// lag` carry no linear relationship — a clean white-noise proxy. The +/// classic application is detecting periodicity (a peak in `|ACF(lag)|` +/// flags a cycle of that length) or testing whether returns are +/// uncorrelated (a key efficient-markets diagnostic). +/// +/// `period` must be strictly greater than `lag` so that at least two +/// `(y, y_lagged)` pairs exist. A flat window has zero variance; the +/// indicator returns `0` rather than dividing by zero. +/// +/// # Example +/// +/// ``` +/// use wickra_core::{Autocorrelation, Indicator}; +/// +/// let mut indicator = Autocorrelation::new(20, 1).unwrap(); +/// let mut last = None; +/// for i in 0..40 { +/// last = indicator.update(f64::from(i)); +/// } +/// assert!(last.is_some()); +/// ``` +#[derive(Debug, Clone)] +pub struct Autocorrelation { + period: usize, + lag: usize, + window: VecDeque, +} + +impl Autocorrelation { + /// Construct a new rolling lag-`lag` autocorrelation over `period` inputs. + /// + /// # Errors + /// Returns [`Error::InvalidPeriod`] if `lag == 0` or `lag >= period`. + pub fn new(period: usize, lag: usize) -> Result { + if lag == 0 { + return Err(Error::InvalidPeriod { + message: "autocorrelation lag must be >= 1", + }); + } + if period <= lag { + return Err(Error::InvalidPeriod { + message: "autocorrelation needs period > lag", + }); + } + Ok(Self { + period, + lag, + window: VecDeque::with_capacity(period), + }) + } + + /// Configured window period. + pub const fn period(&self) -> usize { + self.period + } + + /// Configured lag. + pub const fn lag(&self) -> usize { + self.lag + } +} + +impl Indicator for Autocorrelation { + type Input = f64; + type Output = f64; + + fn update(&mut self, value: f64) -> Option { + if self.window.len() == self.period { + self.window.pop_front(); + } + self.window.push_back(value); + if self.window.len() < self.period { + return None; + } + // ACF over the current window with a single inner pass. The window is + // small relative to a typical input stream so the O(period) per-bar + // cost is bounded by the user-chosen `period`; the constant factor + // is dominated by two adds and one multiply per element. + let n = self.period as f64; + let mean = self.window.iter().sum::() / n; + let mut denom = 0.0; + let mut numer = 0.0; + // The window is a deque; index via slices for cache-friendly access. + let (front, back) = self.window.as_slices(); + let get = |i: usize| -> f64 { + if i < front.len() { + front[i] + } else { + back[i - front.len()] + } + }; + for i in 0..self.period { + let d = get(i) - mean; + denom += d * d; + } + let lag = self.lag; + for i in 0..(self.period - lag) { + numer += (get(i) - mean) * (get(i + lag) - mean); + } + if denom == 0.0 { + return Some(0.0); + } + Some(numer / denom) + } + + fn reset(&mut self) { + self.window.clear(); + } + + fn warmup_period(&self) -> usize { + self.period + } + + fn is_ready(&self) -> bool { + self.window.len() == self.period + } + + fn name(&self) -> &'static str { + "Autocorrelation" + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::traits::BatchExt; + use approx::assert_relative_eq; + + #[test] + fn rejects_zero_lag() { + assert!(Autocorrelation::new(10, 0).is_err()); + } + + #[test] + fn rejects_lag_geq_period() { + assert!(Autocorrelation::new(5, 5).is_err()); + assert!(Autocorrelation::new(5, 10).is_err()); + } + + #[test] + fn accessors_and_metadata() { + let a = Autocorrelation::new(14, 2).unwrap(); + assert_eq!(a.period(), 14); + assert_eq!(a.lag(), 2); + assert_eq!(a.warmup_period(), 14); + assert_eq!(a.name(), "Autocorrelation"); + } + + #[test] + fn constant_series_yields_zero() { + let mut a = Autocorrelation::new(10, 1).unwrap(); + for v in a.batch(&[42.0; 30]).into_iter().flatten() { + assert_relative_eq!(v, 0.0, epsilon = 1e-12); + } + } + + #[test] + fn alternating_series_lag_one_is_strongly_negative() { + // [−1, 1, −1, 1, …] alternates each step. + let prices: Vec = (0..20) + .map(|i| if i % 2 == 0 { -1.0 } else { 1.0 }) + .collect(); + let mut a = Autocorrelation::new(10, 1).unwrap(); + let last = a.batch(&prices).into_iter().flatten().last().unwrap(); + assert!( + last < -0.5, + "alternating series should be strongly negative, got {last}" + ); + } + + #[test] + fn repeating_series_is_strongly_positive_at_period() { + // A series that repeats every 4 steps must have ACF(4) ≈ +1. + let pattern = [1.0, 2.0, 3.0, 4.0]; + let prices: Vec = (0..32).map(|i| pattern[i % 4]).collect(); + let mut a = Autocorrelation::new(16, 4).unwrap(); + let last = a.batch(&prices).into_iter().flatten().last().unwrap(); + assert!( + last > 0.5, + "period-4 repeat should ACF(4) > 0.5, got {last}" + ); + } + + #[test] + fn reset_clears_state() { + let mut a = Autocorrelation::new(5, 1).unwrap(); + a.batch(&[1.0, 2.0, 3.0, 4.0, 5.0]); + assert!(a.is_ready()); + a.reset(); + assert!(!a.is_ready()); + assert_eq!(a.update(1.0), None); + } + + #[test] + fn batch_equals_streaming() { + let prices: Vec = (0..60).map(|i| (f64::from(i) * 0.3).sin()).collect(); + let batch = Autocorrelation::new(14, 2).unwrap().batch(&prices); + let mut b = Autocorrelation::new(14, 2).unwrap(); + let streamed: Vec<_> = prices.iter().map(|p| b.update(*p)).collect(); + assert_eq!(batch, streamed); + } +} diff --git a/crates/wickra-core/src/indicators/beta.rs b/crates/wickra-core/src/indicators/beta.rs new file mode 100644 index 00000000..95763049 --- /dev/null +++ b/crates/wickra-core/src/indicators/beta.rs @@ -0,0 +1,228 @@ +//! Rolling Beta — sensitivity of an asset to a benchmark. + +use std::collections::VecDeque; + +use crate::error::{Error, Result}; +use crate::traits::Indicator; + +/// Rolling Beta of an `asset` series relative to a `benchmark` series. +/// +/// Each `update` receives one `(asset, benchmark)` pair. Over the trailing +/// window of `period` pairs: +/// +/// ```text +/// cov_ab = (1/n) · Σ a·b − ā·b̄ +/// var_b = (1/n) · Σ b² − b̄² +/// Beta = cov_ab / var_b +/// ``` +/// +/// Beta measures how much the asset moves for a unit move in the +/// benchmark. A reading of `1.0` means the two move together one-for-one; +/// `2.0` means the asset typically doubles the benchmark's moves; +/// `0.5` means it moves only half as much; `0.0` means moves are +/// uncorrelated; negative Betas signal a hedge. It is the slope of the +/// OLS regression of the asset on the benchmark and the foundation of the +/// CAPM. Unlike [`crate::PearsonCorrelation`], Beta is *not* unit-free — +/// it carries the ratio of standard deviations. +/// +/// Each `update` is O(1): four running sums (`Σa`, `Σb`, `Σb²`, `Σa·b`) +/// are maintained as the window slides. A flat benchmark window has zero +/// variance and Beta is undefined; the indicator returns `0` in that +/// case rather than producing `NaN`. +/// +/// Conventionally Beta is computed on **returns** (typically log-returns) +/// rather than raw prices; feed the indicator pre-computed returns if +/// that is your convention. The pure rolling OLS slope is the same +/// either way. +/// +/// # Example +/// +/// ``` +/// use wickra_core::{Beta, Indicator}; +/// +/// let mut indicator = Beta::new(20).unwrap(); +/// let mut last = None; +/// for i in 0..40 { +/// // Asset doubles every benchmark move. +/// last = indicator.update((2.0 * f64::from(i), f64::from(i))); +/// } +/// assert!((last.unwrap() - 2.0).abs() < 1e-9); +/// ``` +#[derive(Debug, Clone)] +pub struct Beta { + period: usize, + window: VecDeque<(f64, f64)>, + sum_a: f64, + sum_b: f64, + sum_bb: f64, + sum_ab: f64, +} + +impl Beta { + /// Construct a new rolling Beta. + /// + /// # Errors + /// Returns [`Error::InvalidPeriod`] if `period < 2`. + pub fn new(period: usize) -> Result { + if period < 2 { + return Err(Error::InvalidPeriod { + message: "beta needs period >= 2", + }); + } + Ok(Self { + period, + window: VecDeque::with_capacity(period), + sum_a: 0.0, + sum_b: 0.0, + sum_bb: 0.0, + sum_ab: 0.0, + }) + } + + /// Configured period. + pub const fn period(&self) -> usize { + self.period + } +} + +impl Indicator for Beta { + /// `(asset, benchmark)` pair. + type Input = (f64, f64); + type Output = f64; + + fn update(&mut self, input: (f64, f64)) -> Option { + let (a, b) = input; + if self.window.len() == self.period { + let (oa, ob) = self.window.pop_front().expect("non-empty"); + self.sum_a -= oa; + self.sum_b -= ob; + self.sum_bb -= ob * ob; + self.sum_ab -= oa * ob; + } + self.window.push_back((a, b)); + self.sum_a += a; + self.sum_b += b; + self.sum_bb += b * b; + self.sum_ab += a * b; + if self.window.len() < self.period { + return None; + } + let n = self.period as f64; + let mean_a = self.sum_a / n; + let mean_b = self.sum_b / n; + let var_b = (self.sum_bb / n - mean_b * mean_b).max(0.0); + let cov = self.sum_ab / n - mean_a * mean_b; + if var_b == 0.0 { + // A flat benchmark has no defined beta. + return Some(0.0); + } + Some(cov / var_b) + } + + fn reset(&mut self) { + self.window.clear(); + self.sum_a = 0.0; + self.sum_b = 0.0; + self.sum_bb = 0.0; + self.sum_ab = 0.0; + } + + fn warmup_period(&self) -> usize { + self.period + } + + fn is_ready(&self) -> bool { + self.window.len() == self.period + } + + fn name(&self) -> &'static str { + "Beta" + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::traits::BatchExt; + use approx::assert_relative_eq; + + #[test] + fn rejects_period_below_two() { + assert!(Beta::new(0).is_err()); + assert!(Beta::new(1).is_err()); + assert!(Beta::new(2).is_ok()); + } + + #[test] + fn accessors_and_metadata() { + let b = Beta::new(14).unwrap(); + assert_eq!(b.period(), 14); + assert_eq!(b.warmup_period(), 14); + assert_eq!(b.name(), "Beta"); + } + + #[test] + fn perfect_two_to_one_relationship() { + let pairs: Vec<(f64, f64)> = (0..10) + .map(|i| (2.0 * f64::from(i), f64::from(i))) + .collect(); + let last = Beta::new(5) + .unwrap() + .batch(&pairs) + .into_iter() + .flatten() + .last() + .unwrap(); + assert_relative_eq!(last, 2.0, epsilon = 1e-9); + } + + #[test] + fn perfect_negative_one() { + let pairs: Vec<(f64, f64)> = (0..10).map(|i| (-f64::from(i), f64::from(i))).collect(); + let last = Beta::new(5) + .unwrap() + .batch(&pairs) + .into_iter() + .flatten() + .last() + .unwrap(); + assert_relative_eq!(last, -1.0, epsilon = 1e-9); + } + + #[test] + fn constant_benchmark_yields_zero() { + let pairs: Vec<(f64, f64)> = (0..10).map(|i| (f64::from(i), 7.0)).collect(); + let last = Beta::new(5) + .unwrap() + .batch(&pairs) + .into_iter() + .flatten() + .last() + .unwrap(); + assert_relative_eq!(last, 0.0, epsilon = 1e-12); + } + + #[test] + fn reset_clears_state() { + let mut b = Beta::new(5).unwrap(); + b.batch(&[(1.0, 2.0), (2.0, 4.0), (3.0, 6.0), (4.0, 8.0), (5.0, 10.0)]); + assert!(b.is_ready()); + b.reset(); + assert!(!b.is_ready()); + assert_eq!(b.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() * 2.0 + 0.3 * t.cos(), t.sin()) + }) + .collect(); + let batch = Beta::new(14).unwrap().batch(&pairs); + let mut b = Beta::new(14).unwrap(); + let streamed: Vec<_> = pairs.iter().map(|p| b.update(*p)).collect(); + assert_eq!(batch, streamed); + } +} diff --git a/crates/wickra-core/src/indicators/coefficient_of_variation.rs b/crates/wickra-core/src/indicators/coefficient_of_variation.rs new file mode 100644 index 00000000..c16fba87 --- /dev/null +++ b/crates/wickra-core/src/indicators/coefficient_of_variation.rs @@ -0,0 +1,184 @@ +//! Rolling Coefficient of Variation (`StdDev / Mean`). + +use std::collections::VecDeque; + +use crate::error::{Error, Result}; +use crate::traits::Indicator; + +/// Coefficient of Variation — the rolling population standard deviation +/// divided by the rolling mean. +/// +/// ```text +/// mean = (1/n) · Σ price +/// sd = √( (1/n) · Σ price² − mean² ) +/// CV = sd / mean +/// ``` +/// +/// CV is a dimensionless dispersion measure: it scales `StdDev` by the price +/// level so two assets at very different price magnitudes can be compared +/// directly. A higher CV means more relative variability for the same +/// average price. +/// +/// When the rolling mean is exactly zero the ratio is undefined; the +/// indicator returns `0.0` in that degenerate case rather than producing a +/// `NaN`/infinity. +/// +/// # Example +/// +/// ``` +/// use wickra_core::{CoefficientOfVariation, Indicator}; +/// +/// let mut indicator = CoefficientOfVariation::new(20).unwrap(); +/// let mut last = None; +/// for i in 0..40 { +/// last = indicator.update(100.0 + f64::from(i)); +/// } +/// assert!(last.is_some()); +/// ``` +#[derive(Debug, Clone)] +pub struct CoefficientOfVariation { + period: usize, + window: VecDeque, + sum: f64, + sum_sq: f64, +} + +impl CoefficientOfVariation { + /// Construct a new rolling CV with the given period. + /// + /// # Errors + /// Returns [`Error::PeriodZero`] if `period == 0`. + pub fn new(period: usize) -> Result { + if period == 0 { + return Err(Error::PeriodZero); + } + Ok(Self { + period, + window: VecDeque::with_capacity(period), + sum: 0.0, + sum_sq: 0.0, + }) + } + + /// Configured period. + pub const fn period(&self) -> usize { + self.period + } +} + +impl Indicator for CoefficientOfVariation { + type Input = f64; + type Output = f64; + + fn update(&mut self, value: f64) -> Option { + if self.window.len() == self.period { + let old = self.window.pop_front().expect("non-empty"); + self.sum -= old; + self.sum_sq -= old * old; + } + self.window.push_back(value); + self.sum += value; + self.sum_sq += value * value; + if self.window.len() < self.period { + return None; + } + let n = self.period as f64; + let mean = self.sum / n; + let variance = (self.sum_sq / n - mean * mean).max(0.0); + let sd = variance.sqrt(); + if mean == 0.0 { + // Undefined ratio: return 0 instead of NaN/inf so downstream + // consumers can keep arithmetic going on flat or zeroed series. + return Some(0.0); + } + Some(sd / mean) + } + + fn reset(&mut self) { + self.window.clear(); + self.sum = 0.0; + self.sum_sq = 0.0; + } + + fn warmup_period(&self) -> usize { + self.period + } + + fn is_ready(&self) -> bool { + self.window.len() == self.period + } + + fn name(&self) -> &'static str { + "CoefficientOfVariation" + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::traits::BatchExt; + use approx::assert_relative_eq; + + #[test] + fn rejects_zero_period() { + assert!(matches!( + CoefficientOfVariation::new(0), + Err(Error::PeriodZero) + )); + } + + #[test] + fn accessors_and_metadata() { + let cv = CoefficientOfVariation::new(14).unwrap(); + assert_eq!(cv.period(), 14); + assert_eq!(cv.warmup_period(), 14); + assert_eq!(cv.name(), "CoefficientOfVariation"); + } + + #[test] + fn reference_value() { + // CV(3) of [2, 4, 6]: mean = 4, variance = 8/3, sd = √(8/3); CV = sd / 4. + let mut cv = CoefficientOfVariation::new(3).unwrap(); + let out = cv.batch(&[2.0, 4.0, 6.0]); + assert_eq!(out[0], None); + let expected = (8.0_f64 / 3.0).sqrt() / 4.0; + assert_relative_eq!(out[2].unwrap(), expected, epsilon = 1e-12); + } + + #[test] + fn constant_series_yields_zero() { + let mut cv = CoefficientOfVariation::new(5).unwrap(); + for o in cv.batch(&[42.0; 20]).into_iter().flatten() { + assert_relative_eq!(o, 0.0, epsilon = 1e-12); + } + } + + #[test] + fn zero_mean_returns_zero() { + // [-1, 0, 1] has mean 0; the CV is defined to be 0 rather than NaN. + let mut cv = CoefficientOfVariation::new(3).unwrap(); + let out = cv.batch(&[-1.0, 0.0, 1.0]); + assert_relative_eq!(out[2].unwrap(), 0.0, epsilon = 1e-12); + } + + #[test] + fn reset_clears_state() { + let mut cv = CoefficientOfVariation::new(5).unwrap(); + cv.batch(&[1.0, 2.0, 3.0, 4.0, 5.0]); + assert!(cv.is_ready()); + cv.reset(); + assert!(!cv.is_ready()); + assert_eq!(cv.update(1.0), None); + } + + #[test] + fn batch_equals_streaming() { + let prices: Vec = (0..60) + .map(|i| 100.0 + (f64::from(i) * 0.4).sin() * 5.0) + .collect(); + let batch = CoefficientOfVariation::new(14).unwrap().batch(&prices); + let mut b = CoefficientOfVariation::new(14).unwrap(); + let streamed: Vec<_> = prices.iter().map(|p| b.update(*p)).collect(); + assert_eq!(batch, streamed); + } +} diff --git a/crates/wickra-core/src/indicators/detrended_std_dev.rs b/crates/wickra-core/src/indicators/detrended_std_dev.rs new file mode 100644 index 00000000..054ab23c --- /dev/null +++ b/crates/wickra-core/src/indicators/detrended_std_dev.rs @@ -0,0 +1,221 @@ +//! Population standard deviation of residuals from a rolling OLS detrend. + +use std::collections::VecDeque; + +use crate::error::{Error, Result}; +use crate::traits::Indicator; + +/// Detrended (residual) standard deviation over the last `period` inputs. +/// +/// Over the trailing window indexed `x = 0, 1, …, period − 1` the OLS line +/// `y = a + b·x` is fitted and the residual sum of squares is then divided +/// by `n` (population convention): +/// +/// ```text +/// slope = (n·Σxy − Σx·Σy) / (n·Σxx − (Σx)²) +/// SS_total = Σy² − n·ȳ² +/// RSS = SS_total − slope² · ( denom / n ) +/// DetrendedStdDev = √( RSS / n ) +/// ``` +/// +/// Unlike [`crate::StdDev`], which measures dispersion around the rolling +/// **mean**, `DetrendedStdDev` measures dispersion around the rolling +/// **linear trend** — the portion of the price action that is *not* +/// explained by the local slope. On a strongly trending series this is +/// much smaller than `StdDev`; on a sideways, mean-reverting series the +/// two converge. +/// +/// The divisor is `n` (population), matching the convention of +/// [`crate::StdDev`]; use [`crate::StandardError`] when you want the +/// textbook standard error of estimate with `n − 2` residual degrees of +/// freedom. +/// +/// Each `update` is O(1) via the same rolling sums as +/// [`crate::LinearRegression`], plus a running `Σy²`. Floating-point +/// cancellation noise in the residual is clamped to zero before the square +/// root. +/// +/// # Example +/// +/// ``` +/// use wickra_core::{DetrendedStdDev, Indicator}; +/// +/// let mut indicator = DetrendedStdDev::new(14).unwrap(); +/// let mut last = None; +/// for i in 0..40 { +/// last = indicator.update(100.0 + f64::from(i) + (f64::from(i) * 0.3).sin()); +/// } +/// assert!(last.is_some()); +/// ``` +#[derive(Debug, Clone)] +pub struct DetrendedStdDev { + period: usize, + window: VecDeque, + sum_x: f64, + /// `n·Σxx − (Σx)²` — OLS denominator, constant in `period`. + denom: f64, + sum_y: f64, + sum_xy: f64, + sum_y_sq: f64, +} + +impl DetrendedStdDev { + /// Construct a new rolling detrended standard deviation. + /// + /// # Errors + /// Returns [`Error::InvalidPeriod`] if `period < 2` — a regression line + /// is undefined for fewer than two points. + pub fn new(period: usize) -> Result { + if period < 2 { + return Err(Error::InvalidPeriod { + message: "detrended stddev needs period >= 2", + }); + } + let n = period as f64; + let sum_x = n * (n - 1.0) / 2.0; + let sum_xx = (n - 1.0) * n * (2.0 * n - 1.0) / 6.0; + Ok(Self { + period, + window: VecDeque::with_capacity(period), + sum_x, + denom: n * sum_xx - sum_x * sum_x, + sum_y: 0.0, + sum_xy: 0.0, + sum_y_sq: 0.0, + }) + } + + /// Configured period. + pub const fn period(&self) -> usize { + self.period + } +} + +impl Indicator for DetrendedStdDev { + type Input = f64; + type Output = f64; + + fn update(&mut self, value: f64) -> Option { + if self.window.len() == self.period { + let y0 = self.window.pop_front().expect("non-empty"); + self.sum_xy = self.sum_xy - self.sum_y + y0; + self.sum_y -= y0; + self.sum_y_sq -= y0 * y0; + } + let k = self.window.len() as f64; + self.window.push_back(value); + self.sum_y += value; + self.sum_xy += k * value; + self.sum_y_sq += value * value; + + if self.window.len() < self.period { + return None; + } + let n = self.period as f64; + let slope = (n * self.sum_xy - self.sum_x * self.sum_y) / self.denom; + let mean_y = self.sum_y / n; + let ss_total = self.sum_y_sq - n * mean_y * mean_y; + let s_xx = self.denom / n; + let rss = (ss_total - slope * slope * s_xx).max(0.0); + Some((rss / n).sqrt()) + } + + fn reset(&mut self) { + self.window.clear(); + self.sum_y = 0.0; + self.sum_xy = 0.0; + self.sum_y_sq = 0.0; + } + + fn warmup_period(&self) -> usize { + self.period + } + + fn is_ready(&self) -> bool { + self.window.len() == self.period + } + + fn name(&self) -> &'static str { + "DetrendedStdDev" + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::traits::BatchExt; + use approx::assert_relative_eq; + + #[test] + fn rejects_period_below_two() { + assert!(DetrendedStdDev::new(0).is_err()); + assert!(DetrendedStdDev::new(1).is_err()); + assert!(DetrendedStdDev::new(2).is_ok()); + } + + #[test] + fn accessors_and_metadata() { + let d = DetrendedStdDev::new(14).unwrap(); + assert_eq!(d.period(), 14); + assert_eq!(d.warmup_period(), 14); + assert_eq!(d.name(), "DetrendedStdDev"); + } + + #[test] + fn perfect_line_has_zero_residual() { + // Residuals are zero on a perfectly linear series. + let prices: Vec = (0..30).map(|i| 2.0 * f64::from(i) + 5.0).collect(); + let mut d = DetrendedStdDev::new(10).unwrap(); + for v in d.batch(&prices).into_iter().flatten() { + assert_relative_eq!(v, 0.0, epsilon = 1e-9); + } + } + + #[test] + fn constant_series_yields_zero() { + let mut d = DetrendedStdDev::new(5).unwrap(); + for v in d.batch(&[42.0; 20]).into_iter().flatten() { + assert_relative_eq!(v, 0.0, epsilon = 1e-9); + } + } + + #[test] + fn never_exceeds_stddev() { + // The detrended residual is the projection of (y - ȳ) orthogonal to + // the trend axis, so its norm cannot exceed the raw stddev. Equality + // holds iff the OLS slope is exactly zero. + let prices: Vec = (0..60) + .map(|i| 50.0 + f64::from(i) * 0.5 + (f64::from(i) * 0.7).sin() * 4.0) + .collect(); + let mut d = DetrendedStdDev::new(14).unwrap(); + let mut sd = crate::StdDev::new(14).unwrap(); + for &p in &prices { + let (dv, sv) = (d.update(p), sd.update(p)); + assert_eq!(dv.is_some(), sv.is_some()); + if let (Some(dv), Some(sv)) = (dv, sv) { + assert!(dv <= sv + 1e-9, "detrended {dv} should be <= stddev {sv}"); + } + } + } + + #[test] + fn reset_clears_state() { + let mut d = DetrendedStdDev::new(5).unwrap(); + d.batch(&[1.0, 2.0, 3.0, 4.0, 5.0]); + assert!(d.is_ready()); + d.reset(); + assert!(!d.is_ready()); + assert_eq!(d.update(1.0), None); + } + + #[test] + fn batch_equals_streaming() { + let prices: Vec = (0..60) + .map(|i| 100.0 + (f64::from(i) * 0.4).sin() * 10.0) + .collect(); + let batch = DetrendedStdDev::new(14).unwrap().batch(&prices); + let mut b = DetrendedStdDev::new(14).unwrap(); + let streamed: Vec<_> = prices.iter().map(|p| b.update(*p)).collect(); + assert_eq!(batch, streamed); + } +} diff --git a/crates/wickra-core/src/indicators/hurst_exponent.rs b/crates/wickra-core/src/indicators/hurst_exponent.rs new file mode 100644 index 00000000..97bcae8e --- /dev/null +++ b/crates/wickra-core/src/indicators/hurst_exponent.rs @@ -0,0 +1,299 @@ +//! Rolling Hurst Exponent via simplified R/S analysis. + +use std::collections::VecDeque; + +use crate::error::{Error, Result}; +use crate::traits::Indicator; + +/// Hurst Exponent of the last `period` values, estimated by rescaled-range +/// (R/S) analysis. +/// +/// The classic Hurst-Mandelbrot estimator forms log-log pairs of `(n, +/// R(n)/S(n))` for several window lengths `n` and reports the slope of the +/// least-squares fit. Wickra uses a streaming-friendly variant that +/// partitions the trailing window into `chunks` of equal size, +/// computes `(R/S)` for each chunk length, and fits a log-log line to the +/// resulting points: +/// +/// ```text +/// for each chunk size m ∈ {n/2, n/3, …, n/chunks}: +/// mean_m = (1/m) · Σ x_i over the chunk +/// dev_m_i = (Σ_{j ≤ i} (x_j − mean_m)) // cumulative deviation +/// R_m = max(dev_m) − min(dev_m) +/// S_m = population_stddev(chunk) +/// pair = (log m, log(R_m / S_m)) +/// H = slope of OLS line through the (log m, log(R/S)) points +/// ``` +/// +/// The interpretation is unchanged from the textbook: +/// +/// - `H ≈ 0.5` → random walk; recent moves carry no information about +/// future direction (the efficient-markets baseline). +/// - `H > 0.5` → persistent / trending; up moves are likelier to be +/// followed by more up moves. +/// - `H < 0.5` → anti-persistent / mean-reverting; up moves tend to +/// reverse. +/// +/// Use it as a regime filter: trend-following strategies prefer +/// `H > 0.55`; mean-reversion prefers `H < 0.45`. The output is clamped +/// to `[0, 1]` to absorb degenerate fits on very small windows. +/// +/// `period` must be at least `2 · chunks` so every chunk has at least two +/// points (otherwise its stddev is zero). A perfectly flat window has all +/// `R/S = 0` and the indicator returns `0.5` (random-walk baseline) to +/// avoid divide-by-zero / log-zero failures. +/// +/// Each `update` is O(period); the window is stored in a deque and the +/// chunked R/S computation runs once per emission, not per input. +/// +/// # Example +/// +/// ``` +/// use wickra_core::{HurstExponent, Indicator}; +/// +/// let mut indicator = HurstExponent::new(100, 4).unwrap(); +/// let mut last = None; +/// for i in 0..200 { +/// last = indicator.update(f64::from(i)); +/// } +/// assert!(last.is_some()); +/// ``` +#[derive(Debug, Clone)] +pub struct HurstExponent { + period: usize, + chunks: usize, + window: VecDeque, +} + +impl HurstExponent { + /// Construct a new Hurst Exponent over a window of `period` inputs, + /// fitted across `chunks` log-log points. + /// + /// `chunks` controls the number of R/S pairs that go into the slope + /// fit; the typical value is `4` (the original Hurst paper used 5 — 9 + /// points; smaller windows constrain the choice). + /// + /// # Errors + /// Returns [`Error::InvalidPeriod`] if `chunks < 2` or + /// `period < 2 · chunks`. + pub fn new(period: usize, chunks: usize) -> Result { + if chunks < 2 { + return Err(Error::InvalidPeriod { + message: "Hurst chunks must be >= 2", + }); + } + if period < 2 * chunks { + return Err(Error::InvalidPeriod { + message: "Hurst period must be >= 2 * chunks", + }); + } + Ok(Self { + period, + chunks, + window: VecDeque::with_capacity(period), + }) + } + + /// Configured window period. + pub const fn period(&self) -> usize { + self.period + } + + /// Configured chunk count. + pub const fn chunks(&self) -> usize { + self.chunks + } +} + +/// R/S over a single chunk; returns `None` if the chunk has zero dispersion +/// (its stddev is zero, so the ratio is undefined). +fn rescaled_range(chunk: &[f64]) -> Option { + let n = chunk.len() as f64; + let mean = chunk.iter().sum::() / n; + let mut cum = 0.0; + let mut hi = f64::NEG_INFINITY; + let mut lo = f64::INFINITY; + let mut sum_sq = 0.0; + for &x in chunk { + let d = x - mean; + cum += d; + if cum > hi { + hi = cum; + } + if cum < lo { + lo = cum; + } + sum_sq += d * d; + } + let r = hi - lo; + let s = (sum_sq / n).sqrt(); + if s == 0.0 || r == 0.0 { + return None; + } + Some(r / s) +} + +impl Indicator for HurstExponent { + type Input = f64; + type Output = f64; + + fn update(&mut self, value: f64) -> Option { + if self.window.len() == self.period { + self.window.pop_front(); + } + self.window.push_back(value); + if self.window.len() < self.period { + return None; + } + + // Materialise the window contiguously so chunk slicing is trivial. + let buf: Vec = self.window.iter().copied().collect(); + // Build (log m, log(R/S)) points. The chunk size sweeps from period + // (one big chunk) down to period / chunks (chunks small chunks). + let mut sum_x = 0.0; + let mut sum_y = 0.0; + let mut sum_xy = 0.0; + let mut sum_xx = 0.0; + let mut count = 0usize; + for k in 1..=self.chunks { + // k chunks each of size m; ignore the integer-division leftover + // bars at the end of the window. The `period >= 2 * chunks` + // constructor invariant guarantees m >= 2 for every k in range. + let m = self.period / k; + // Average R/S across the k chunks of size m to reduce noise. + let mut acc = 0.0; + let mut chunks_used = 0; + for c in 0..k { + let start = c * m; + let end = start + m; + if let Some(rs) = rescaled_range(&buf[start..end]) { + acc += rs; + chunks_used += 1; + } + } + if chunks_used == 0 { + continue; + } + let avg_rs = acc / f64::from(chunks_used); + let x = (m as f64).ln(); + let y = avg_rs.ln(); + sum_x += x; + sum_y += y; + sum_xy += x * y; + sum_xx += x * x; + count += 1; + } + if count < 2 { + // A perfectly flat window yields no usable R/S point; the + // canonical fallback for R/S on white noise is H = 0.5. + return Some(0.5); + } + // With chunks >= 2 and period >= 2 * chunks, m_1 = period and + // m_2 = period / 2 are always distinct, so the variance of the + // log-m values is strictly positive and `denom > 0`. + let n = count as f64; + let denom = n * sum_xx - sum_x * sum_x; + let slope = (n * sum_xy - sum_x * sum_y) / denom; + Some(slope.clamp(0.0, 1.0)) + } + + fn reset(&mut self) { + self.window.clear(); + } + + fn warmup_period(&self) -> usize { + self.period + } + + fn is_ready(&self) -> bool { + self.window.len() == self.period + } + + fn name(&self) -> &'static str { + "HurstExponent" + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::traits::BatchExt; + use approx::assert_relative_eq; + + #[test] + fn rejects_invalid_parameters() { + assert!(HurstExponent::new(10, 0).is_err()); + assert!(HurstExponent::new(10, 1).is_err()); + assert!(HurstExponent::new(3, 2).is_err()); + assert!(HurstExponent::new(4, 2).is_ok()); + } + + #[test] + fn accessors_and_metadata() { + let h = HurstExponent::new(100, 4).unwrap(); + assert_eq!(h.period(), 100); + assert_eq!(h.chunks(), 4); + assert_eq!(h.warmup_period(), 100); + assert_eq!(h.name(), "HurstExponent"); + } + + #[test] + fn constant_series_is_one_half() { + let mut h = HurstExponent::new(40, 4).unwrap(); + for v in h.batch(&[42.0; 80]).into_iter().flatten() { + assert_relative_eq!(v, 0.5, epsilon = 1e-12); + } + } + + #[test] + fn output_stays_in_zero_one_range() { + let prices: Vec = (0..400) + .map(|i| { + 100.0 + + (f64::from(i) * 0.05).sin() * 8.0 + + (f64::from(i) * 0.21).cos() * 3.0 + + f64::from(i) * 0.1 + }) + .collect(); + let mut h = HurstExponent::new(100, 4).unwrap(); + for v in h.batch(&prices).into_iter().flatten() { + assert!((0.0..=1.0).contains(&v), "Hurst out of range: {v}"); + } + } + + #[test] + fn trending_series_above_half() { + // A clean monotonic ramp is the textbook persistent series; the R/S + // pairs must lie above the random-walk baseline. + let prices: Vec = (0..200).map(f64::from).collect(); + let mut h = HurstExponent::new(100, 4).unwrap(); + let last = h.batch(&prices).into_iter().flatten().last().unwrap(); + assert!( + last > 0.5, + "trending series should have H > 0.5, got {last}" + ); + } + + #[test] + fn reset_clears_state() { + let mut h = HurstExponent::new(20, 4).unwrap(); + for i in 0..20 { + h.update(f64::from(i)); + } + assert!(h.is_ready()); + h.reset(); + assert!(!h.is_ready()); + assert_eq!(h.update(1.0), None); + } + + #[test] + fn batch_equals_streaming() { + let prices: Vec = (0..200) + .map(|i| 100.0 + (f64::from(i) * 0.1).sin() * 5.0) + .collect(); + let batch = HurstExponent::new(50, 4).unwrap().batch(&prices); + let mut b = HurstExponent::new(50, 4).unwrap(); + let streamed: Vec<_> = prices.iter().map(|p| b.update(*p)).collect(); + assert_eq!(batch, streamed); + } +} diff --git a/crates/wickra-core/src/indicators/kurtosis.rs b/crates/wickra-core/src/indicators/kurtosis.rs new file mode 100644 index 00000000..b8c935aa --- /dev/null +++ b/crates/wickra-core/src/indicators/kurtosis.rs @@ -0,0 +1,203 @@ +//! Rolling excess kurtosis (Pearson's fourth standardised central moment − 3). + +use std::collections::VecDeque; + +use crate::error::{Error, Result}; +use crate::traits::Indicator; + +/// Rolling **excess** kurtosis of the last `period` values. +/// +/// ```text +/// mean = (1/n) · Σ x +/// m2 = (1/n) · Σ (x − mean)² +/// m4 = (1/n) · Σ (x − mean)⁴ +/// Kurtosis = m4 / m2² − 3 +/// ``` +/// +/// The unshifted kurtosis `m4 / m2²` equals `3` for the normal distribution; +/// subtracting `3` gives **excess** kurtosis so that `0` is the Gaussian +/// baseline. Positive readings flag fat tails (heavy outliers compared to +/// normal); negative readings flag light tails (more concentrated than +/// normal). This is the population definition with divisor `n`. A window +/// with zero dispersion yields `0`. +/// +/// Each `update` is O(1): four running sums (`Σ x`, `Σ x²`, `Σ x³`, `Σ x⁴`) +/// are maintained as the window slides; the central moments are derived +/// from them via the binomial-expansion identities, so no inner loop runs +/// per bar. +/// +/// # Example +/// +/// ``` +/// use wickra_core::{Indicator, Kurtosis}; +/// +/// let mut indicator = Kurtosis::new(20).unwrap(); +/// let mut last = None; +/// for i in 0..40 { +/// last = indicator.update(f64::from(i)); +/// } +/// assert!(last.is_some()); +/// ``` +#[derive(Debug, Clone)] +pub struct Kurtosis { + period: usize, + window: VecDeque, + sum: f64, + sum_sq: f64, + sum_cu: f64, + sum_qu: f64, +} + +impl Kurtosis { + /// Construct a new rolling excess kurtosis with the given period. + /// + /// # Errors + /// Returns [`Error::InvalidPeriod`] if `period < 4`. + pub fn new(period: usize) -> Result { + if period < 4 { + return Err(Error::InvalidPeriod { + message: "kurtosis needs period >= 4", + }); + } + Ok(Self { + period, + window: VecDeque::with_capacity(period), + sum: 0.0, + sum_sq: 0.0, + sum_cu: 0.0, + sum_qu: 0.0, + }) + } + + /// Configured period. + pub const fn period(&self) -> usize { + self.period + } +} + +impl Indicator for Kurtosis { + type Input = f64; + type Output = f64; + + fn update(&mut self, value: f64) -> Option { + if self.window.len() == self.period { + let old = self.window.pop_front().expect("non-empty"); + let sq = old * old; + self.sum -= old; + self.sum_sq -= sq; + self.sum_cu -= old * sq; + self.sum_qu -= sq * sq; + } + self.window.push_back(value); + let sq = value * value; + self.sum += value; + self.sum_sq += sq; + self.sum_cu += value * sq; + self.sum_qu += sq * sq; + if self.window.len() < self.period { + return None; + } + let n = self.period as f64; + let mean = self.sum / n; + let m2 = (self.sum_sq / n - mean * mean).max(0.0); + if m2 == 0.0 { + // Flat window: kurtosis is undefined, return 0 (Gaussian baseline). + return Some(0.0); + } + // m4 = E[x⁴] − 4·mean·E[x³] + 6·mean²·E[x²] − 3·mean⁴. + let mean_sq = mean * mean; + let m4 = self.sum_qu / n - 4.0 * mean * (self.sum_cu / n) + + 6.0 * mean_sq * (self.sum_sq / n) + - 3.0 * mean_sq * mean_sq; + Some(m4 / (m2 * m2) - 3.0) + } + + fn reset(&mut self) { + self.window.clear(); + self.sum = 0.0; + self.sum_sq = 0.0; + self.sum_cu = 0.0; + self.sum_qu = 0.0; + } + + fn warmup_period(&self) -> usize { + self.period + } + + fn is_ready(&self) -> bool { + self.window.len() == self.period + } + + fn name(&self) -> &'static str { + "Kurtosis" + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::traits::BatchExt; + use approx::assert_relative_eq; + + #[test] + fn rejects_period_below_four() { + assert!(Kurtosis::new(0).is_err()); + assert!(Kurtosis::new(3).is_err()); + assert!(Kurtosis::new(4).is_ok()); + } + + #[test] + fn accessors_and_metadata() { + let k = Kurtosis::new(14).unwrap(); + assert_eq!(k.period(), 14); + assert_eq!(k.warmup_period(), 14); + assert_eq!(k.name(), "Kurtosis"); + } + + #[test] + fn two_point_distribution_is_negative_two() { + // A {a, b, a, b} window has m4/m2² = 1, so excess kurtosis = −2. + // This is the theoretical minimum for any real distribution. + let mut k = Kurtosis::new(4).unwrap(); + let out = k.batch(&[-1.0, 1.0, -1.0, 1.0]); + assert_relative_eq!(out[3].unwrap(), -2.0, epsilon = 1e-9); + } + + #[test] + fn constant_series_yields_zero() { + let mut k = Kurtosis::new(5).unwrap(); + for v in k.batch(&[42.0; 20]).into_iter().flatten() { + assert_relative_eq!(v, 0.0, epsilon = 1e-12); + } + } + + #[test] + fn outlier_window_is_leptokurtic() { + // A single large outlier amid otherwise-flat samples has positive + // excess kurtosis (a heavy tail). + let mut k = Kurtosis::new(5).unwrap(); + let out = k.batch(&[0.0, 0.0, 0.0, 0.0, 100.0]); + assert!(out[4].unwrap() > 0.0); + } + + #[test] + fn reset_clears_state() { + let mut k = Kurtosis::new(5).unwrap(); + k.batch(&[1.0, 2.0, 3.0, 4.0, 5.0]); + assert!(k.is_ready()); + k.reset(); + assert!(!k.is_ready()); + assert_eq!(k.update(1.0), None); + } + + #[test] + fn batch_equals_streaming() { + let prices: Vec = (0..60) + .map(|i| 100.0 + (f64::from(i) * 0.3).sin() * 5.0) + .collect(); + let batch = Kurtosis::new(14).unwrap().batch(&prices); + let mut b = Kurtosis::new(14).unwrap(); + let streamed: Vec<_> = prices.iter().map(|p| b.update(*p)).collect(); + assert_eq!(batch, streamed); + } +} diff --git a/crates/wickra-core/src/indicators/median_absolute_deviation.rs b/crates/wickra-core/src/indicators/median_absolute_deviation.rs new file mode 100644 index 00000000..c2f9c479 --- /dev/null +++ b/crates/wickra-core/src/indicators/median_absolute_deviation.rs @@ -0,0 +1,201 @@ +//! Rolling Median Absolute Deviation (MAD), a robust dispersion estimator. + +use std::collections::VecDeque; + +use crate::error::{Error, Result}; +use crate::traits::Indicator; + +/// Median Absolute Deviation of the last `period` values. +/// +/// ```text +/// med = median(window) +/// MAD = median( |x_i − med| for x_i in window ) +/// ``` +/// +/// MAD is the median analogue of the standard deviation: it is a robust +/// dispersion measure that ignores extreme outliers (a single huge spike +/// barely moves the result) and is widely used as a sturdier alternative +/// to `StdDev` for risk reporting on heavy-tailed return distributions. +/// Multiplying MAD by `1.4826` produces a consistent estimator of the +/// underlying Gaussian standard deviation (the "robust σ"); Wickra returns +/// the raw MAD so the caller chooses whether to scale. +/// +/// Each `update` is O(period log period): the window is kept as a deque +/// and copied into a small scratch buffer that is sorted twice (once to +/// pick the median, once to pick the median of absolute deviations). The +/// rolling structure makes the constant factor low; for the typical +/// period range (10–100) this is dwarfed by the streaming overhead. +/// +/// # Example +/// +/// ``` +/// use wickra_core::{Indicator, MedianAbsoluteDeviation}; +/// +/// let mut indicator = MedianAbsoluteDeviation::new(20).unwrap(); +/// let mut last = None; +/// for i in 0..40 { +/// last = indicator.update(100.0 + f64::from(i)); +/// } +/// assert!(last.is_some()); +/// ``` +#[derive(Debug, Clone)] +pub struct MedianAbsoluteDeviation { + period: usize, + window: VecDeque, + /// Reusable scratch buffer to avoid allocating per `update`. + scratch: Vec, +} + +impl MedianAbsoluteDeviation { + /// Construct a new rolling MAD with the given period. + /// + /// # Errors + /// Returns [`Error::PeriodZero`] if `period == 0`. + pub fn new(period: usize) -> Result { + if period == 0 { + return Err(Error::PeriodZero); + } + Ok(Self { + period, + window: VecDeque::with_capacity(period), + scratch: Vec::with_capacity(period), + }) + } + + /// Configured period. + pub const fn period(&self) -> usize { + self.period + } +} + +/// Sort a slice of `f64` in-place using total ordering (NaN-safe). +fn sort_finite(buf: &mut [f64]) { + buf.sort_by(f64::total_cmp); +} + +/// Median of a sorted, non-empty slice. +fn median_sorted(sorted: &[f64]) -> f64 { + let n = sorted.len(); + let mid = n / 2; + if n % 2 == 0 { + (sorted[mid - 1] + sorted[mid]) * 0.5 + } else { + sorted[mid] + } +} + +impl Indicator for MedianAbsoluteDeviation { + type Input = f64; + type Output = f64; + + fn update(&mut self, value: f64) -> Option { + if self.window.len() == self.period { + self.window.pop_front(); + } + self.window.push_back(value); + if self.window.len() < self.period { + return None; + } + // Copy into scratch and sort to find the window median. + self.scratch.clear(); + self.scratch.extend(self.window.iter().copied()); + sort_finite(&mut self.scratch); + let med = median_sorted(&self.scratch); + // Replace with absolute deviations and sort again. + for x in &mut self.scratch { + *x = (*x - med).abs(); + } + sort_finite(&mut self.scratch); + Some(median_sorted(&self.scratch)) + } + + fn reset(&mut self) { + self.window.clear(); + self.scratch.clear(); + } + + fn warmup_period(&self) -> usize { + self.period + } + + fn is_ready(&self) -> bool { + self.window.len() == self.period + } + + fn name(&self) -> &'static str { + "MedianAbsoluteDeviation" + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::traits::BatchExt; + use approx::assert_relative_eq; + + #[test] + fn rejects_zero_period() { + assert!(matches!( + MedianAbsoluteDeviation::new(0), + Err(Error::PeriodZero) + )); + } + + #[test] + fn accessors_and_metadata() { + let m = MedianAbsoluteDeviation::new(14).unwrap(); + assert_eq!(m.period(), 14); + assert_eq!(m.warmup_period(), 14); + assert_eq!(m.name(), "MedianAbsoluteDeviation"); + } + + #[test] + fn reference_value() { + // [1, 1, 2, 2, 4, 6, 9]: median = 2, deviations [1,1,0,0,2,4,7], + // sorted [0,0,1,1,2,4,7] → median = 1. + let mut m = MedianAbsoluteDeviation::new(7).unwrap(); + let out = m.batch(&[1.0, 1.0, 2.0, 2.0, 4.0, 6.0, 9.0]); + assert_relative_eq!(out[6].unwrap(), 1.0, epsilon = 1e-12); + } + + #[test] + fn constant_series_yields_zero() { + let mut m = MedianAbsoluteDeviation::new(5).unwrap(); + for v in m.batch(&[42.0; 20]).into_iter().flatten() { + assert_relative_eq!(v, 0.0, epsilon = 1e-12); + } + } + + #[test] + fn ignores_single_extreme_outlier() { + // A window of 9 equal values plus 1 huge outlier still has MAD = 0, + // because more than half the window agrees on the median and the + // deviations majority are zero. + let mut m = MedianAbsoluteDeviation::new(10).unwrap(); + let mut prices = vec![5.0; 9]; + prices.push(1_000.0); + let last = m.batch(&prices).into_iter().flatten().last().unwrap(); + assert_relative_eq!(last, 0.0, epsilon = 1e-12); + } + + #[test] + fn reset_clears_state() { + let mut m = MedianAbsoluteDeviation::new(5).unwrap(); + m.batch(&[1.0, 2.0, 3.0, 4.0, 5.0]); + assert!(m.is_ready()); + m.reset(); + assert!(!m.is_ready()); + assert_eq!(m.update(1.0), None); + } + + #[test] + fn batch_equals_streaming() { + let prices: Vec = (0..60) + .map(|i| 100.0 + (f64::from(i) * 0.3).sin() * 5.0) + .collect(); + let batch = MedianAbsoluteDeviation::new(14).unwrap().batch(&prices); + let mut b = MedianAbsoluteDeviation::new(14).unwrap(); + let streamed: Vec<_> = prices.iter().map(|p| b.update(*p)).collect(); + assert_eq!(batch, streamed); + } +} diff --git a/crates/wickra-core/src/indicators/mod.rs b/crates/wickra-core/src/indicators/mod.rs index e6b2b75d..bf6fe202 100644 --- a/crates/wickra-core/src/indicators/mod.rs +++ b/crates/wickra-core/src/indicators/mod.rs @@ -20,9 +20,11 @@ mod aroon_oscillator; mod atr; mod atr_bands; mod atr_trailing_stop; +mod autocorrelation; mod awesome_oscillator; mod awesome_oscillator_histogram; mod balance_of_power; +mod beta; mod bollinger; mod bollinger_bandwidth; mod camarilla_pivots; @@ -37,6 +39,7 @@ mod choppiness_index; mod classic_pivots; mod cmf; mod cmo; +mod coefficient_of_variation; mod connors_rsi; mod coppock; mod cybernetic_cycle; @@ -45,6 +48,7 @@ mod decycler_oscillator; mod dema; mod demand_index; mod demark_pivots; +mod detrended_std_dev; mod donchian; mod donchian_stop; mod double_bollinger; @@ -68,6 +72,7 @@ mod hilo_activator; mod historical_volatility; mod hma; mod hurst_channel; +mod hurst_exponent; mod ichimoku; mod inertia; mod instantaneous_trendline; @@ -76,6 +81,7 @@ mod jma; mod kama; mod keltner; mod kst; +mod kurtosis; mod kvo; mod laguerre_rsi; mod linreg; @@ -88,6 +94,7 @@ mod mama; mod market_facilitation_index; mod mass_index; mod mcginley_dynamic; +mod median_absolute_deviation; mod median_price; mod mfi; mod mom; @@ -95,6 +102,7 @@ mod natr; mod nvi; mod obv; mod parkinson; +mod pearson_correlation; mod percent_b; mod percentage_trailing_stop; mod pgo; @@ -102,6 +110,7 @@ mod pmo; mod ppo; mod psar; mod pvi; +mod r_squared; mod renko_trailing_stop; mod roc; mod rogers_satchell; @@ -111,9 +120,12 @@ mod rvi; mod rvi_volatility; mod rwi; mod sine_wave; +mod skewness; mod sma; mod smi; mod smma; +mod spearman_correlation; +mod standard_error; mod standard_error_bands; mod starc_bands; mod stc; @@ -147,6 +159,7 @@ mod ttm_squeeze; mod typical_price; mod ulcer_index; mod ultimate_oscillator; +mod variance; mod vertical_horizontal_filter; mod vidya; mod volty_stop; @@ -186,9 +199,11 @@ pub use aroon_oscillator::AroonOscillator; pub use atr::Atr; pub use atr_bands::{AtrBands, AtrBandsOutput}; pub use atr_trailing_stop::AtrTrailingStop; +pub use autocorrelation::Autocorrelation; pub use awesome_oscillator::AwesomeOscillator; pub use awesome_oscillator_histogram::AwesomeOscillatorHistogram; pub use balance_of_power::BalanceOfPower; +pub use beta::Beta; pub use bollinger::{BollingerBands, BollingerOutput}; pub use bollinger_bandwidth::BollingerBandwidth; pub use camarilla_pivots::{Camarilla, CamarillaPivotsOutput}; @@ -203,6 +218,7 @@ pub use choppiness_index::ChoppinessIndex; pub use classic_pivots::{ClassicPivots, ClassicPivotsOutput}; pub use cmf::ChaikinMoneyFlow; pub use cmo::Cmo; +pub use coefficient_of_variation::CoefficientOfVariation; pub use connors_rsi::ConnorsRsi; pub use coppock::Coppock; pub use cybernetic_cycle::CyberneticCycle; @@ -211,6 +227,7 @@ pub use decycler_oscillator::DecyclerOscillator; pub use dema::Dema; pub use demand_index::DemandIndex; pub use demark_pivots::{DemarkPivots, DemarkPivotsOutput}; +pub use detrended_std_dev::DetrendedStdDev; pub use donchian::{Donchian, DonchianOutput}; pub use donchian_stop::{DonchianStop, DonchianStopOutput}; pub use double_bollinger::{DoubleBollinger, DoubleBollingerOutput}; @@ -234,6 +251,7 @@ pub use hilo_activator::HiLoActivator; pub use historical_volatility::HistoricalVolatility; pub use hma::Hma; pub use hurst_channel::{HurstChannel, HurstChannelOutput}; +pub use hurst_exponent::HurstExponent; pub use ichimoku::{Ichimoku, IchimokuOutput}; pub use inertia::Inertia; pub use instantaneous_trendline::InstantaneousTrendline; @@ -242,6 +260,7 @@ pub use jma::Jma; pub use kama::Kama; pub use keltner::{Keltner, KeltnerOutput}; pub use kst::{Kst, KstOutput}; +pub use kurtosis::Kurtosis; pub use kvo::Kvo; pub use laguerre_rsi::LaguerreRsi; pub use linreg::LinearRegression; @@ -254,6 +273,7 @@ pub use mama::{Mama, MamaOutput}; pub use market_facilitation_index::MarketFacilitationIndex; pub use mass_index::MassIndex; pub use mcginley_dynamic::McGinleyDynamic; +pub use median_absolute_deviation::MedianAbsoluteDeviation; pub use median_price::MedianPrice; pub use mfi::Mfi; pub use mom::Mom; @@ -261,6 +281,7 @@ pub use natr::Natr; pub use nvi::Nvi; pub use obv::Obv; pub use parkinson::ParkinsonVolatility; +pub use pearson_correlation::PearsonCorrelation; pub use percent_b::PercentB; pub use percentage_trailing_stop::PercentageTrailingStop; pub use pgo::Pgo; @@ -268,6 +289,7 @@ pub use pmo::Pmo; pub use ppo::Ppo; pub use psar::Psar; pub use pvi::Pvi; +pub use r_squared::RSquared; pub use renko_trailing_stop::RenkoTrailingStop; pub use roc::Roc; pub use rogers_satchell::RogersSatchellVolatility; @@ -277,9 +299,12 @@ pub use rvi::Rvi; pub use rvi_volatility::RviVolatility; pub use rwi::{Rwi, RwiOutput}; pub use sine_wave::SineWave; +pub use skewness::Skewness; pub use sma::Sma; pub use smi::Smi; pub use smma::Smma; +pub use spearman_correlation::SpearmanCorrelation; +pub use standard_error::StandardError; pub use standard_error_bands::{StandardErrorBands, StandardErrorBandsOutput}; pub use starc_bands::{StarcBands, StarcBandsOutput}; pub use stc::Stc; @@ -313,6 +338,7 @@ pub use ttm_squeeze::{TtmSqueeze, TtmSqueezeOutput}; pub use typical_price::TypicalPrice; pub use ulcer_index::UlcerIndex; pub use ultimate_oscillator::UltimateOscillator; +pub use variance::Variance; pub use vertical_horizontal_filter::VerticalHorizontalFilter; pub use vidya::Vidya; pub use volty_stop::VoltyStop; diff --git a/crates/wickra-core/src/indicators/pearson_correlation.rs b/crates/wickra-core/src/indicators/pearson_correlation.rs new file mode 100644 index 00000000..bc0f7fe7 --- /dev/null +++ b/crates/wickra-core/src/indicators/pearson_correlation.rs @@ -0,0 +1,246 @@ +//! Rolling Pearson correlation between two synchronised series. + +use std::collections::VecDeque; + +use crate::error::{Error, Result}; +use crate::traits::Indicator; + +/// Rolling Pearson correlation between two synchronised series. +/// +/// Each `update` receives one `(x, y)` pair (e.g. the latest close of the +/// asset and of the benchmark). Over the trailing window of `period` +/// pairs: +/// +/// ```text +/// cov_xy = (1/n) · Σ x·y − x̄·ȳ +/// var_x = (1/n) · Σ x² − x̄² +/// var_y = (1/n) · Σ y² − ȳ² +/// Pearson = cov_xy / √(var_x · var_y) +/// ``` +/// +/// Output is in `[−1, +1]`. `+1` means a perfect positive linear +/// relationship; `−1` is a perfect inverse one; `0` means no linear +/// relationship. It is the same statistic `SciPy` / `NumPy` report as +/// `pearsonr` and the standardised relative of [`crate::Beta`] — Beta +/// scales Pearson by the ratio of standard deviations. +/// +/// Each `update` is O(1): five running sums (`Σx`, `Σy`, `Σx²`, `Σy²`, +/// `Σxy`) are maintained as the window slides. A flat series in either +/// channel gives an undefined ratio; the indicator returns `0` in that +/// case rather than producing `NaN`. The output is clamped to `[−1, +1]` +/// to absorb tiny floating-point overshoots near the boundaries. +/// +/// # Example +/// +/// ``` +/// use wickra_core::{Indicator, PearsonCorrelation}; +/// +/// let mut indicator = PearsonCorrelation::new(20).unwrap(); +/// let mut last = None; +/// for i in 0..40 { +/// last = indicator.update((f64::from(i), 2.0 * f64::from(i) + 1.0)); +/// } +/// // A perfectly linear pair → +1. +/// assert!((last.unwrap() - 1.0).abs() < 1e-9); +/// ``` +#[derive(Debug, Clone)] +pub struct PearsonCorrelation { + period: usize, + window: VecDeque<(f64, f64)>, + sum_x: f64, + sum_y: f64, + sum_xx: f64, + sum_yy: f64, + sum_xy: f64, +} + +impl PearsonCorrelation { + /// Construct a new rolling Pearson correlation. + /// + /// # Errors + /// Returns [`Error::InvalidPeriod`] if `period < 2` — correlation is + /// undefined for fewer than two pairs. + pub fn new(period: usize) -> Result { + if period < 2 { + return Err(Error::InvalidPeriod { + message: "pearson correlation needs period >= 2", + }); + } + Ok(Self { + period, + window: VecDeque::with_capacity(period), + sum_x: 0.0, + sum_y: 0.0, + sum_xx: 0.0, + sum_yy: 0.0, + sum_xy: 0.0, + }) + } + + /// Configured period. + pub const fn period(&self) -> usize { + self.period + } +} + +impl Indicator for PearsonCorrelation { + type Input = (f64, f64); + type Output = f64; + + fn update(&mut self, input: (f64, f64)) -> Option { + let (x, y) = input; + if self.window.len() == self.period { + let (ox, oy) = self.window.pop_front().expect("non-empty"); + self.sum_x -= ox; + self.sum_y -= oy; + self.sum_xx -= ox * ox; + self.sum_yy -= oy * oy; + self.sum_xy -= ox * oy; + } + self.window.push_back((x, y)); + self.sum_x += x; + self.sum_y += y; + self.sum_xx += x * x; + self.sum_yy += y * y; + self.sum_xy += x * y; + if self.window.len() < self.period { + return None; + } + let n = self.period as f64; + let mean_x = self.sum_x / n; + let mean_y = self.sum_y / n; + let var_x = (self.sum_xx / n - mean_x * mean_x).max(0.0); + let var_y = (self.sum_yy / n - mean_y * mean_y).max(0.0); + let cov = self.sum_xy / n - mean_x * mean_y; + let denom = (var_x * var_y).sqrt(); + if denom == 0.0 { + // At least one channel is flat: correlation is undefined. + return Some(0.0); + } + Some((cov / denom).clamp(-1.0, 1.0)) + } + + fn reset(&mut self) { + self.window.clear(); + self.sum_x = 0.0; + self.sum_y = 0.0; + self.sum_xx = 0.0; + self.sum_yy = 0.0; + self.sum_xy = 0.0; + } + + fn warmup_period(&self) -> usize { + self.period + } + + fn is_ready(&self) -> bool { + self.window.len() == self.period + } + + fn name(&self) -> &'static str { + "PearsonCorrelation" + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::traits::BatchExt; + use approx::assert_relative_eq; + + #[test] + fn rejects_period_below_two() { + assert!(PearsonCorrelation::new(0).is_err()); + assert!(PearsonCorrelation::new(1).is_err()); + assert!(PearsonCorrelation::new(2).is_ok()); + } + + #[test] + fn accessors_and_metadata() { + let p = PearsonCorrelation::new(14).unwrap(); + assert_eq!(p.period(), 14); + assert_eq!(p.warmup_period(), 14); + assert_eq!(p.name(), "PearsonCorrelation"); + } + + #[test] + fn perfect_positive_is_one() { + let pairs: Vec<(f64, f64)> = (0..10) + .map(|i| (f64::from(i), 3.0 * f64::from(i) + 1.0)) + .collect(); + let last = PearsonCorrelation::new(5) + .unwrap() + .batch(&pairs) + .into_iter() + .flatten() + .last() + .unwrap(); + assert_relative_eq!(last, 1.0, epsilon = 1e-9); + } + + #[test] + fn perfect_negative_is_minus_one() { + let pairs: Vec<(f64, f64)> = (0..10) + .map(|i| (f64::from(i), -2.0 * f64::from(i) + 5.0)) + .collect(); + let last = PearsonCorrelation::new(5) + .unwrap() + .batch(&pairs) + .into_iter() + .flatten() + .last() + .unwrap(); + assert_relative_eq!(last, -1.0, epsilon = 1e-9); + } + + #[test] + fn constant_channel_yields_zero() { + let pairs: Vec<(f64, f64)> = (0..10).map(|i| (f64::from(i), 7.0)).collect(); + let last = PearsonCorrelation::new(5) + .unwrap() + .batch(&pairs) + .into_iter() + .flatten() + .last() + .unwrap(); + assert_relative_eq!(last, 0.0, epsilon = 1e-12); + } + + #[test] + fn output_in_minus_one_to_one_range() { + let pairs: Vec<(f64, f64)> = (0..60) + .map(|i| { + let t = f64::from(i); + (100.0 + t.sin() * 5.0, 50.0 + (t * 0.3).cos() * 3.0) + }) + .collect(); + let mut p = PearsonCorrelation::new(20).unwrap(); + for v in p.batch(&pairs).into_iter().flatten() { + assert!((-1.0..=1.0).contains(&v)); + } + } + + #[test] + fn reset_clears_state() { + let mut p = PearsonCorrelation::new(5).unwrap(); + p.batch(&[(1.0, 2.0), (2.0, 4.0), (3.0, 6.0), (4.0, 8.0), (5.0, 10.0)]); + assert!(p.is_ready()); + p.reset(); + assert!(!p.is_ready()); + assert_eq!(p.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 = PearsonCorrelation::new(14).unwrap().batch(&pairs); + let mut b = PearsonCorrelation::new(14).unwrap(); + let streamed: Vec<_> = pairs.iter().map(|p| b.update(*p)).collect(); + assert_eq!(batch, streamed); + } +} diff --git a/crates/wickra-core/src/indicators/r_squared.rs b/crates/wickra-core/src/indicators/r_squared.rs new file mode 100644 index 00000000..6802b898 --- /dev/null +++ b/crates/wickra-core/src/indicators/r_squared.rs @@ -0,0 +1,216 @@ +//! Coefficient of determination R² for the rolling OLS fit. + +use std::collections::VecDeque; + +use crate::error::{Error, Result}; +use crate::traits::Indicator; + +/// R² (coefficient of determination) of the rolling least-squares fit. +/// +/// Over the trailing window indexed `x = 0, 1, …, period − 1` the OLS line +/// `y = a + b·x` is fitted and the ratio of variance explained by the line +/// to total variance is reported: +/// +/// ```text +/// slope = (n·Σxy − Σx·Σy) / (n·Σxx − (Σx)²) +/// SS_total = Σy² − n·ȳ² +/// SS_explained = slope² · ( denom / n ) +/// R² = SS_explained / SS_total if SS_total > 0 +/// = 1 otherwise (flat window) +/// ``` +/// +/// A reading of `1.0` means the window lies on a straight line — perfect +/// linear fit. `0.0` means the slope is irrelevant; the trend explains none +/// of the variance. Mid-range values quantify how trending the recent price +/// action is, independent of the slope's sign or magnitude. Use it as a +/// trend-quality filter: a strategy that needs a clear trend can require +/// `R² > 0.7`, while a mean-reversion strategy can prefer `R² < 0.3`. +/// +/// A flat window has `SS_total = 0`; the line is also flat and the fit is +/// trivially perfect, so the indicator returns `1.0` rather than dividing +/// by zero. +/// +/// Each `update` is O(1) via the same rolling sums as +/// [`crate::LinearRegression`], plus a running `Σy²`. The output is +/// clamped to `[0, 1]` to absorb tiny floating-point cancellation. +/// +/// # Example +/// +/// ``` +/// use wickra_core::{Indicator, RSquared}; +/// +/// let mut indicator = RSquared::new(14).unwrap(); +/// let mut last = None; +/// for i in 0..40 { +/// last = indicator.update(f64::from(i)); +/// } +/// assert!(last.is_some()); +/// ``` +#[derive(Debug, Clone)] +pub struct RSquared { + period: usize, + window: VecDeque, + sum_x: f64, + /// `n·Σxx − (Σx)²` — OLS denominator, constant in `period`. + denom: f64, + sum_y: f64, + sum_xy: f64, + sum_y_sq: f64, +} + +impl RSquared { + /// Construct a new rolling R² over `period` inputs. + /// + /// # Errors + /// Returns [`Error::InvalidPeriod`] if `period < 2` — a regression line + /// is undefined for fewer than two points. + pub fn new(period: usize) -> Result { + if period < 2 { + return Err(Error::InvalidPeriod { + message: "R² needs period >= 2", + }); + } + let n = period as f64; + let sum_x = n * (n - 1.0) / 2.0; + let sum_xx = (n - 1.0) * n * (2.0 * n - 1.0) / 6.0; + Ok(Self { + period, + window: VecDeque::with_capacity(period), + sum_x, + denom: n * sum_xx - sum_x * sum_x, + sum_y: 0.0, + sum_xy: 0.0, + sum_y_sq: 0.0, + }) + } + + /// Configured period. + pub const fn period(&self) -> usize { + self.period + } +} + +impl Indicator for RSquared { + type Input = f64; + type Output = f64; + + fn update(&mut self, value: f64) -> Option { + if self.window.len() == self.period { + let y0 = self.window.pop_front().expect("non-empty"); + self.sum_xy = self.sum_xy - self.sum_y + y0; + self.sum_y -= y0; + self.sum_y_sq -= y0 * y0; + } + let k = self.window.len() as f64; + self.window.push_back(value); + self.sum_y += value; + self.sum_xy += k * value; + self.sum_y_sq += value * value; + + if self.window.len() < self.period { + return None; + } + let n = self.period as f64; + let slope = (n * self.sum_xy - self.sum_x * self.sum_y) / self.denom; + let mean_y = self.sum_y / n; + let ss_total = (self.sum_y_sq - n * mean_y * mean_y).max(0.0); + let s_xx = self.denom / n; + let ss_explained = slope * slope * s_xx; + if ss_total <= 0.0 { + // Flat window: the fit is trivially perfect. + return Some(1.0); + } + Some((ss_explained / ss_total).clamp(0.0, 1.0)) + } + + fn reset(&mut self) { + self.window.clear(); + self.sum_y = 0.0; + self.sum_xy = 0.0; + self.sum_y_sq = 0.0; + } + + fn warmup_period(&self) -> usize { + self.period + } + + fn is_ready(&self) -> bool { + self.window.len() == self.period + } + + fn name(&self) -> &'static str { + "RSquared" + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::traits::BatchExt; + use approx::assert_relative_eq; + + #[test] + fn rejects_period_below_two() { + assert!(RSquared::new(0).is_err()); + assert!(RSquared::new(1).is_err()); + assert!(RSquared::new(2).is_ok()); + } + + #[test] + fn accessors_and_metadata() { + let r = RSquared::new(14).unwrap(); + assert_eq!(r.period(), 14); + assert_eq!(r.warmup_period(), 14); + assert_eq!(r.name(), "RSquared"); + } + + #[test] + fn perfect_line_is_one() { + let prices: Vec = (0..30).map(|i| 2.0 * f64::from(i) + 5.0).collect(); + let mut r = RSquared::new(10).unwrap(); + for v in r.batch(&prices).into_iter().flatten() { + assert_relative_eq!(v, 1.0, epsilon = 1e-9); + } + } + + #[test] + fn constant_series_is_one() { + // SS_total is zero; the indicator must return 1 instead of NaN. + let mut r = RSquared::new(5).unwrap(); + for v in r.batch(&[42.0; 20]).into_iter().flatten() { + assert_relative_eq!(v, 1.0, epsilon = 1e-12); + } + } + + #[test] + fn output_stays_in_zero_one_range() { + let prices: Vec = (0..120) + .map(|i| 100.0 + (f64::from(i) * 0.4).sin() * 5.0 + (f64::from(i) * 0.07).cos() * 12.0) + .collect(); + let mut r = RSquared::new(20).unwrap(); + for v in r.batch(&prices).into_iter().flatten() { + assert!((0.0..=1.0).contains(&v), "R² out of range: {v}"); + } + } + + #[test] + fn reset_clears_state() { + let mut r = RSquared::new(5).unwrap(); + r.batch(&[1.0, 2.0, 3.0, 4.0, 5.0]); + assert!(r.is_ready()); + r.reset(); + assert!(!r.is_ready()); + assert_eq!(r.update(1.0), None); + } + + #[test] + fn batch_equals_streaming() { + let prices: Vec = (0..60) + .map(|i| 50.0 + (f64::from(i) * 0.3).sin() * 10.0) + .collect(); + let batch = RSquared::new(14).unwrap().batch(&prices); + let mut b = RSquared::new(14).unwrap(); + let streamed: Vec<_> = prices.iter().map(|p| b.update(*p)).collect(); + assert_eq!(batch, streamed); + } +} diff --git a/crates/wickra-core/src/indicators/skewness.rs b/crates/wickra-core/src/indicators/skewness.rs new file mode 100644 index 00000000..57a62660 --- /dev/null +++ b/crates/wickra-core/src/indicators/skewness.rs @@ -0,0 +1,202 @@ +//! Rolling Pearson skewness (third standardised central moment). + +use std::collections::VecDeque; + +use crate::error::{Error, Result}; +use crate::traits::Indicator; + +/// Rolling Pearson skewness of the last `period` values. +/// +/// ```text +/// mean = (1/n) · Σ x +/// m2 = (1/n) · Σ (x − mean)² // population variance +/// m3 = (1/n) · Σ (x − mean)³ // third central moment +/// Skew = m3 / m2^(3/2) +/// ``` +/// +/// Positive skewness means the right tail (large positive deviations from +/// the mean) is heavier than the left; negative skewness flags the +/// opposite. A symmetric distribution has skewness `0`. This is the +/// population (Pearson) definition with divisor `n`; many statistics +/// packages report the bias-corrected sample skewness instead. The window +/// is required to have at least three points so the moments are +/// well-defined. A window with zero dispersion yields `0`. +/// +/// Each `update` is O(1): three running sums (`Σ x`, `Σ x²`, `Σ x³`) are +/// maintained as the window slides; the central moments are then derived +/// from them via the binomial-expansion identities, so no inner loop runs +/// per bar. +/// +/// # Example +/// +/// ``` +/// use wickra_core::{Indicator, Skewness}; +/// +/// let mut indicator = Skewness::new(20).unwrap(); +/// let mut last = None; +/// for i in 0..40 { +/// last = indicator.update(f64::from(i)); +/// } +/// assert!(last.is_some()); +/// ``` +#[derive(Debug, Clone)] +pub struct Skewness { + period: usize, + window: VecDeque, + sum: f64, + sum_sq: f64, + sum_cu: f64, +} + +impl Skewness { + /// Construct a new rolling skewness with the given period. + /// + /// # Errors + /// Returns [`Error::InvalidPeriod`] if `period < 3`. + pub fn new(period: usize) -> Result { + if period < 3 { + return Err(Error::InvalidPeriod { + message: "skewness needs period >= 3", + }); + } + Ok(Self { + period, + window: VecDeque::with_capacity(period), + sum: 0.0, + sum_sq: 0.0, + sum_cu: 0.0, + }) + } + + /// Configured period. + pub const fn period(&self) -> usize { + self.period + } +} + +impl Indicator for Skewness { + type Input = f64; + type Output = f64; + + fn update(&mut self, value: f64) -> Option { + if self.window.len() == self.period { + let old = self.window.pop_front().expect("non-empty"); + self.sum -= old; + self.sum_sq -= old * old; + self.sum_cu -= old * old * old; + } + self.window.push_back(value); + self.sum += value; + self.sum_sq += value * value; + self.sum_cu += value * value * value; + if self.window.len() < self.period { + return None; + } + let n = self.period as f64; + let mean = self.sum / n; + // m2 = E[x²] − E[x]² + let m2 = (self.sum_sq / n - mean * mean).max(0.0); + // m3 = E[x³] − 3·mean·E[x²] + 2·mean³ (binomial expansion). + let m3 = self.sum_cu / n - 3.0 * mean * (self.sum_sq / n) + 2.0 * mean * mean * mean; + if m2 == 0.0 { + // A window with no dispersion has no defined shape; return 0. + return Some(0.0); + } + Some(m3 / m2.powf(1.5)) + } + + fn reset(&mut self) { + self.window.clear(); + self.sum = 0.0; + self.sum_sq = 0.0; + self.sum_cu = 0.0; + } + + fn warmup_period(&self) -> usize { + self.period + } + + fn is_ready(&self) -> bool { + self.window.len() == self.period + } + + fn name(&self) -> &'static str { + "Skewness" + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::traits::BatchExt; + use approx::assert_relative_eq; + + #[test] + fn rejects_period_below_three() { + assert!(Skewness::new(0).is_err()); + assert!(Skewness::new(1).is_err()); + assert!(Skewness::new(2).is_err()); + assert!(Skewness::new(3).is_ok()); + } + + #[test] + fn accessors_and_metadata() { + let s = Skewness::new(14).unwrap(); + assert_eq!(s.period(), 14); + assert_eq!(s.warmup_period(), 14); + assert_eq!(s.name(), "Skewness"); + } + + #[test] + fn symmetric_window_is_zero() { + // Symmetric around its mean — skewness must be (numerically) zero. + let mut s = Skewness::new(5).unwrap(); + let out = s.batch(&[-2.0, -1.0, 0.0, 1.0, 2.0]); + assert_relative_eq!(out[4].unwrap(), 0.0, epsilon = 1e-9); + } + + #[test] + fn constant_series_yields_zero() { + let mut s = Skewness::new(5).unwrap(); + for v in s.batch(&[42.0; 20]).into_iter().flatten() { + assert_relative_eq!(v, 0.0, epsilon = 1e-12); + } + } + + #[test] + fn right_tail_is_positive() { + // One large positive outlier creates a right-skewed window. + let mut s = Skewness::new(5).unwrap(); + let out = s.batch(&[0.0, 0.0, 0.0, 0.0, 10.0]); + assert!(out[4].unwrap() > 0.0); + } + + #[test] + fn left_tail_is_negative() { + // Mirror image — one large negative outlier gives left skew. + let mut s = Skewness::new(5).unwrap(); + let out = s.batch(&[10.0, 10.0, 10.0, 10.0, 0.0]); + assert!(out[4].unwrap() < 0.0); + } + + #[test] + fn reset_clears_state() { + let mut s = Skewness::new(5).unwrap(); + s.batch(&[1.0, 2.0, 3.0, 4.0, 5.0]); + assert!(s.is_ready()); + s.reset(); + assert!(!s.is_ready()); + assert_eq!(s.update(1.0), None); + } + + #[test] + fn batch_equals_streaming() { + let prices: Vec = (0..60) + .map(|i| 100.0 + (f64::from(i) * 0.3).sin() * 7.0) + .collect(); + let batch = Skewness::new(14).unwrap().batch(&prices); + let mut b = Skewness::new(14).unwrap(); + let streamed: Vec<_> = prices.iter().map(|p| b.update(*p)).collect(); + assert_eq!(batch, streamed); + } +} diff --git a/crates/wickra-core/src/indicators/spearman_correlation.rs b/crates/wickra-core/src/indicators/spearman_correlation.rs new file mode 100644 index 00000000..4eee492c --- /dev/null +++ b/crates/wickra-core/src/indicators/spearman_correlation.rs @@ -0,0 +1,314 @@ +//! Rolling Spearman rank correlation between two synchronised series. + +use std::collections::VecDeque; + +use crate::error::{Error, Result}; +use crate::traits::Indicator; + +/// Rolling Spearman rank correlation between two synchronised series. +/// +/// Each `update` receives one `(x, y)` pair. Over the trailing window of +/// `period` pairs, the values in each channel are replaced by their ranks +/// (mid-ranks for ties), and the Pearson correlation of those ranks is +/// reported: +/// +/// ```text +/// rx = rank(x_i) with mid-rank tie handling +/// ry = rank(y_i) with mid-rank tie handling +/// Spearman = Pearson( rx, ry ) +/// ``` +/// +/// Spearman is the non-linear, **monotone** analogue of +/// [`crate::PearsonCorrelation`]: `+1` means the two series move in the +/// same direction (any monotone relationship, not just linear); `−1` +/// means they move in opposite directions; `0` means no monotone +/// relationship. Because ranks throw away magnitude, Spearman is robust +/// to outliers and to non-linear (but monotone) transformations — the +/// canonical example is two assets that move together but with very +/// different volatility profiles. +/// +/// Each `update` is O(period²) in the naïve implementation; Wickra uses +/// an O(period log period) sort-and-pair approach: the window is copied +/// into a scratch buffer, sorted twice (once per channel) to derive the +/// ranks, then Pearson is computed on the rank arrays via the same O(n) +/// rolling sums as [`crate::PearsonCorrelation`]. +/// +/// A window in which one channel is constant has no rank dispersion and +/// the correlation is undefined; the indicator returns `0` rather than +/// `NaN`. The output is clamped to `[−1, +1]` to absorb tiny +/// floating-point overshoots. +/// +/// # Example +/// +/// ``` +/// use wickra_core::{Indicator, SpearmanCorrelation}; +/// +/// let mut indicator = SpearmanCorrelation::new(10).unwrap(); +/// let mut last = None; +/// for i in 1..20 { +/// // Strictly monotone — Spearman should be +1. +/// last = indicator.update((f64::from(i), (f64::from(i)).powi(3))); +/// } +/// assert!((last.unwrap() - 1.0).abs() < 1e-9); +/// ``` +#[derive(Debug, Clone)] +pub struct SpearmanCorrelation { + period: usize, + window: VecDeque<(f64, f64)>, + /// Reusable scratch buffer for ranking; pairs of `(value, original_index)`. + scratch: Vec<(f64, usize)>, + /// Reusable rank buffers, indexed by original position in the window. + rx: Vec, + ry: Vec, +} + +impl SpearmanCorrelation { + /// Construct a new rolling Spearman correlation. + /// + /// # Errors + /// Returns [`Error::InvalidPeriod`] if `period < 2`. + pub fn new(period: usize) -> Result { + if period < 2 { + return Err(Error::InvalidPeriod { + message: "spearman correlation needs period >= 2", + }); + } + Ok(Self { + period, + window: VecDeque::with_capacity(period), + scratch: Vec::with_capacity(period), + rx: vec![0.0; period], + ry: vec![0.0; period], + }) + } + + /// Configured period. + pub const fn period(&self) -> usize { + self.period + } +} + +/// Fill `ranks_out[original_index] = rank` for the supplied `values`, +/// using mid-ranks for ties. `scratch` is reused so no allocation +/// happens per call after the first. +fn rank_into( + values: impl Iterator, + ranks_out: &mut [f64], + scratch: &mut Vec<(f64, usize)>, +) { + scratch.clear(); + for (i, v) in values.enumerate() { + scratch.push((v, i)); + } + scratch.sort_by(|a, b| a.0.total_cmp(&b.0)); + let n = scratch.len(); + let mut i = 0; + while i < n { + let mut j = i + 1; + while j < n && scratch[j].0 == scratch[i].0 { + j += 1; + } + // Mid-rank of positions [i, j-1] in 1-indexed terms: + // (i + 1 + j) / 2. + let mid = (i as f64 + 1.0 + j as f64) / 2.0; + for k in i..j { + ranks_out[scratch[k].1] = mid; + } + i = j; + } +} + +impl Indicator for SpearmanCorrelation { + 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; + } + // Rank each channel. + rank_into( + self.window.iter().map(|p| p.0), + &mut self.rx, + &mut self.scratch, + ); + rank_into( + self.window.iter().map(|p| p.1), + &mut self.ry, + &mut self.scratch, + ); + // Pearson over the rank arrays. Closed forms are not used here + // because tie handling produces mid-ranks; the generic Pearson keeps + // the code uniform. + let n = self.period as f64; + let mut sum_x = 0.0; + let mut sum_y = 0.0; + let mut sum_xx = 0.0; + let mut sum_yy = 0.0; + let mut sum_xy = 0.0; + for i in 0..self.period { + let x = self.rx[i]; + let y = self.ry[i]; + sum_x += x; + sum_y += y; + sum_xx += x * x; + sum_yy += y * y; + sum_xy += x * y; + } + let mean_x = sum_x / n; + let mean_y = sum_y / n; + let var_x = (sum_xx / n - mean_x * mean_x).max(0.0); + let var_y = (sum_yy / n - mean_y * mean_y).max(0.0); + let cov = sum_xy / n - mean_x * mean_y; + let denom = (var_x * var_y).sqrt(); + if denom == 0.0 { + return Some(0.0); + } + Some((cov / denom).clamp(-1.0, 1.0)) + } + + fn reset(&mut self) { + self.window.clear(); + self.scratch.clear(); + self.rx.iter_mut().for_each(|r| *r = 0.0); + self.ry.iter_mut().for_each(|r| *r = 0.0); + } + + fn warmup_period(&self) -> usize { + self.period + } + + fn is_ready(&self) -> bool { + self.window.len() == self.period + } + + fn name(&self) -> &'static str { + "SpearmanCorrelation" + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::traits::BatchExt; + use approx::assert_relative_eq; + + #[test] + fn rejects_period_below_two() { + assert!(SpearmanCorrelation::new(0).is_err()); + assert!(SpearmanCorrelation::new(1).is_err()); + assert!(SpearmanCorrelation::new(2).is_ok()); + } + + #[test] + fn accessors_and_metadata() { + let s = SpearmanCorrelation::new(14).unwrap(); + assert_eq!(s.period(), 14); + assert_eq!(s.warmup_period(), 14); + assert_eq!(s.name(), "SpearmanCorrelation"); + } + + #[test] + fn perfect_monotone_relationship_is_one() { + // y = x³ is strictly monotone but very non-linear; Pearson would + // not return exactly 1 but Spearman must. + let pairs: Vec<(f64, f64)> = (1..=10) + .map(|i| (f64::from(i), (f64::from(i)).powi(3))) + .collect(); + let last = SpearmanCorrelation::new(5) + .unwrap() + .batch(&pairs) + .into_iter() + .flatten() + .last() + .unwrap(); + assert_relative_eq!(last, 1.0, epsilon = 1e-9); + } + + #[test] + fn perfect_inverse_is_minus_one() { + let pairs: Vec<(f64, f64)> = (1..=10) + .map(|i| (f64::from(i), 1.0 / (f64::from(i)))) + .collect(); + let last = SpearmanCorrelation::new(5) + .unwrap() + .batch(&pairs) + .into_iter() + .flatten() + .last() + .unwrap(); + assert_relative_eq!(last, -1.0, epsilon = 1e-9); + } + + #[test] + fn constant_channel_yields_zero() { + let pairs: Vec<(f64, f64)> = (0..10).map(|i| (f64::from(i), 7.0)).collect(); + let last = SpearmanCorrelation::new(5) + .unwrap() + .batch(&pairs) + .into_iter() + .flatten() + .last() + .unwrap(); + assert_relative_eq!(last, 0.0, epsilon = 1e-12); + } + + #[test] + fn output_in_minus_one_to_one_range() { + let pairs: Vec<(f64, f64)> = (0..60) + .map(|i| { + let t = f64::from(i); + (100.0 + t.sin() * 5.0, 50.0 + (t * 0.7).cos() * 3.0) + }) + .collect(); + let mut s = SpearmanCorrelation::new(20).unwrap(); + for v in s.batch(&pairs).into_iter().flatten() { + assert!((-1.0..=1.0).contains(&v)); + } + } + + #[test] + fn handles_ties_via_mid_ranks() { + // x has a tie at the top; Spearman must still produce a sensible + // value (it equals Pearson of the rank arrays). + let pairs = [(1.0, 1.0), (2.0, 2.0), (3.0, 3.0), (3.0, 4.0)]; + let last = SpearmanCorrelation::new(4) + .unwrap() + .batch(&pairs) + .into_iter() + .flatten() + .last() + .unwrap(); + // Ranks: rx = [1, 2, 3.5, 3.5]; ry = [1, 2, 3, 4]. Pearson of those + // is a positive number less than 1 because of the tie in rx. + assert!(last > 0.0 && last < 1.0); + } + + #[test] + fn reset_clears_state() { + let mut s = SpearmanCorrelation::new(5).unwrap(); + s.batch(&[(1.0, 2.0), (2.0, 4.0), (3.0, 6.0), (4.0, 8.0), (5.0, 10.0)]); + assert!(s.is_ready()); + s.reset(); + assert!(!s.is_ready()); + assert_eq!(s.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.1).cos(), (t * 0.3).cos()) + }) + .collect(); + let batch = SpearmanCorrelation::new(14).unwrap().batch(&pairs); + let mut b = SpearmanCorrelation::new(14).unwrap(); + let streamed: Vec<_> = pairs.iter().map(|p| b.update(*p)).collect(); + assert_eq!(batch, streamed); + } +} diff --git a/crates/wickra-core/src/indicators/standard_error.rs b/crates/wickra-core/src/indicators/standard_error.rs new file mode 100644 index 00000000..6754d963 --- /dev/null +++ b/crates/wickra-core/src/indicators/standard_error.rs @@ -0,0 +1,244 @@ +//! Standard Error of the rolling least-squares regression. + +use std::collections::VecDeque; + +use crate::error::{Error, Result}; +use crate::traits::Indicator; + +/// Standard Error of the regression line fit over the last `period` inputs. +/// +/// Over the trailing window indexed `x = 0, 1, …, period − 1` the OLS line +/// `y = a + b·x` is fitted, then: +/// +/// ```text +/// slope = (n·Σxy − Σx·Σy) / (n·Σxx − (Σx)²) +/// SS_total = Σy² − n·ȳ² // total sum of squares +/// RSS = SS_total − slope² · S_xx // residual sum of squares +/// StdErr = √( RSS / (n − 2) ) // n − 2 residual d.o.f. +/// ``` +/// +/// where `S_xx = (n·Σxx − (Σx)²) / n` is the centred sum of squares of the +/// design. +/// +/// This is the textbook **standard error of estimate** of OLS: it measures +/// the typical distance between the observed prices and the fitted line, +/// using the residual degrees of freedom `n − 2`. It is the spread that +/// drives [`crate::Bollinger`]-style bands around a regression instead of +/// around an SMA — when the price hugs its trend, `StdErr` is small. +/// +/// Each `update` is O(1): the `Σx` and `Σxx` terms depend only on `period` +/// and are precomputed once, while `Σy`, `Σxy`, and `Σy²` are maintained +/// incrementally as the window slides. Tiny floating-point cancellation +/// noise that could drive the residual sum of squares slightly negative is +/// clamped to zero before the square root. +/// +/// # Example +/// +/// ``` +/// use wickra_core::{Indicator, StandardError}; +/// +/// let mut indicator = StandardError::new(14).unwrap(); +/// let mut last = None; +/// for i in 0..40 { +/// last = indicator.update(100.0 + f64::from(i) + (f64::from(i) * 0.5).sin()); +/// } +/// assert!(last.is_some()); +/// ``` +#[derive(Debug, Clone)] +pub struct StandardError { + period: usize, + window: VecDeque, + sum_x: f64, + /// `n·Σxx − (Σx)²` — OLS denominator, constant in `period`. + denom: f64, + sum_y: f64, + sum_xy: f64, + sum_y_sq: f64, +} + +impl StandardError { + /// Construct a new rolling standard error of regression. + /// + /// # Errors + /// Returns [`Error::InvalidPeriod`] if `period < 3` — the residual + /// degrees of freedom `n − 2` would be non-positive. + pub fn new(period: usize) -> Result { + if period < 3 { + return Err(Error::InvalidPeriod { + message: "standard error needs period >= 3", + }); + } + let n = period as f64; + let sum_x = n * (n - 1.0) / 2.0; + let sum_xx = (n - 1.0) * n * (2.0 * n - 1.0) / 6.0; + Ok(Self { + period, + window: VecDeque::with_capacity(period), + sum_x, + denom: n * sum_xx - sum_x * sum_x, + sum_y: 0.0, + sum_xy: 0.0, + sum_y_sq: 0.0, + }) + } + + /// Configured period. + pub const fn period(&self) -> usize { + self.period + } +} + +impl Indicator for StandardError { + type Input = f64; + type Output = f64; + + fn update(&mut self, value: f64) -> Option { + if self.window.len() == self.period { + // Slide: pop oldest, shift indices, then push the new value at index n − 1. + let y0 = self.window.pop_front().expect("non-empty"); + self.sum_xy = self.sum_xy - self.sum_y + y0; + self.sum_y -= y0; + self.sum_y_sq -= y0 * y0; + } + let k = self.window.len() as f64; + self.window.push_back(value); + self.sum_y += value; + self.sum_xy += k * value; + self.sum_y_sq += value * value; + + if self.window.len() < self.period { + return None; + } + let n = self.period as f64; + let slope = (n * self.sum_xy - self.sum_x * self.sum_y) / self.denom; + let mean_y = self.sum_y / n; + let ss_total = self.sum_y_sq - n * mean_y * mean_y; + // S_xx = denom / n + let s_xx = self.denom / n; + let rss = (ss_total - slope * slope * s_xx).max(0.0); + Some((rss / (n - 2.0)).sqrt()) + } + + fn reset(&mut self) { + self.window.clear(); + self.sum_y = 0.0; + self.sum_xy = 0.0; + self.sum_y_sq = 0.0; + } + + fn warmup_period(&self) -> usize { + self.period + } + + fn is_ready(&self) -> bool { + self.window.len() == self.period + } + + fn name(&self) -> &'static str { + "StandardError" + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::traits::BatchExt; + use approx::assert_relative_eq; + + #[test] + fn rejects_period_below_three() { + assert!(StandardError::new(0).is_err()); + assert!(StandardError::new(2).is_err()); + assert!(StandardError::new(3).is_ok()); + } + + #[test] + fn accessors_and_metadata() { + let se = StandardError::new(14).unwrap(); + assert_eq!(se.period(), 14); + assert_eq!(se.warmup_period(), 14); + assert_eq!(se.name(), "StandardError"); + } + + #[test] + fn perfect_line_has_zero_error() { + // Residuals from a perfectly linear fit are zero, so SE = 0. + let prices: Vec = (0..30).map(|i| 2.0 * f64::from(i) + 5.0).collect(); + let mut se = StandardError::new(10).unwrap(); + for v in se.batch(&prices).into_iter().flatten() { + assert_relative_eq!(v, 0.0, epsilon = 1e-9); + } + } + + #[test] + fn constant_series_yields_zero() { + let mut se = StandardError::new(5).unwrap(); + for v in se.batch(&[42.0; 20]).into_iter().flatten() { + assert_relative_eq!(v, 0.0, epsilon = 1e-9); + } + } + + #[test] + fn matches_naive_definition() { + // Compare the O(1) update against a fresh-from-scratch OLS refit each bar. + fn naive(window: &[f64]) -> f64 { + let n = window.len() as f64; + let mean_y = window.iter().sum::() / n; + let mut sum_xy = 0.0; + let mut sum_x = 0.0; + let mut sum_xx = 0.0; + for (i, &y) in window.iter().enumerate() { + let x = i as f64; + sum_xy += x * y; + sum_x += x; + sum_xx += x * x; + } + let mean_x = sum_x / n; + let s_xx = sum_xx - n * mean_x * mean_x; + let slope = (sum_xy - n * mean_x * mean_y) / s_xx; + let intercept = mean_y - slope * mean_x; + let rss: f64 = window + .iter() + .enumerate() + .map(|(i, &y)| { + let r = y - (intercept + slope * i as f64); + r * r + }) + .sum(); + (rss / (n - 2.0)).sqrt() + } + + let prices: Vec = (0..60) + .map(|i| 100.0 + f64::from(i) * 0.5 + (f64::from(i) * 0.7).sin() * 3.0) + .collect(); + let period = 14; + let got = StandardError::new(period).unwrap().batch(&prices); + for (i, g) in got.iter().enumerate() { + if let Some(v) = g { + let expected = naive(&prices[i + 1 - period..=i]); + assert_relative_eq!(*v, expected, epsilon = 1e-9); + } + } + } + + #[test] + fn reset_clears_state() { + let mut se = StandardError::new(5).unwrap(); + se.batch(&[1.0, 2.0, 3.0, 4.0, 5.0]); + assert!(se.is_ready()); + se.reset(); + assert!(!se.is_ready()); + assert_eq!(se.update(1.0), None); + } + + #[test] + fn batch_equals_streaming() { + let prices: Vec = (0..60) + .map(|i| 100.0 + (f64::from(i) * 0.4).sin() * 10.0) + .collect(); + let batch = StandardError::new(14).unwrap().batch(&prices); + let mut b = StandardError::new(14).unwrap(); + let streamed: Vec<_> = prices.iter().map(|p| b.update(*p)).collect(); + assert_eq!(batch, streamed); + } +} diff --git a/crates/wickra-core/src/indicators/variance.rs b/crates/wickra-core/src/indicators/variance.rs new file mode 100644 index 00000000..99838191 --- /dev/null +++ b/crates/wickra-core/src/indicators/variance.rs @@ -0,0 +1,192 @@ +//! Rolling population variance. + +use std::collections::VecDeque; + +use crate::error::{Error, Result}; +use crate::traits::Indicator; + +/// Rolling population variance over the last `period` values. +/// +/// ```text +/// mean = (1/n) · Σ price +/// Variance = (1/n) · Σ price² − mean² +/// ``` +/// +/// Variance is the squared standard deviation. It is the second central +/// moment of the rolling distribution and the natural input to risk +/// calculations that expect squared returns (e.g. portfolio variance, +/// covariance matrices). Use [`crate::StdDev`] when you need the +/// scale-preserving square root instead. +/// +/// Floating-point cancellation can drive the running expression slightly +/// negative on perfectly constant inputs; the result is clamped to zero +/// before being returned so it stays a valid variance. +/// +/// # Example +/// +/// ``` +/// use wickra_core::{Indicator, Variance}; +/// +/// let mut indicator = Variance::new(20).unwrap(); +/// let mut last = None; +/// for i in 0..40 { +/// last = indicator.update(100.0 + f64::from(i)); +/// } +/// assert!(last.is_some()); +/// ``` +#[derive(Debug, Clone)] +pub struct Variance { + period: usize, + window: VecDeque, + sum: f64, + sum_sq: f64, +} + +impl Variance { + /// Construct a new rolling variance with the given period. + /// + /// # Errors + /// Returns [`Error::PeriodZero`] if `period == 0`. + pub fn new(period: usize) -> Result { + if period == 0 { + return Err(Error::PeriodZero); + } + Ok(Self { + period, + window: VecDeque::with_capacity(period), + sum: 0.0, + sum_sq: 0.0, + }) + } + + /// Configured period. + pub const fn period(&self) -> usize { + self.period + } +} + +impl Indicator for Variance { + type Input = f64; + type Output = f64; + + fn update(&mut self, value: f64) -> Option { + if self.window.len() == self.period { + let old = self.window.pop_front().expect("non-empty"); + self.sum -= old; + self.sum_sq -= old * old; + } + self.window.push_back(value); + self.sum += value; + self.sum_sq += value * value; + if self.window.len() < self.period { + return None; + } + let n = self.period as f64; + let mean = self.sum / n; + Some((self.sum_sq / n - mean * mean).max(0.0)) + } + + fn reset(&mut self) { + self.window.clear(); + self.sum = 0.0; + self.sum_sq = 0.0; + } + + fn warmup_period(&self) -> usize { + self.period + } + + fn is_ready(&self) -> bool { + self.window.len() == self.period + } + + fn name(&self) -> &'static str { + "Variance" + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::traits::BatchExt; + use approx::assert_relative_eq; + + #[test] + fn rejects_zero_period() { + assert!(matches!(Variance::new(0), Err(Error::PeriodZero))); + } + + #[test] + fn accessors_and_metadata() { + let v = Variance::new(14).unwrap(); + assert_eq!(v.period(), 14); + assert_eq!(v.warmup_period(), 14); + assert_eq!(v.name(), "Variance"); + } + + #[test] + fn reference_value() { + // Variance(3) of [2, 4, 6]: mean = 4, variance = (4 + 0 + 4) / 3 = 8/3. + let mut v = Variance::new(3).unwrap(); + let out = v.batch(&[2.0, 4.0, 6.0]); + assert_eq!(out[0], None); + assert_eq!(out[1], None); + assert_relative_eq!(out[2].unwrap(), 8.0 / 3.0, epsilon = 1e-12); + } + + #[test] + fn constant_series_yields_zero() { + let mut v = Variance::new(5).unwrap(); + for o in v.batch(&[42.0; 20]).into_iter().flatten() { + assert_relative_eq!(o, 0.0, epsilon = 1e-12); + } + } + + #[test] + fn first_value_on_period_th_input() { + let mut v = Variance::new(5).unwrap(); + let out = v.batch(&[1.0, 2.0, 3.0, 4.0, 5.0, 6.0]); + for (i, x) in out.iter().enumerate().take(4) { + assert!(x.is_none(), "index {i} must be None during warmup"); + } + assert!(out[4].is_some()); + } + + #[test] + fn reset_clears_state() { + let mut v = Variance::new(5).unwrap(); + v.batch(&[1.0, 2.0, 3.0, 4.0, 5.0]); + assert!(v.is_ready()); + v.reset(); + assert!(!v.is_ready()); + assert_eq!(v.update(1.0), None); + } + + #[test] + fn equals_stddev_squared() { + // The rolling Variance must equal the rolling population StdDev squared. + let prices: Vec = (0..60) + .map(|i| 50.0 + (f64::from(i) * 0.3).sin() * 7.0) + .collect(); + let mut var = Variance::new(14).unwrap(); + let mut sd = crate::StdDev::new(14).unwrap(); + for &p in &prices { + let (v, s) = (var.update(p), sd.update(p)); + assert_eq!(v.is_some(), s.is_some()); + if let (Some(v), Some(s)) = (v, s) { + assert_relative_eq!(v, s * s, epsilon = 1e-9); + } + } + } + + #[test] + fn batch_equals_streaming() { + let prices: Vec = (0..60) + .map(|i| 50.0 + (f64::from(i) * 0.3).cos() * 10.0) + .collect(); + let batch = Variance::new(14).unwrap().batch(&prices); + let mut b = Variance::new(14).unwrap(); + let streamed: Vec<_> = prices.iter().map(|p| b.update(*p)).collect(); + assert_eq!(batch, streamed); + } +} diff --git a/crates/wickra-core/src/lib.rs b/crates/wickra-core/src/lib.rs index a224a179..f76d3f35 100644 --- a/crates/wickra-core/src/lib.rs +++ b/crates/wickra-core/src/lib.rs @@ -46,32 +46,34 @@ pub use error::{Error, Result}; pub use indicators::{ AccelerationBands, AccelerationBandsOutput, AcceleratorOscillator, AdOscillator, AdaptiveCycle, Adl, Adx, AdxOutput, Adxr, Alligator, AlligatorOutput, Alma, AnchoredVwap, Apo, Aroon, - AroonOscillator, AroonOutput, Atr, AtrBands, AtrBandsOutput, AtrTrailingStop, - AwesomeOscillator, AwesomeOscillatorHistogram, BalanceOfPower, BollingerBands, + AroonOscillator, AroonOutput, Atr, AtrBands, AtrBandsOutput, AtrTrailingStop, Autocorrelation, + AwesomeOscillator, AwesomeOscillatorHistogram, BalanceOfPower, Beta, BollingerBands, BollingerBandwidth, BollingerOutput, Camarilla, CamarillaPivotsOutput, Cci, CenterOfGravity, Cfo, ChaikinMoneyFlow, ChaikinOscillator, ChaikinVolatility, ChandeKrollStop, ChandeKrollStopOutput, ChandelierExit, ChandelierExitOutput, ChoppinessIndex, ClassicPivots, - ClassicPivotsOutput, Cmo, ConnorsRsi, Coppock, CyberneticCycle, Decycler, DecyclerOscillator, - Dema, DemandIndex, DemarkPivots, DemarkPivotsOutput, Donchian, DonchianOutput, DonchianStop, - DonchianStopOutput, DoubleBollinger, DoubleBollingerOutput, Dpo, EaseOfMovement, - EhlersStochastic, ElderImpulse, Ema, EmpiricalModeDecomposition, Evwma, Fama, FibonacciPivots, - FibonacciPivotsOutput, FisherTransform, ForceIndex, FractalChaosBands, FractalChaosBandsOutput, - Frama, GarmanKlassVolatility, HeikinAshi, HeikinAshiOutput, HiLoActivator, - HilbertDominantCycle, HistoricalVolatility, Hma, HurstChannel, HurstChannelOutput, Ichimoku, + ClassicPivotsOutput, Cmo, CoefficientOfVariation, ConnorsRsi, Coppock, CyberneticCycle, + Decycler, DecyclerOscillator, Dema, DemandIndex, DemarkPivots, DemarkPivotsOutput, + DetrendedStdDev, Donchian, DonchianOutput, DonchianStop, DonchianStopOutput, DoubleBollinger, + DoubleBollingerOutput, Dpo, EaseOfMovement, EhlersStochastic, ElderImpulse, Ema, + EmpiricalModeDecomposition, Evwma, Fama, FibonacciPivots, FibonacciPivotsOutput, + FisherTransform, ForceIndex, FractalChaosBands, FractalChaosBandsOutput, Frama, + GarmanKlassVolatility, HeikinAshi, HeikinAshiOutput, HiLoActivator, HilbertDominantCycle, + HistoricalVolatility, Hma, HurstChannel, HurstChannelOutput, HurstExponent, Ichimoku, IchimokuOutput, Inertia, InstantaneousTrendline, InverseFisherTransform, Jma, Kama, Keltner, - KeltnerOutput, Kst, KstOutput, Kvo, LaguerreRsi, LinRegAngle, LinRegChannel, + KeltnerOutput, Kst, KstOutput, Kurtosis, Kvo, LaguerreRsi, LinRegAngle, LinRegChannel, LinRegChannelOutput, LinRegSlope, LinearRegression, MaEnvelope, MaEnvelopeOutput, MacdIndicator, MacdOutput, Mama, MamaOutput, MarketFacilitationIndex, MassIndex, - McGinleyDynamic, MedianPrice, Mfi, Mom, Natr, Nvi, Obv, ParkinsonVolatility, PercentB, - PercentageTrailingStop, Pgo, Pmo, Ppo, Psar, Pvi, RenkoTrailingStop, Roc, - RogersSatchellVolatility, RollingVwap, RoofingFilter, Rsi, Rvi, RviVolatility, Rwi, RwiOutput, - SineWave, Sma, Smi, Smma, StandardErrorBands, StandardErrorBandsOutput, StarcBands, + McGinleyDynamic, MedianAbsoluteDeviation, MedianPrice, Mfi, Mom, Natr, Nvi, Obv, + ParkinsonVolatility, PearsonCorrelation, PercentB, PercentageTrailingStop, Pgo, Pmo, Ppo, Psar, + Pvi, RSquared, RenkoTrailingStop, Roc, RogersSatchellVolatility, RollingVwap, RoofingFilter, + Rsi, Rvi, RviVolatility, Rwi, RwiOutput, SineWave, Skewness, Sma, Smi, Smma, + SpearmanCorrelation, StandardError, StandardErrorBands, StandardErrorBandsOutput, StarcBands, StarcBandsOutput, Stc, StdDev, StepTrailingStop, StochRsi, Stochastic, StochasticOutput, SuperSmoother, SuperTrend, SuperTrendOutput, TdCombo, TdCountdown, TdDeMarker, TdDifferential, TdLines, TdLinesOutput, TdOpen, TdPressure, TdRangeProjection, TdRangeProjectionOutput, TdRei, TdRiskLevel, TdRiskLevelOutput, TdSequential, TdSequentialOutput, TdSetup, Tema, Tii, Trima, Trix, TrueRange, Tsi, Tsv, TtmSqueeze, TtmSqueezeOutput, TypicalPrice, UlcerIndex, - UltimateOscillator, VerticalHorizontalFilter, Vidya, VoltyStop, VolumeOscillator, + UltimateOscillator, Variance, VerticalHorizontalFilter, Vidya, VoltyStop, VolumeOscillator, VolumePriceTrend, Vortex, VortexOutput, Vwap, VwapStdDevBands, VwapStdDevBandsOutput, Vwma, Vzo, WaveTrend, WaveTrendOutput, WeightedClose, WilliamsFractals, WilliamsFractalsOutput, WilliamsR, Wma, WoodiePivots, WoodiePivotsOutput, YangZhangVolatility, YoyoExit, ZScore, diff --git a/crates/wickra/benches/indicators.rs b/crates/wickra/benches/indicators.rs index b150384a..aae2fc32 100644 --- a/crates/wickra/benches/indicators.rs +++ b/crates/wickra/benches/indicators.rs @@ -20,19 +20,21 @@ use criterion::{criterion_group, criterion_main, BenchmarkId, Criterion, Through use std::hint::black_box; use wickra::{ AccelerationBands, AdOscillator, AdaptiveCycle, Adxr, Alma, AnchoredVwap, Atr, AtrBands, - BatchExt, BollingerBands, Camarilla, Candle, CenterOfGravity, ClassicPivots, CyberneticCycle, - Decycler, DecyclerOscillator, DemandIndex, DemarkPivots, DonchianStop, DoubleBollinger, - EhlersStochastic, Ema, EmpiricalModeDecomposition, Fama, FibonacciPivots, FisherTransform, - FractalChaosBands, Frama, GarmanKlassVolatility, HeikinAshi, HiLoActivator, - HilbertDominantCycle, HurstChannel, Ichimoku, Indicator, InstantaneousTrendline, - InverseFisherTransform, Jma, Kst, Kvo, LinRegChannel, MaEnvelope, MacdIndicator, Mama, - MarketFacilitationIndex, McGinleyDynamic, Nvi, Obv, ParkinsonVolatility, - PercentageTrailingStop, Pgo, Pvi, RenkoTrailingStop, RogersSatchellVolatility, RoofingFilter, - Rsi, Rvi, RviVolatility, Rwi, SineWave, Sma, StandardErrorBands, StarcBands, StepTrailingStop, - Stochastic, SuperSmoother, TdCombo, TdCountdown, TdDeMarker, TdDifferential, TdLines, TdOpen, - TdPressure, TdRangeProjection, TdRei, TdRiskLevel, TdSequential, TdSetup, Tii, Tsv, TtmSqueeze, - Vidya, VoltyStop, VolumeOscillator, VwapStdDevBands, Vzo, WaveTrend, WilliamsFractals, Wma, - WoodiePivots, YangZhangVolatility, YoyoExit, ZigZag, + Autocorrelation, BatchExt, BollingerBands, Camarilla, Candle, CenterOfGravity, ClassicPivots, + CoefficientOfVariation, CyberneticCycle, Decycler, DecyclerOscillator, DemandIndex, + DemarkPivots, DetrendedStdDev, DonchianStop, DoubleBollinger, EhlersStochastic, Ema, + EmpiricalModeDecomposition, Fama, FibonacciPivots, FisherTransform, FractalChaosBands, Frama, + GarmanKlassVolatility, HeikinAshi, HiLoActivator, HilbertDominantCycle, HurstChannel, + HurstExponent, Ichimoku, Indicator, InstantaneousTrendline, InverseFisherTransform, Jma, Kst, + Kurtosis, Kvo, LinRegChannel, MaEnvelope, MacdIndicator, Mama, MarketFacilitationIndex, + McGinleyDynamic, MedianAbsoluteDeviation, Nvi, Obv, ParkinsonVolatility, + PercentageTrailingStop, Pgo, Pvi, RSquared, RenkoTrailingStop, RogersSatchellVolatility, + RoofingFilter, Rsi, Rvi, RviVolatility, Rwi, SineWave, Skewness, Sma, StandardError, + StandardErrorBands, StarcBands, StepTrailingStop, Stochastic, SuperSmoother, TdCombo, + TdCountdown, TdDeMarker, TdDifferential, TdLines, TdOpen, TdPressure, TdRangeProjection, TdRei, + TdRiskLevel, TdSequential, TdSetup, Tii, Tsv, TtmSqueeze, Variance, Vidya, VoltyStop, + VolumeOscillator, VwapStdDevBands, Vzo, WaveTrend, WilliamsFractals, Wma, WoodiePivots, + YangZhangVolatility, YoyoExit, ZigZag, }; use wickra_data::csv::CandleReader; @@ -361,6 +363,30 @@ fn benches(c: &mut Criterion) { bench_scalar_multi(c, "double_bollinger", &closes, || { DoubleBollinger::new(20, 1.0, 2.0).unwrap() }); + + // --- Family 12: Statistik / Regression --- + bench_scalar(c, "variance", &closes, || Variance::new(20).unwrap()); + bench_scalar(c, "coefficient_of_variation", &closes, || { + CoefficientOfVariation::new(20).unwrap() + }); + bench_scalar(c, "skewness", &closes, || Skewness::new(20).unwrap()); + bench_scalar(c, "kurtosis", &closes, || Kurtosis::new(20).unwrap()); + bench_scalar(c, "standard_error", &closes, || { + StandardError::new(14).unwrap() + }); + bench_scalar(c, "detrended_std_dev", &closes, || { + DetrendedStdDev::new(14).unwrap() + }); + bench_scalar(c, "r_squared", &closes, || RSquared::new(14).unwrap()); + bench_scalar(c, "median_absolute_deviation", &closes, || { + MedianAbsoluteDeviation::new(20).unwrap() + }); + bench_scalar(c, "autocorrelation", &closes, || { + Autocorrelation::new(20, 1).unwrap() + }); + bench_scalar(c, "hurst_exponent", &closes, || { + HurstExponent::new(100, 4).unwrap() + }); } /// Variant of `bench_scalar` for scalar-input indicators whose output is *not* diff --git a/fuzz/fuzz_targets/indicator_update.rs b/fuzz/fuzz_targets/indicator_update.rs index 175ebf7f..b25aea01 100644 --- a/fuzz/fuzz_targets/indicator_update.rs +++ b/fuzz/fuzz_targets/indicator_update.rs @@ -15,16 +15,18 @@ use libfuzzer_sys::fuzz_target; use wickra_core::{ - AdaptiveCycle, Alma, Apo, BatchExt, BollingerBands, CenterOfGravity, Cfo, Cmo, ConnorsRsi, - Coppock, CyberneticCycle, Decycler, DecyclerOscillator, Dema, DoubleBollinger, Dpo, - EhlersStochastic, ElderImpulse, Ema, EmpiricalModeDecomposition, Fama, FisherTransform, - Frama, HilbertDominantCycle, HistoricalVolatility, Hma, Indicator, InstantaneousTrendline, - InverseFisherTransform, Jma, Kama, Kst, LaguerreRsi, LinRegAngle, LinRegChannel, - LinRegSlope, LinearRegression, MaEnvelope, MacdIndicator, Mama, McGinleyDynamic, Mom, - PercentageTrailingStop, Pmo, Ppo, RenkoTrailingStop, Roc, RoofingFilter, Rsi, RviVolatility, - SineWave, Sma, Smma, StandardErrorBands, Stc, StdDev, StepTrailingStop, StochRsi, - SuperSmoother, T3, Tema, Tii, Trima, Trix, Tsi, UlcerIndex, VerticalHorizontalFilter, Vidya, - Wma, ZScore, ZeroLagMacd, Zlema, + AdaptiveCycle, Alma, Apo, Autocorrelation, BatchExt, Beta, BollingerBands, CenterOfGravity, + Cfo, Cmo, CoefficientOfVariation, ConnorsRsi, Coppock, CyberneticCycle, Decycler, + DecyclerOscillator, Dema, DetrendedStdDev, DoubleBollinger, Dpo, EhlersStochastic, + ElderImpulse, Ema, EmpiricalModeDecomposition, Fama, FisherTransform, Frama, + HilbertDominantCycle, HistoricalVolatility, Hma, HurstExponent, Indicator, + InstantaneousTrendline, InverseFisherTransform, Jma, Kama, Kst, Kurtosis, LaguerreRsi, + LinRegAngle, LinRegChannel, LinRegSlope, LinearRegression, MaEnvelope, MacdIndicator, Mama, + McGinleyDynamic, MedianAbsoluteDeviation, Mom, PearsonCorrelation, PercentageTrailingStop, + Pmo, Ppo, RSquared, RenkoTrailingStop, Roc, RoofingFilter, Rsi, RviVolatility, SineWave, + Skewness, Sma, Smma, SpearmanCorrelation, StandardError, StandardErrorBands, Stc, StdDev, + StepTrailingStop, StochRsi, SuperSmoother, T3, Tema, Tii, Trima, Trix, Tsi, UlcerIndex, + Variance, VerticalHorizontalFilter, Vidya, Wma, ZScore, ZeroLagMacd, Zlema, }; /// Drive a single streaming + batch run through one scalar indicator. Marked @@ -86,6 +88,18 @@ fuzz_target!(|data: Vec| { drive(|| LinRegAngle::new(14).unwrap(), &data); drive(|| VerticalHorizontalFilter::new(14).unwrap(), &data); drive(|| ZScore::new(14).unwrap(), &data); + drive(|| Variance::new(14).unwrap(), &data); + drive(|| CoefficientOfVariation::new(14).unwrap(), &data); + drive(|| Skewness::new(14).unwrap(), &data); + drive(|| Kurtosis::new(14).unwrap(), &data); + drive(|| StandardError::new(14).unwrap(), &data); + drive(|| DetrendedStdDev::new(14).unwrap(), &data); + drive(|| RSquared::new(14).unwrap(), &data); + drive(|| MedianAbsoluteDeviation::new(14).unwrap(), &data); + drive(|| Autocorrelation::new(14, 2).unwrap(), &data); + // HurstExponent needs `period >= 2 * chunks`; 16/4 is the cheapest fit + // that still exercises every code path. + drive(|| HurstExponent::new(16, 4).unwrap(), &data); drive(|| RviVolatility::new(10).unwrap(), &data); drive(|| LaguerreRsi::new(0.5).unwrap(), &data); drive(|| ConnorsRsi::classic(), &data); @@ -186,4 +200,17 @@ fuzz_target!(|data: Vec| { } let _ = DoubleBollinger::new(20, 1.0, 2.0).unwrap().batch(&data); } + + // Family 12: Two-series indicators — pair adjacent samples of `data`. + { + let mut p = PearsonCorrelation::new(14).unwrap(); + let mut b = Beta::new(14).unwrap(); + let mut s = SpearmanCorrelation::new(14).unwrap(); + for w in data.windows(2) { + let pair = (w[0], w[1]); + let _ = p.update(pair); + let _ = b.update(pair); + let _ = s.update(pair); + } + } });