feat(family-12): add 13 Statistik/Regression indicators (#51)
* 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%.
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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/
|
||||
|
||||
@@ -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);
|
||||
|
||||
+14
-1
@@ -310,7 +310,7 @@ if (!nativeBinding) {
|
||||
throw new Error(`Failed to load native binding`)
|
||||
}
|
||||
|
||||
const { version, SMA, EMA, WMA, RSI, DEMA, TEMA, HMA, ROC, TRIX, SMMA, TRIMA, ZLEMA, MOM, CMO, DPO, StdDev, UlcerIndex, VerticalHorizontalFilter, ZScore, MACD, BollingerBands, ATR, Stochastic, OBV, ADX, ADXR, CCI, WilliamsR, MFI, PSAR, Keltner, Donchian, VWAP, RollingVWAP, AwesomeOscillator, Aroon, KAMA, RVI, PGO, KST, SMI, LaguerreRSI, ConnorsRSI, Inertia, ALMA, McGinleyDynamic, FRAMA, VIDYA, JMA, Alligator, EVWMA, APO, AwesomeOscillatorHistogram, CFO, ZeroLagMACD, ElderImpulse, STC, T3, TSI, PMO, TII, ADL, VolumePriceTrend, ChaikinMoneyFlow, ChaikinOscillator, ForceIndex, EaseOfMovement, KVO, VolumeOscillator, NVI, PVI, WilliamsAD, AnchoredVWAP, DemandIndex, TSV, VZO, MarketFacilitationIndex, SuperTrend, ChandelierExit, ChandeKrollStop, AtrTrailingStop, HiLoActivator, VoltyStop, YoyoExit, DonchianStop, PercentageTrailingStop, StepTrailingStop, RenkoTrailingStop, TypicalPrice, MedianPrice, WeightedClose, LinearRegression, LinRegSlope, AcceleratorOscillator, BalanceOfPower, ChoppinessIndex, TrueRange, ChaikinVolatility, LinRegAngle, BollingerBandwidth, PercentB, NATR, HistoricalVolatility, AroonOscillator, Vortex, RWI, WaveTrend, MassIndex, StochRSI, UltimateOscillator, PPO, Coppock, VWMA, RVIVolatility, ParkinsonVolatility, GarmanKlassVolatility, RogersSatchellVolatility, YangZhangVolatility, MaEnvelope, AccelerationBands, StarcBands, AtrBands, HurstChannel, LinRegChannel, StandardErrorBands, DoubleBollinger, TtmSqueeze, FractalChaosBands, VwapStdDevBands, ClassicPivots, FibonacciPivots, Camarilla, WoodiePivots, DemarkPivots, WilliamsFractals, ZigZag, TDSetup, TDSequential, TDDeMarker, TDREI, TDPressure, TDCombo, TDCountdown, TDLines, TDRangeProjection, TDDifferential, TDOpen, TDRiskLevel, 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
|
||||
|
||||
@@ -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<Self> {
|
||||
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<f64> {
|
||||
self.inner.update(value)
|
||||
}
|
||||
#[napi]
|
||||
pub fn batch(&mut self, prices: Vec<f64>) -> Vec<f64> {
|
||||
flatten(self.inner.batch(&prices))
|
||||
}
|
||||
#[napi]
|
||||
pub fn reset(&mut self) {
|
||||
self.inner.reset();
|
||||
}
|
||||
#[napi(js_name = "isReady")]
|
||||
pub fn is_ready(&self) -> bool {
|
||||
self.inner.is_ready()
|
||||
}
|
||||
#[napi(js_name = "warmupPeriod")]
|
||||
pub fn warmup_period(&self) -> u32 {
|
||||
self.inner.warmup_period() as u32
|
||||
}
|
||||
}
|
||||
|
||||
// ============================== 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<Self> {
|
||||
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<f64> {
|
||||
self.inner.update(value)
|
||||
}
|
||||
#[napi]
|
||||
pub fn batch(&mut self, prices: Vec<f64>) -> Vec<f64> {
|
||||
flatten(self.inner.batch(&prices))
|
||||
}
|
||||
#[napi]
|
||||
pub fn reset(&mut self) {
|
||||
self.inner.reset();
|
||||
}
|
||||
#[napi(js_name = "isReady")]
|
||||
pub fn is_ready(&self) -> bool {
|
||||
self.inner.is_ready()
|
||||
}
|
||||
#[napi(js_name = "warmupPeriod")]
|
||||
pub fn warmup_period(&self) -> u32 {
|
||||
self.inner.warmup_period() as u32
|
||||
}
|
||||
}
|
||||
|
||||
// ============================== 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<Self> {
|
||||
Ok(Self {
|
||||
inner: <$rust_ty>::new(period as usize).map_err(map_err)?,
|
||||
})
|
||||
}
|
||||
#[napi]
|
||||
pub fn update(&mut self, x: f64, y: f64) -> Option<f64> {
|
||||
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<f64>, y: Vec<f64>) -> napi::Result<Vec<f64>> {
|
||||
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.
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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<Self> {
|
||||
Ok(Self {
|
||||
inner: wc::Variance::new(period).map_err(map_err)?,
|
||||
})
|
||||
}
|
||||
fn update(&mut self, value: f64) -> Option<f64> {
|
||||
self.inner.update(value)
|
||||
}
|
||||
fn batch<'py>(
|
||||
&mut self,
|
||||
py: Python<'py>,
|
||||
prices: PyReadonlyArray1<'py, f64>,
|
||||
) -> PyResult<Bound<'py, PyArray1<f64>>> {
|
||||
let 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<Self> {
|
||||
Ok(Self {
|
||||
inner: wc::CoefficientOfVariation::new(period).map_err(map_err)?,
|
||||
})
|
||||
}
|
||||
fn update(&mut self, value: f64) -> Option<f64> {
|
||||
self.inner.update(value)
|
||||
}
|
||||
fn batch<'py>(
|
||||
&mut self,
|
||||
py: Python<'py>,
|
||||
prices: PyReadonlyArray1<'py, f64>,
|
||||
) -> PyResult<Bound<'py, PyArray1<f64>>> {
|
||||
let 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<Self> {
|
||||
Ok(Self {
|
||||
inner: wc::Skewness::new(period).map_err(map_err)?,
|
||||
})
|
||||
}
|
||||
fn update(&mut self, value: f64) -> Option<f64> {
|
||||
self.inner.update(value)
|
||||
}
|
||||
fn batch<'py>(
|
||||
&mut self,
|
||||
py: Python<'py>,
|
||||
prices: PyReadonlyArray1<'py, f64>,
|
||||
) -> PyResult<Bound<'py, PyArray1<f64>>> {
|
||||
let 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<Self> {
|
||||
Ok(Self {
|
||||
inner: wc::Kurtosis::new(period).map_err(map_err)?,
|
||||
})
|
||||
}
|
||||
fn update(&mut self, value: f64) -> Option<f64> {
|
||||
self.inner.update(value)
|
||||
}
|
||||
fn batch<'py>(
|
||||
&mut self,
|
||||
py: Python<'py>,
|
||||
prices: PyReadonlyArray1<'py, f64>,
|
||||
) -> PyResult<Bound<'py, PyArray1<f64>>> {
|
||||
let 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<Self> {
|
||||
Ok(Self {
|
||||
inner: wc::StandardError::new(period).map_err(map_err)?,
|
||||
})
|
||||
}
|
||||
fn update(&mut self, value: f64) -> Option<f64> {
|
||||
self.inner.update(value)
|
||||
}
|
||||
fn batch<'py>(
|
||||
&mut self,
|
||||
py: Python<'py>,
|
||||
prices: PyReadonlyArray1<'py, f64>,
|
||||
) -> PyResult<Bound<'py, PyArray1<f64>>> {
|
||||
let 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<Self> {
|
||||
Ok(Self {
|
||||
inner: wc::DetrendedStdDev::new(period).map_err(map_err)?,
|
||||
})
|
||||
}
|
||||
fn update(&mut self, value: f64) -> Option<f64> {
|
||||
self.inner.update(value)
|
||||
}
|
||||
fn batch<'py>(
|
||||
&mut self,
|
||||
py: Python<'py>,
|
||||
prices: PyReadonlyArray1<'py, f64>,
|
||||
) -> PyResult<Bound<'py, PyArray1<f64>>> {
|
||||
let 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<Self> {
|
||||
Ok(Self {
|
||||
inner: wc::RSquared::new(period).map_err(map_err)?,
|
||||
})
|
||||
}
|
||||
fn update(&mut self, value: f64) -> Option<f64> {
|
||||
self.inner.update(value)
|
||||
}
|
||||
fn batch<'py>(
|
||||
&mut self,
|
||||
py: Python<'py>,
|
||||
prices: PyReadonlyArray1<'py, f64>,
|
||||
) -> PyResult<Bound<'py, PyArray1<f64>>> {
|
||||
let 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<Self> {
|
||||
Ok(Self {
|
||||
inner: wc::Autocorrelation::new(period, lag).map_err(map_err)?,
|
||||
})
|
||||
}
|
||||
fn update(&mut self, value: f64) -> Option<f64> {
|
||||
self.inner.update(value)
|
||||
}
|
||||
fn batch<'py>(
|
||||
&mut self,
|
||||
py: Python<'py>,
|
||||
prices: PyReadonlyArray1<'py, f64>,
|
||||
) -> PyResult<Bound<'py, PyArray1<f64>>> {
|
||||
let 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<Self> {
|
||||
Ok(Self {
|
||||
inner: wc::MedianAbsoluteDeviation::new(period).map_err(map_err)?,
|
||||
})
|
||||
}
|
||||
fn update(&mut self, value: f64) -> Option<f64> {
|
||||
self.inner.update(value)
|
||||
}
|
||||
fn batch<'py>(
|
||||
&mut self,
|
||||
py: Python<'py>,
|
||||
prices: PyReadonlyArray1<'py, f64>,
|
||||
) -> PyResult<Bound<'py, PyArray1<f64>>> {
|
||||
let 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<Self> {
|
||||
Ok(Self {
|
||||
inner: wc::HurstExponent::new(period, chunks).map_err(map_err)?,
|
||||
})
|
||||
}
|
||||
fn update(&mut self, value: f64) -> Option<f64> {
|
||||
self.inner.update(value)
|
||||
}
|
||||
fn batch<'py>(
|
||||
&mut self,
|
||||
py: Python<'py>,
|
||||
prices: PyReadonlyArray1<'py, f64>,
|
||||
) -> PyResult<Bound<'py, PyArray1<f64>>> {
|
||||
let 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<Self> {
|
||||
Ok(Self {
|
||||
inner: wc::PearsonCorrelation::new(period).map_err(map_err)?,
|
||||
})
|
||||
}
|
||||
fn update(&mut self, x: f64, y: f64) -> Option<f64> {
|
||||
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<Bound<'py, PyArray1<f64>>> {
|
||||
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<Self> {
|
||||
Ok(Self {
|
||||
inner: wc::Beta::new(period).map_err(map_err)?,
|
||||
})
|
||||
}
|
||||
fn update(&mut self, asset: f64, benchmark: f64) -> Option<f64> {
|
||||
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<Bound<'py, PyArray1<f64>>> {
|
||||
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<Self> {
|
||||
Ok(Self {
|
||||
inner: wc::SpearmanCorrelation::new(period).map_err(map_err)?,
|
||||
})
|
||||
}
|
||||
fn update(&mut self, x: f64, y: f64) -> Option<f64> {
|
||||
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<Bound<'py, PyArray1<f64>>> {
|
||||
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::<PyIchimoku>()?;
|
||||
m.add_class::<PyHeikinAshi>()?;
|
||||
m.add_class::<PyVariance>()?;
|
||||
m.add_class::<PyCoefficientOfVariation>()?;
|
||||
m.add_class::<PySkewness>()?;
|
||||
m.add_class::<PyKurtosis>()?;
|
||||
m.add_class::<PyStandardError>()?;
|
||||
m.add_class::<PyDetrendedStdDev>()?;
|
||||
m.add_class::<PyRSquared>()?;
|
||||
m.add_class::<PyAutocorrelation>()?;
|
||||
m.add_class::<PyMedianAbsoluteDeviation>()?;
|
||||
m.add_class::<PyHurstExponent>()?;
|
||||
m.add_class::<PyPearsonCorrelation>()?;
|
||||
m.add_class::<PyBeta>()?;
|
||||
m.add_class::<PySpearmanCorrelation>()?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -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 ---
|
||||
|
||||
|
||||
|
||||
@@ -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<f64> {
|
||||
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<Float64Array, JsError> {
|
||||
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)]
|
||||
|
||||
@@ -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<f64>,
|
||||
}
|
||||
|
||||
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<Self> {
|
||||
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<f64> {
|
||||
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::<f64>() / 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<f64> = (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<f64> = (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<f64> = (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);
|
||||
}
|
||||
}
|
||||
@@ -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<Self> {
|
||||
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<f64> {
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -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<f64>,
|
||||
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<Self> {
|
||||
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<f64> {
|
||||
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<f64> = (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);
|
||||
}
|
||||
}
|
||||
@@ -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<f64>,
|
||||
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<Self> {
|
||||
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<f64> {
|
||||
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<f64> = (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<f64> = (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<f64> = (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);
|
||||
}
|
||||
}
|
||||
@@ -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<f64>,
|
||||
}
|
||||
|
||||
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<Self> {
|
||||
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<f64> {
|
||||
let n = chunk.len() as f64;
|
||||
let mean = chunk.iter().sum::<f64>() / 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<f64> {
|
||||
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<f64> = 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<f64> = (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<f64> = (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<f64> = (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);
|
||||
}
|
||||
}
|
||||
@@ -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<f64>,
|
||||
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<Self> {
|
||||
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<f64> {
|
||||
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<f64> = (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);
|
||||
}
|
||||
}
|
||||
@@ -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<f64>,
|
||||
/// Reusable scratch buffer to avoid allocating per `update`.
|
||||
scratch: Vec<f64>,
|
||||
}
|
||||
|
||||
impl MedianAbsoluteDeviation {
|
||||
/// Construct a new rolling MAD with the given period.
|
||||
///
|
||||
/// # Errors
|
||||
/// Returns [`Error::PeriodZero`] if `period == 0`.
|
||||
pub fn new(period: usize) -> Result<Self> {
|
||||
if period == 0 {
|
||||
return Err(Error::PeriodZero);
|
||||
}
|
||||
Ok(Self {
|
||||
period,
|
||||
window: VecDeque::with_capacity(period),
|
||||
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<f64> {
|
||||
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<f64> = (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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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<Self> {
|
||||
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<f64> {
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -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<f64>,
|
||||
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<Self> {
|
||||
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<f64> {
|
||||
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<f64> = (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<f64> = (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<f64> = (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);
|
||||
}
|
||||
}
|
||||
@@ -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<f64>,
|
||||
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<Self> {
|
||||
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<f64> {
|
||||
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<f64> = (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);
|
||||
}
|
||||
}
|
||||
@@ -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<f64>,
|
||||
ry: Vec<f64>,
|
||||
}
|
||||
|
||||
impl SpearmanCorrelation {
|
||||
/// Construct a new rolling Spearman correlation.
|
||||
///
|
||||
/// # Errors
|
||||
/// Returns [`Error::InvalidPeriod`] if `period < 2`.
|
||||
pub fn new(period: usize) -> Result<Self> {
|
||||
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<Item = f64>,
|
||||
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<f64> {
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -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<f64>,
|
||||
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<Self> {
|
||||
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<f64> {
|
||||
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<f64> = (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::<f64>() / 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<f64> = (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<f64> = (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);
|
||||
}
|
||||
}
|
||||
@@ -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<f64>,
|
||||
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<Self> {
|
||||
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<f64> {
|
||||
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<f64> = (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<f64> = (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);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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*
|
||||
|
||||
@@ -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<f64>| {
|
||||
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<f64>| {
|
||||
}
|
||||
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);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user