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:
kingchenc
2026-05-25 23:42:05 +02:00
committed by GitHub
parent 5aa0949bce
commit 05fcdd9a5e
26 changed files with 4303 additions and 42 deletions
+31
View File
@@ -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
+3 -3
View File
@@ -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
View File
@@ -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
+156
View File
@@ -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.
+26
View File
@@ -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",
+713
View File
@@ -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 ---
+69
View File
@@ -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);
}
}
+228
View File
@@ -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 (10100) 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);
}
}
+26
View File
@@ -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);
}
}
+17 -15
View File
@@ -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,
+39 -13
View File
@@ -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*
+37 -10
View File
@@ -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);
}
}
});