diff --git a/CHANGELOG.md b/CHANGELOG.md
index 99673049..d2e426e6 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -7,6 +7,35 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased]
+### Added
+- **Cross-asset pairwise indicators.** A new two-series family of
+ `Indicator` implementations that relate two distinct
+ assets rather than a single OHLCV stream. Each is exposed in Rust, Python,
+ Node, and WASM:
+ - **Pairwise Beta** (`PairwiseBeta`) — rolling OLS slope of one asset's
+ **log-returns** on another's. Unlike `Beta`, which regresses the raw inputs
+ it is fed, `PairwiseBeta` differences consecutive prices into log-returns
+ internally — the conventional way to measure cross-asset beta, where a beta
+ on price levels would be dominated by the shared trend.
+ - **Pair Spread Z-Score** (`PairSpreadZScore`) — the standardised log-spread
+ `ln(a) − β·ln(b)` of a pair, where `β` is a rolling-OLS hedge ratio and the
+ spread is z-scored over its own look-back. The canonical mean-reversion /
+ statistical-arbitrage entry signal, with independent `beta_period` and
+ `z_period` windows.
+ - **Lead–Lag Cross-Correlation** (`LeadLagCrossCorrelation`) — the integer
+ offset `k ∈ [−max_lag, max_lag]` that maximises `|corr(a[t], b[t+k])|`,
+ answering which of two assets leads the other and by how many bars. Emits
+ `{ lag, correlation }`; a positive lag means `a` leads `b`.
+ - **Cointegration** (`Cointegration`) — the Engle–Granger two-step screen for
+ pairs trading: a rolling OLS hedge ratio `β`, the spread (residual)
+ `a − (α + β·b)`, and an augmented Dickey–Fuller `t`-statistic on the spread
+ (configurable `adf_lags`). A strongly negative statistic flags a
+ mean-reverting, tradeable spread. Emits `{ hedge_ratio, spread, adf_stat }`.
+ - **Relative Strength A-vs-B** (`RelativeStrengthAB`) — the comparative
+ relative strength of two assets: the ratio line `a / b` together with its
+ moving average and its RSI, the classic asset-vs-asset / asset-vs-index
+ rotation screen. Emits `{ ratio, ratio_ma, ratio_rsi }`.
+
## [0.4.0] - 2026-06-01
### Added
diff --git a/README.md b/README.md
index a89d43ba..cbc3b9d8 100644
--- a/README.md
+++ b/README.md
@@ -47,7 +47,7 @@ Full documentation lives at **[docs.wickra.org](https://docs.wickra.org)**:
[Node](https://docs.wickra.org/Quickstart-Node),
[WASM](https://docs.wickra.org/Quickstart-WASM).
- **Indicators** — a per-indicator deep dive (formula, parameters, warmup) for
- every one of the 214 indicators; start at the
+ every one of the 219 indicators; start at the
[indicators overview](https://docs.wickra.org/Indicators-Overview).
- **Reference** — [warmup periods](https://docs.wickra.org/Warmup-Periods),
[streaming vs batch](https://docs.wickra.org/Streaming-vs-Batch),
@@ -135,7 +135,7 @@ python -m benchmarks.compare_libraries
## Indicators
-214 streaming-first indicators across sixteen families. Every one passes the
+219 streaming-first indicators across sixteen families. Every one passes the
`batch == streaming` equivalence test, reference-value tests, and reset
semantics tests. Each has a per-indicator deep dive (formula, parameters,
warmup) at [docs.wickra.org](https://docs.wickra.org/Indicators-Overview).
@@ -150,7 +150,7 @@ warmup) at [docs.wickra.org](https://docs.wickra.org/Indicators-Overview).
| Bands & Channels | MA Envelope, Acceleration Bands, STARC Bands, ATR Bands, Hurst Channel, LinReg Channel, Standard Error Bands, Double Bollinger Bands, TTM Squeeze, Fractal Chaos Bands, VWAP StdDev Bands |
| 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, Variance, Coefficient of Variation, Skewness, Kurtosis, Standard Error, Detrended StdDev, R², Median Absolute Deviation, Autocorrelation, Hurst Exponent, Pearson Correlation, Beta, Spearman Correlation |
+| Price Statistics | Typical Price, Median Price, Weighted Close, Linear Regression, Linear Regression Slope, Z-Score, Linear Regression Angle, Variance, Coefficient of Variation, Skewness, Kurtosis, Standard Error, Detrended StdDev, R², Median Absolute Deviation, Autocorrelation, Hurst Exponent, Pearson Correlation, Beta, Pairwise Beta, Pair Spread Z-Score, Lead-Lag Cross-Correlation, Cointegration, Relative Strength A-vs-B, Spearman Correlation |
| 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 |
@@ -230,7 +230,7 @@ A Python live-trading example using the public `websockets` package lives at
```
wickra/
├── crates/
-│ ├── wickra-core/ core engine + all 214 indicators
+│ ├── wickra-core/ core engine + all 219 indicators
│ ├── wickra/ top-level facade crate (publishes on crates.io) + benches/
│ └── wickra-data/ CSV reader, tick aggregator, live exchange feeds
├── bindings/
diff --git a/bindings/node/__tests__/indicators.test.js b/bindings/node/__tests__/indicators.test.js
index bfeedb90..937797df 100644
--- a/bindings/node/__tests__/indicators.test.js
+++ b/bindings/node/__tests__/indicators.test.js
@@ -461,6 +461,8 @@ test('OpeningRange(2) breakout distance is signed close minus midpoint', () => {
const pairFactories = {
PearsonCorrelation: () => new wickra.PearsonCorrelation(14),
Beta: () => new wickra.Beta(14),
+ PairwiseBeta: () => new wickra.PairwiseBeta(14),
+ PairSpreadZScore: () => new wickra.PairSpreadZScore(14, 14),
SpearmanCorrelation: () => new wickra.SpearmanCorrelation(14),
};
@@ -492,6 +494,85 @@ test('Beta perfect two-to-one', () => {
assert.ok(Math.abs(out[out.length - 1] - 2) < 1e-9);
});
+test('PairwiseBeta squared price is two', () => {
+ // b needs varying returns; a = b² ⇒ a's log-returns are exactly 2× b's.
+ const bench = Array.from({ length: 20 }, (_, i) => 100 + 10 * Math.sin(i * 0.5));
+ const asset = bench.map((v) => v * v);
+ const out = new wickra.PairwiseBeta(5).batch(asset, bench);
+ assert.ok(Math.abs(out[out.length - 1] - 2) < 1e-9);
+});
+
+test('PairSpreadZScore flat benchmark is sign of last move', () => {
+ // Flat b ⇒ hedge ratio 0 ⇒ spread = ln(a); z_period = 2 ⇒ z = sign of move.
+ const a = [100, 100, 110, 105, 130];
+ const b = [100, 100, 100, 100, 100];
+ const out = new wickra.PairSpreadZScore(2, 2).batch(a, b);
+ assert.ok(Math.abs(out[out.length - 1] - 1) < 1e-9);
+ assert.ok(Math.abs(out[out.length - 2] + 1) < 1e-9);
+});
+
+const llSignal = (t) =>
+ Math.sin(t * 0.4) + 0.4 * Math.sin(t * 1.1) + 0.2 * Math.cos(t * 0.27);
+
+test('LeadLagCrossCorrelation detects positive lead (object output)', () => {
+ const ll = new wickra.LeadLagCrossCorrelation(12, 5);
+ let last = null;
+ // b is a delayed by 3 ⇒ a leads b ⇒ lag = +3.
+ for (let t = 0; t < 60; t++) last = ll.update(llSignal(t), llSignal(t - 3));
+ assert.equal(last.lag, 3);
+ assert.ok(last.correlation > 0.99);
+});
+
+test('LeadLagCrossCorrelation batch is flat 2*n with last row matching', () => {
+ const n = 60;
+ const a = Array.from({ length: n }, (_, t) => llSignal(t));
+ const b = Array.from({ length: n }, (_, t) => llSignal(t - 3));
+ const out = new wickra.LeadLagCrossCorrelation(12, 5).batch(a, b);
+ assert.equal(out.length, 2 * n);
+ assert.equal(out[2 * (n - 1)], 3);
+ assert.ok(out[2 * (n - 1) + 1] > 0.99);
+});
+
+test('Cointegration detects mean-reverting pair (object output)', () => {
+ const n = 80;
+ const b = Array.from({ length: n }, (_, t) => 50 + 0.5 * t);
+ const a = b.map((v, t) => 2 * v + 1 + 0.5 * Math.sin(t * 0.6));
+ const co = new wickra.Cointegration(40, 1);
+ let last = null;
+ for (let i = 0; i < n; i++) last = co.update(a[i], b[i]);
+ assert.ok(Math.abs(last.hedgeRatio - 2) < 0.1);
+ assert.ok(last.adfStat < -2);
+});
+
+test('Cointegration batch is flat 3*n with last row matching', () => {
+ const n = 80;
+ const b = Array.from({ length: n }, (_, t) => 50 + 0.5 * t);
+ const a = b.map((v, t) => 2 * v + 1 + 0.5 * Math.sin(t * 0.6));
+ const out = new wickra.Cointegration(40, 1).batch(a, b);
+ assert.equal(out.length, 3 * n);
+ assert.ok(Math.abs(out[3 * (n - 1)] - 2) < 0.1);
+ assert.ok(out[3 * (n - 1) + 2] < -2);
+});
+
+test('RelativeStrengthAB constant ratio is flat (object output)', () => {
+ const rs = new wickra.RelativeStrengthAB(5, 5);
+ let last = null;
+ for (let i = 0; i < 30; i++) last = rs.update(200, 100); // ratio is a constant 2
+ assert.ok(Math.abs(last.ratio - 2) < 1e-12);
+ assert.ok(Math.abs(last.ratioMa - 2) < 1e-12);
+ assert.ok(Math.abs(last.ratioRsi - 50) < 1e-9);
+});
+
+test('RelativeStrengthAB batch is flat 3*n with last row matching', () => {
+ const n = 30;
+ const a = Array.from({ length: n }, () => 200);
+ const b = Array.from({ length: n }, () => 100);
+ const out = new wickra.RelativeStrengthAB(5, 5).batch(a, b);
+ assert.equal(out.length, 3 * n);
+ assert.ok(Math.abs(out[3 * (n - 1)] - 2) < 1e-12);
+ assert.ok(Math.abs(out[3 * (n - 1) + 2] - 50) < 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);
diff --git a/bindings/node/index.d.ts b/bindings/node/index.d.ts
index 31639ac8..5e8e3229 100644
--- a/bindings/node/index.d.ts
+++ b/bindings/node/index.d.ts
@@ -5,6 +5,34 @@
/** Library version (matches the Rust crate version). */
export declare function version(): string
+/** Lead/lag result: the offset that maximises correlation, and that correlation. */
+export interface LeadLagValue {
+ /** Offset that maximises `|corr(a, b shifted)|`. Positive ⇒ `a` leads `b`. */
+ lag: number
+ /** Signed correlation at that lag, in `[-1, 1]`. */
+ correlation: number
+}
+/** Cointegration result: hedge ratio, current spread, and the ADF statistic. */
+export interface CointegrationValue {
+ /** Engle–Granger hedge ratio (OLS slope of `a` on `b`). */
+ hedgeRatio: number
+ /** Current spread (regression residual) `a - (alpha + beta*b)`. */
+ spread: number
+ /**
+ * Augmented Dickey–Fuller statistic on the spread; more negative ⇒ more
+ * strongly mean-reverting.
+ */
+ adfStat: number
+}
+/** Relative-strength triple: the a/b ratio, its moving average, and its RSI. */
+export interface RelativeStrengthValue {
+ /** Raw ratio `a / b`. */
+ ratio: number
+ /** Moving average of the ratio. */
+ ratioMa: number
+ /** RSI of the ratio. */
+ ratioRsi: number
+}
/** MACD triple: macd line, signal line, histogram. */
export interface MacdValue {
macd: number
@@ -628,6 +656,19 @@ export declare class Beta {
isReady(): boolean
warmupPeriod(): number
}
+export type PairwiseBetaNode = PairwiseBeta
+export declare class PairwiseBeta {
+ constructor(period: number)
+ update(x: number, y: number): number | null
+ /**
+ * Batch over two equally-sized arrays. Returns a length-`n` array
+ * with `NaN` for warmup positions.
+ */
+ batch(x: Array, y: Array): Array
+ reset(): void
+ isReady(): boolean
+ warmupPeriod(): number
+}
export type SpearmanCorrelationNode = SpearmanCorrelation
export declare class SpearmanCorrelation {
constructor(period: number)
@@ -641,6 +682,65 @@ export declare class SpearmanCorrelation {
isReady(): boolean
warmupPeriod(): number
}
+export type PairSpreadZScoreNode = PairSpreadZScore
+/**
+ * Pair spread z-score: two ctor params (`betaPeriod`, `zPeriod`), one `(a, b)`
+ * price pair per update, a single z-score out.
+ */
+export declare class PairSpreadZScore {
+ constructor(betaPeriod: number, zPeriod: number)
+ update(a: number, b: number): number | null
+ /**
+ * Batch over two equally-sized arrays of prices. Returns a length-`n`
+ * array with `NaN` for warmup positions.
+ */
+ batch(a: Array, b: Array): Array
+ reset(): void
+ isReady(): boolean
+ warmupPeriod(): number
+}
+export type LeadLagCrossCorrelationNode = LeadLagCrossCorrelation
+export declare class LeadLagCrossCorrelation {
+ constructor(window: number, maxLag: number)
+ update(a: number, b: number): LeadLagValue | null
+ /**
+ * Batch over two equally-sized arrays. Returns a flat array of length
+ * `2 * n`, interleaved per row as `[lag0, corr0, lag1, corr1, ...]`. Read
+ * column `j` of row `i` as `result[i * 2 + j]`. Warmup rows are `NaN`.
+ */
+ batch(a: Array, b: Array): Array
+ reset(): void
+ isReady(): boolean
+ warmupPeriod(): number
+}
+export type CointegrationNode = Cointegration
+export declare class Cointegration {
+ constructor(period: number, adfLags: number)
+ update(a: number, b: number): CointegrationValue | null
+ /**
+ * Batch over two equally-sized arrays. Returns a flat array of length
+ * `3 * n`, interleaved per row as `[hedgeRatio0, spread0, adfStat0, ...]`.
+ * Read column `j` of row `i` as `result[i * 3 + j]`. Warmup rows are `NaN`.
+ */
+ batch(a: Array, b: Array): Array
+ reset(): void
+ isReady(): boolean
+ warmupPeriod(): number
+}
+export type RelativeStrengthAbNode = RelativeStrengthAB
+export declare class RelativeStrengthAB {
+ constructor(maPeriod: number, rsiPeriod: number)
+ update(a: number, b: number): RelativeStrengthValue | null
+ /**
+ * Batch over two equally-sized arrays. Returns a flat array of length
+ * `3 * n`, interleaved per row as `[ratio0, ratioMa0, ratioRsi0, ...]`.
+ * Read column `j` of row `i` as `result[i * 3 + j]`. Warmup rows are `NaN`.
+ */
+ batch(a: Array, b: Array): Array
+ reset(): void
+ isReady(): boolean
+ warmupPeriod(): number
+}
export type MacdNode = MACD
export declare class MACD {
constructor(fast: number, slow: number, signal: number)
diff --git a/bindings/node/index.js b/bindings/node/index.js
index f110f316..39d8e357 100644
--- a/bindings/node/index.js
+++ b/bindings/node/index.js
@@ -310,7 +310,7 @@ if (!nativeBinding) {
throw new Error(`Failed to load native binding`)
}
-const { version, SMA, EMA, WMA, RSI, DEMA, TEMA, HMA, ROC, TRIX, SMMA, TRIMA, ZLEMA, MOM, CMO, DPO, StdDev, UlcerIndex, VerticalHorizontalFilter, ZScore, MACD, BollingerBands, ATR, Stochastic, OBV, ADX, ADXR, CCI, WilliamsR, MFI, PSAR, Keltner, Donchian, VWAP, RollingVWAP, AwesomeOscillator, Aroon, KAMA, RVI, PGO, KST, SMI, LaguerreRSI, ConnorsRSI, Inertia, ALMA, McGinleyDynamic, FRAMA, VIDYA, JMA, Alligator, EVWMA, APO, AwesomeOscillatorHistogram, CFO, ZeroLagMACD, ElderImpulse, STC, T3, TSI, PMO, TII, ADL, VolumePriceTrend, ChaikinMoneyFlow, ChaikinOscillator, ForceIndex, EaseOfMovement, KVO, VolumeOscillator, NVI, PVI, WilliamsAD, AnchoredVWAP, DemandIndex, TSV, VZO, MarketFacilitationIndex, SuperTrend, ChandelierExit, ChandeKrollStop, AtrTrailingStop, HiLoActivator, VoltyStop, YoyoExit, DonchianStop, PercentageTrailingStop, StepTrailingStop, RenkoTrailingStop, TypicalPrice, MedianPrice, WeightedClose, LinearRegression, LinRegSlope, AcceleratorOscillator, BalanceOfPower, ChoppinessIndex, TrueRange, ChaikinVolatility, LinRegAngle, BollingerBandwidth, PercentB, NATR, HistoricalVolatility, AroonOscillator, Vortex, RWI, WaveTrend, MassIndex, StochRSI, UltimateOscillator, PPO, Coppock, VWMA, RVIVolatility, ParkinsonVolatility, GarmanKlassVolatility, RogersSatchellVolatility, YangZhangVolatility, MaEnvelope, AccelerationBands, StarcBands, AtrBands, HurstChannel, LinRegChannel, StandardErrorBands, DoubleBollinger, TtmSqueeze, FractalChaosBands, VwapStdDevBands, ClassicPivots, FibonacciPivots, Camarilla, WoodiePivots, DemarkPivots, WilliamsFractals, ZigZag, TDSetup, TDSequential, TDDeMarker, TDREI, TDPressure, TDCombo, TDCountdown, TDLines, TDRangeProjection, TDDifferential, TDOpen, TDRiskLevel, SuperSmoother, FisherTransform, InverseFisherTransform, Decycler, DecyclerOscillator, RoofingFilter, CenterOfGravity, CyberneticCycle, InstantaneousTrendline, EhlersStochastic, EmpiricalModeDecomposition, HilbertDominantCycle, AdaptiveCycle, SineWave, MAMA, FAMA, Ichimoku, HeikinAshi, Variance, CoefficientOfVariation, Skewness, Kurtosis, StandardError, DetrendedStdDev, RSquared, MedianAbsoluteDeviation, Autocorrelation, HurstExponent, PearsonCorrelation, Beta, SpearmanCorrelation, ValueArea, InitialBalance, OpeningRange, Doji, Hammer, InvertedHammer, HangingMan, ShootingStar, Engulfing, Harami, MorningEveningStar, ThreeSoldiersOrCrows, PiercingDarkCloud, Marubozu, Tweezer, SpinningTop, ThreeInside, ThreeOutside, SharpeRatio, SortinoRatio, CalmarRatio, OmegaRatio, MaxDrawdown, AverageDrawdown, DrawdownDuration, PainIndex, ValueAtRisk, ConditionalValueAtRisk, ProfitFactor, GainLossRatio, RecoveryFactor, KellyCriterion, TreynorRatio, InformationRatio, Alpha } = nativeBinding
+const { version, SMA, EMA, WMA, RSI, DEMA, TEMA, HMA, ROC, TRIX, SMMA, TRIMA, ZLEMA, MOM, CMO, DPO, StdDev, UlcerIndex, VerticalHorizontalFilter, ZScore, McGinleyDynamic, FRAMA, SuperSmoother, FisherTransform, Decycler, CenterOfGravity, CyberneticCycle, InstantaneousTrendline, EhlersStochastic, RVIVolatility, Variance, CoefficientOfVariation, Skewness, Kurtosis, StandardError, DetrendedStdDev, RSquared, MedianAbsoluteDeviation, Autocorrelation, HurstExponent, PearsonCorrelation, Beta, PairwiseBeta, SpearmanCorrelation, PairSpreadZScore, LeadLagCrossCorrelation, Cointegration, RelativeStrengthAB, MACD, BollingerBands, ATR, Stochastic, OBV, ADX, ADXR, CCI, WilliamsR, MFI, PSAR, Keltner, Donchian, VWAP, RollingVWAP, AwesomeOscillator, Aroon, Inertia, ConnorsRSI, LaguerreRSI, SMI, KST, PGO, RVI, AwesomeOscillatorHistogram, STC, ElderImpulse, ZeroLagMACD, CFO, APO, KAMA, EVWMA, Alligator, JMA, VIDYA, ALMA, T3, TSI, PMO, TII, ADL, VolumePriceTrend, ChaikinMoneyFlow, ChaikinOscillator, ForceIndex, NVI, PVI, VolumeOscillator, KVO, WilliamsAD, AnchoredVWAP, DemandIndex, TSV, VZO, MarketFacilitationIndex, EaseOfMovement, SuperTrend, ChandelierExit, ChandeKrollStop, AtrTrailingStop, HiLoActivator, VoltyStop, YoyoExit, DonchianStop, PercentageTrailingStop, StepTrailingStop, RenkoTrailingStop, TypicalPrice, MedianPrice, WeightedClose, LinearRegression, LinRegSlope, AcceleratorOscillator, BalanceOfPower, ChoppinessIndex, TrueRange, ChaikinVolatility, YangZhangVolatility, RogersSatchellVolatility, GarmanKlassVolatility, ParkinsonVolatility, LinRegAngle, BollingerBandwidth, PercentB, NATR, HistoricalVolatility, AroonOscillator, WaveTrend, RWI, Vortex, MassIndex, StochRSI, UltimateOscillator, PPO, Coppock, VWMA, MaEnvelope, AccelerationBands, StarcBands, AtrBands, HurstChannel, LinRegChannel, StandardErrorBands, DoubleBollinger, TtmSqueeze, FractalChaosBands, VwapStdDevBands, ClassicPivots, FibonacciPivots, Camarilla, WoodiePivots, DemarkPivots, WilliamsFractals, ZigZag, TDSetup, TDSequential, TDDeMarker, TDREI, TDPressure, TDCombo, TDCountdown, TDLines, TDRangeProjection, TDDifferential, TDOpen, TDRiskLevel, InverseFisherTransform, DecyclerOscillator, RoofingFilter, EmpiricalModeDecomposition, HilbertDominantCycle, AdaptiveCycle, SineWave, MAMA, FAMA, Ichimoku, HeikinAshi, ValueArea, InitialBalance, OpeningRange, Doji, Hammer, InvertedHammer, HangingMan, ShootingStar, Engulfing, Harami, MorningEveningStar, ThreeSoldiersOrCrows, PiercingDarkCloud, Marubozu, Tweezer, SpinningTop, ThreeInside, ThreeOutside, SharpeRatio, SortinoRatio, CalmarRatio, OmegaRatio, MaxDrawdown, AverageDrawdown, DrawdownDuration, PainIndex, ValueAtRisk, ConditionalValueAtRisk, ProfitFactor, GainLossRatio, RecoveryFactor, KellyCriterion, TreynorRatio, InformationRatio, Alpha } = nativeBinding
module.exports.version = version
module.exports.SMA = SMA
@@ -332,6 +332,34 @@ module.exports.StdDev = StdDev
module.exports.UlcerIndex = UlcerIndex
module.exports.VerticalHorizontalFilter = VerticalHorizontalFilter
module.exports.ZScore = ZScore
+module.exports.McGinleyDynamic = McGinleyDynamic
+module.exports.FRAMA = FRAMA
+module.exports.SuperSmoother = SuperSmoother
+module.exports.FisherTransform = FisherTransform
+module.exports.Decycler = Decycler
+module.exports.CenterOfGravity = CenterOfGravity
+module.exports.CyberneticCycle = CyberneticCycle
+module.exports.InstantaneousTrendline = InstantaneousTrendline
+module.exports.EhlersStochastic = EhlersStochastic
+module.exports.RVIVolatility = RVIVolatility
+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.PairwiseBeta = PairwiseBeta
+module.exports.SpearmanCorrelation = SpearmanCorrelation
+module.exports.PairSpreadZScore = PairSpreadZScore
+module.exports.LeadLagCrossCorrelation = LeadLagCrossCorrelation
+module.exports.Cointegration = Cointegration
+module.exports.RelativeStrengthAB = RelativeStrengthAB
module.exports.MACD = MACD
module.exports.BollingerBands = BollingerBands
module.exports.ATR = ATR
@@ -349,27 +377,25 @@ module.exports.VWAP = VWAP
module.exports.RollingVWAP = RollingVWAP
module.exports.AwesomeOscillator = AwesomeOscillator
module.exports.Aroon = Aroon
-module.exports.KAMA = KAMA
-module.exports.RVI = RVI
-module.exports.PGO = PGO
-module.exports.KST = KST
-module.exports.SMI = SMI
-module.exports.LaguerreRSI = LaguerreRSI
-module.exports.ConnorsRSI = ConnorsRSI
module.exports.Inertia = Inertia
-module.exports.ALMA = ALMA
-module.exports.McGinleyDynamic = McGinleyDynamic
-module.exports.FRAMA = FRAMA
-module.exports.VIDYA = VIDYA
-module.exports.JMA = JMA
-module.exports.Alligator = Alligator
-module.exports.EVWMA = EVWMA
-module.exports.APO = APO
+module.exports.ConnorsRSI = ConnorsRSI
+module.exports.LaguerreRSI = LaguerreRSI
+module.exports.SMI = SMI
+module.exports.KST = KST
+module.exports.PGO = PGO
+module.exports.RVI = RVI
module.exports.AwesomeOscillatorHistogram = AwesomeOscillatorHistogram
-module.exports.CFO = CFO
-module.exports.ZeroLagMACD = ZeroLagMACD
-module.exports.ElderImpulse = ElderImpulse
module.exports.STC = STC
+module.exports.ElderImpulse = ElderImpulse
+module.exports.ZeroLagMACD = ZeroLagMACD
+module.exports.CFO = CFO
+module.exports.APO = APO
+module.exports.KAMA = KAMA
+module.exports.EVWMA = EVWMA
+module.exports.Alligator = Alligator
+module.exports.JMA = JMA
+module.exports.VIDYA = VIDYA
+module.exports.ALMA = ALMA
module.exports.T3 = T3
module.exports.TSI = TSI
module.exports.PMO = PMO
@@ -379,17 +405,17 @@ module.exports.VolumePriceTrend = VolumePriceTrend
module.exports.ChaikinMoneyFlow = ChaikinMoneyFlow
module.exports.ChaikinOscillator = ChaikinOscillator
module.exports.ForceIndex = ForceIndex
-module.exports.EaseOfMovement = EaseOfMovement
-module.exports.KVO = KVO
-module.exports.VolumeOscillator = VolumeOscillator
module.exports.NVI = NVI
module.exports.PVI = PVI
+module.exports.VolumeOscillator = VolumeOscillator
+module.exports.KVO = KVO
module.exports.WilliamsAD = WilliamsAD
module.exports.AnchoredVWAP = AnchoredVWAP
module.exports.DemandIndex = DemandIndex
module.exports.TSV = TSV
module.exports.VZO = VZO
module.exports.MarketFacilitationIndex = MarketFacilitationIndex
+module.exports.EaseOfMovement = EaseOfMovement
module.exports.SuperTrend = SuperTrend
module.exports.ChandelierExit = ChandelierExit
module.exports.ChandeKrollStop = ChandeKrollStop
@@ -411,26 +437,25 @@ module.exports.BalanceOfPower = BalanceOfPower
module.exports.ChoppinessIndex = ChoppinessIndex
module.exports.TrueRange = TrueRange
module.exports.ChaikinVolatility = ChaikinVolatility
+module.exports.YangZhangVolatility = YangZhangVolatility
+module.exports.RogersSatchellVolatility = RogersSatchellVolatility
+module.exports.GarmanKlassVolatility = GarmanKlassVolatility
+module.exports.ParkinsonVolatility = ParkinsonVolatility
module.exports.LinRegAngle = LinRegAngle
module.exports.BollingerBandwidth = BollingerBandwidth
module.exports.PercentB = PercentB
module.exports.NATR = NATR
module.exports.HistoricalVolatility = HistoricalVolatility
module.exports.AroonOscillator = AroonOscillator
-module.exports.Vortex = Vortex
-module.exports.RWI = RWI
module.exports.WaveTrend = WaveTrend
+module.exports.RWI = RWI
+module.exports.Vortex = Vortex
module.exports.MassIndex = MassIndex
module.exports.StochRSI = StochRSI
module.exports.UltimateOscillator = UltimateOscillator
module.exports.PPO = PPO
module.exports.Coppock = Coppock
module.exports.VWMA = VWMA
-module.exports.RVIVolatility = RVIVolatility
-module.exports.ParkinsonVolatility = ParkinsonVolatility
-module.exports.GarmanKlassVolatility = GarmanKlassVolatility
-module.exports.RogersSatchellVolatility = RogersSatchellVolatility
-module.exports.YangZhangVolatility = YangZhangVolatility
module.exports.MaEnvelope = MaEnvelope
module.exports.AccelerationBands = AccelerationBands
module.exports.StarcBands = StarcBands
@@ -461,16 +486,9 @@ module.exports.TDRangeProjection = TDRangeProjection
module.exports.TDDifferential = TDDifferential
module.exports.TDOpen = TDOpen
module.exports.TDRiskLevel = TDRiskLevel
-module.exports.SuperSmoother = SuperSmoother
-module.exports.FisherTransform = FisherTransform
module.exports.InverseFisherTransform = InverseFisherTransform
-module.exports.Decycler = Decycler
module.exports.DecyclerOscillator = DecyclerOscillator
module.exports.RoofingFilter = RoofingFilter
-module.exports.CenterOfGravity = CenterOfGravity
-module.exports.CyberneticCycle = CyberneticCycle
-module.exports.InstantaneousTrendline = InstantaneousTrendline
-module.exports.EhlersStochastic = EhlersStochastic
module.exports.EmpiricalModeDecomposition = EmpiricalModeDecomposition
module.exports.HilbertDominantCycle = HilbertDominantCycle
module.exports.AdaptiveCycle = AdaptiveCycle
@@ -479,19 +497,6 @@ 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
module.exports.ValueArea = ValueArea
module.exports.InitialBalance = InitialBalance
module.exports.OpeningRange = OpeningRange
@@ -510,7 +515,6 @@ module.exports.Tweezer = Tweezer
module.exports.SpinningTop = SpinningTop
module.exports.ThreeInside = ThreeInside
module.exports.ThreeOutside = ThreeOutside
-// Family 15: Risk / Performance metrics
module.exports.SharpeRatio = SharpeRatio
module.exports.SortinoRatio = SortinoRatio
module.exports.CalmarRatio = CalmarRatio
diff --git a/bindings/node/src/lib.rs b/bindings/node/src/lib.rs
index c9af2092..d39d6e1f 100644
--- a/bindings/node/src/lib.rs
+++ b/bindings/node/src/lib.rs
@@ -306,12 +306,271 @@ node_pair_indicator!(
wc::PearsonCorrelation
);
node_pair_indicator!(BetaNode, "Beta", wc::Beta);
+node_pair_indicator!(PairwiseBetaNode, "PairwiseBeta", wc::PairwiseBeta);
node_pair_indicator!(
SpearmanCorrelationNode,
"SpearmanCorrelation",
wc::SpearmanCorrelation
);
+// ============================== PairSpreadZScore ==============================
+
+/// Pair spread z-score: two ctor params (`betaPeriod`, `zPeriod`), one `(a, b)`
+/// price pair per update, a single z-score out.
+#[napi(js_name = "PairSpreadZScore")]
+pub struct PairSpreadZScoreNode {
+ inner: wc::PairSpreadZScore,
+}
+
+#[napi]
+impl PairSpreadZScoreNode {
+ #[napi(constructor)]
+ pub fn new(beta_period: u32, z_period: u32) -> napi::Result {
+ Ok(Self {
+ inner: wc::PairSpreadZScore::new(beta_period as usize, z_period as usize)
+ .map_err(map_err)?,
+ })
+ }
+ #[napi]
+ pub fn update(&mut self, a: f64, b: f64) -> Option {
+ self.inner.update((a, b))
+ }
+ /// Batch over two equally-sized arrays of prices. Returns a length-`n`
+ /// array with `NaN` for warmup positions.
+ #[napi]
+ pub fn batch(&mut self, a: Vec, b: Vec) -> napi::Result> {
+ if a.len() != b.len() {
+ return Err(NapiError::new(
+ Status::InvalidArg,
+ "a and b must be equal length".to_string(),
+ ));
+ }
+ 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)
+ }
+ #[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
+ }
+}
+
+// ============================== LeadLagCrossCorrelation ==============================
+
+/// Lead/lag result: the offset that maximises correlation, and that correlation.
+#[napi(object)]
+pub struct LeadLagValue {
+ /// Offset that maximises `|corr(a, b shifted)|`. Positive ⇒ `a` leads `b`.
+ pub lag: i32,
+ /// Signed correlation at that lag, in `[-1, 1]`.
+ pub correlation: f64,
+}
+
+#[napi(js_name = "LeadLagCrossCorrelation")]
+pub struct LeadLagCrossCorrelationNode {
+ inner: wc::LeadLagCrossCorrelation,
+}
+
+#[napi]
+impl LeadLagCrossCorrelationNode {
+ #[napi(constructor)]
+ pub fn new(window: u32, max_lag: u32) -> napi::Result {
+ Ok(Self {
+ inner: wc::LeadLagCrossCorrelation::new(window as usize, max_lag as usize)
+ .map_err(map_err)?,
+ })
+ }
+ #[napi]
+ pub fn update(&mut self, a: f64, b: f64) -> Option {
+ self.inner.update((a, b)).map(|o| LeadLagValue {
+ lag: o.lag as i32,
+ correlation: o.correlation,
+ })
+ }
+ /// Batch over two equally-sized arrays. Returns a flat array of length
+ /// `2 * n`, interleaved per row as `[lag0, corr0, lag1, corr1, ...]`. Read
+ /// column `j` of row `i` as `result[i * 2 + j]`. Warmup rows are `NaN`.
+ #[napi]
+ pub fn batch(&mut self, a: Vec, b: Vec) -> napi::Result> {
+ if a.len() != b.len() {
+ return Err(NapiError::new(
+ Status::InvalidArg,
+ "a and b must be equal length".to_string(),
+ ));
+ }
+ let mut out = vec![f64::NAN; a.len() * 2];
+ for i in 0..a.len() {
+ if let Some(o) = self.inner.update((a[i], b[i])) {
+ out[i * 2] = o.lag as f64;
+ out[i * 2 + 1] = o.correlation;
+ }
+ }
+ 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
+ }
+}
+
+// ============================== Cointegration ==============================
+
+/// Cointegration result: hedge ratio, current spread, and the ADF statistic.
+#[napi(object)]
+pub struct CointegrationValue {
+ /// Engle–Granger hedge ratio (OLS slope of `a` on `b`).
+ pub hedge_ratio: f64,
+ /// Current spread (regression residual) `a - (alpha + beta*b)`.
+ pub spread: f64,
+ /// Augmented Dickey–Fuller statistic on the spread; more negative ⇒ more
+ /// strongly mean-reverting.
+ pub adf_stat: f64,
+}
+
+#[napi(js_name = "Cointegration")]
+pub struct CointegrationNode {
+ inner: wc::Cointegration,
+}
+
+#[napi]
+impl CointegrationNode {
+ #[napi(constructor)]
+ pub fn new(period: u32, adf_lags: u32) -> napi::Result {
+ Ok(Self {
+ inner: wc::Cointegration::new(period as usize, adf_lags as usize).map_err(map_err)?,
+ })
+ }
+ #[napi]
+ pub fn update(&mut self, a: f64, b: f64) -> Option {
+ self.inner.update((a, b)).map(|o| CointegrationValue {
+ hedge_ratio: o.hedge_ratio,
+ spread: o.spread,
+ adf_stat: o.adf_stat,
+ })
+ }
+ /// Batch over two equally-sized arrays. Returns a flat array of length
+ /// `3 * n`, interleaved per row as `[hedgeRatio0, spread0, adfStat0, ...]`.
+ /// Read column `j` of row `i` as `result[i * 3 + j]`. Warmup rows are `NaN`.
+ #[napi]
+ pub fn batch(&mut self, a: Vec, b: Vec) -> napi::Result> {
+ if a.len() != b.len() {
+ return Err(NapiError::new(
+ Status::InvalidArg,
+ "a and b must be equal length".to_string(),
+ ));
+ }
+ let mut out = vec![f64::NAN; a.len() * 3];
+ for i in 0..a.len() {
+ if let Some(o) = self.inner.update((a[i], b[i])) {
+ out[i * 3] = o.hedge_ratio;
+ out[i * 3 + 1] = o.spread;
+ out[i * 3 + 2] = o.adf_stat;
+ }
+ }
+ 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
+ }
+}
+
+// ============================== RelativeStrengthAB ==============================
+
+/// Relative-strength triple: the a/b ratio, its moving average, and its RSI.
+#[napi(object)]
+pub struct RelativeStrengthValue {
+ /// Raw ratio `a / b`.
+ pub ratio: f64,
+ /// Moving average of the ratio.
+ pub ratio_ma: f64,
+ /// RSI of the ratio.
+ pub ratio_rsi: f64,
+}
+
+#[napi(js_name = "RelativeStrengthAB")]
+pub struct RelativeStrengthAbNode {
+ inner: wc::RelativeStrengthAB,
+}
+
+#[napi]
+impl RelativeStrengthAbNode {
+ #[napi(constructor)]
+ pub fn new(ma_period: u32, rsi_period: u32) -> napi::Result {
+ Ok(Self {
+ inner: wc::RelativeStrengthAB::new(ma_period as usize, rsi_period as usize)
+ .map_err(map_err)?,
+ })
+ }
+ #[napi]
+ pub fn update(&mut self, a: f64, b: f64) -> Option {
+ self.inner.update((a, b)).map(|o| RelativeStrengthValue {
+ ratio: o.ratio,
+ ratio_ma: o.ratio_ma,
+ ratio_rsi: o.ratio_rsi,
+ })
+ }
+ /// Batch over two equally-sized arrays. Returns a flat array of length
+ /// `3 * n`, interleaved per row as `[ratio0, ratioMa0, ratioRsi0, ...]`.
+ /// Read column `j` of row `i` as `result[i * 3 + j]`. Warmup rows are `NaN`.
+ #[napi]
+ pub fn batch(&mut self, a: Vec, b: Vec) -> napi::Result> {
+ if a.len() != b.len() {
+ return Err(NapiError::new(
+ Status::InvalidArg,
+ "a and b must be equal length".to_string(),
+ ));
+ }
+ let mut out = vec![f64::NAN; a.len() * 3];
+ for i in 0..a.len() {
+ if let Some(o) = self.inner.update((a[i], b[i])) {
+ out[i * 3] = o.ratio;
+ out[i * 3 + 1] = o.ratio_ma;
+ out[i * 3 + 2] = o.ratio_rsi;
+ }
+ }
+ 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
+ }
+}
+
// ============================== MACD ==============================
/// MACD triple: macd line, signal line, histogram.
diff --git a/bindings/python/python/wickra/__init__.py b/bindings/python/python/wickra/__init__.py
index 62554188..15f88bdb 100644
--- a/bindings/python/python/wickra/__init__.py
+++ b/bindings/python/python/wickra/__init__.py
@@ -161,6 +161,11 @@ from ._wickra import (
HurstExponent,
PearsonCorrelation,
Beta,
+ PairwiseBeta,
+ PairSpreadZScore,
+ LeadLagCrossCorrelation,
+ Cointegration,
+ RelativeStrengthAB,
SpearmanCorrelation,
# Ehlers / Cycle
SuperSmoother,
@@ -393,6 +398,11 @@ __all__ = [
"HurstExponent",
"PearsonCorrelation",
"Beta",
+ "PairwiseBeta",
+ "PairSpreadZScore",
+ "LeadLagCrossCorrelation",
+ "Cointegration",
+ "RelativeStrengthAB",
"SpearmanCorrelation",
# Ehlers / Cycle
"SuperSmoother",
diff --git a/bindings/python/src/lib.rs b/bindings/python/src/lib.rs
index 85aecbed..ed5296b2 100644
--- a/bindings/python/src/lib.rs
+++ b/bindings/python/src/lib.rs
@@ -10751,6 +10751,383 @@ impl PyBeta {
}
}
+// ============================== PairwiseBeta ==============================
+
+#[pyclass(name = "PairwiseBeta", module = "wickra._wickra", skip_from_py_object)]
+#[derive(Clone)]
+struct PyPairwiseBeta {
+ inner: wc::PairwiseBeta,
+}
+
+#[pymethods]
+impl PyPairwiseBeta {
+ #[new]
+ #[pyo3(signature = (period=20))]
+ fn new(period: usize) -> PyResult {
+ Ok(Self {
+ inner: wc::PairwiseBeta::new(period).map_err(map_err)?,
+ })
+ }
+ fn update(&mut self, a: f64, b: f64) -> Option {
+ self.inner.update((a, b))
+ }
+ /// Batch over two equally-sized numpy arrays of prices: `a` and `b`.
+ fn batch<'py>(
+ &mut self,
+ py: Python<'py>,
+ a: PyReadonlyArray1<'py, f64>,
+ b: PyReadonlyArray1<'py, f64>,
+ ) -> PyResult>> {
+ let xs = a
+ .as_slice()
+ .map_err(|_| PyValueError::new_err(NON_CONTIGUOUS))?;
+ let ys = b
+ .as_slice()
+ .map_err(|_| PyValueError::new_err(NON_CONTIGUOUS))?;
+ if xs.len() != ys.len() {
+ return Err(PyValueError::new_err("a and b 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!("PairwiseBeta(period={})", self.inner.period())
+ }
+}
+
+// ============================== PairSpreadZScore ==============================
+
+#[pyclass(
+ name = "PairSpreadZScore",
+ module = "wickra._wickra",
+ skip_from_py_object
+)]
+#[derive(Clone)]
+struct PyPairSpreadZScore {
+ inner: wc::PairSpreadZScore,
+}
+
+#[pymethods]
+impl PyPairSpreadZScore {
+ #[new]
+ #[pyo3(signature = (beta_period=20, z_period=20))]
+ fn new(beta_period: usize, z_period: usize) -> PyResult {
+ Ok(Self {
+ inner: wc::PairSpreadZScore::new(beta_period, z_period).map_err(map_err)?,
+ })
+ }
+ fn update(&mut self, a: f64, b: f64) -> Option {
+ self.inner.update((a, b))
+ }
+ /// Batch over two equally-sized numpy arrays of prices: `a` and `b`.
+ fn batch<'py>(
+ &mut self,
+ py: Python<'py>,
+ a: PyReadonlyArray1<'py, f64>,
+ b: PyReadonlyArray1<'py, f64>,
+ ) -> PyResult>> {
+ let xs = a
+ .as_slice()
+ .map_err(|_| PyValueError::new_err(NON_CONTIGUOUS))?;
+ let ys = b
+ .as_slice()
+ .map_err(|_| PyValueError::new_err(NON_CONTIGUOUS))?;
+ if xs.len() != ys.len() {
+ return Err(PyValueError::new_err("a and b 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 beta_period(&self) -> usize {
+ self.inner.beta_period()
+ }
+ #[getter]
+ fn z_period(&self) -> usize {
+ self.inner.z_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!(
+ "PairSpreadZScore(beta_period={}, z_period={})",
+ self.inner.beta_period(),
+ self.inner.z_period()
+ )
+ }
+}
+
+// ============================== LeadLagCrossCorrelation ==============================
+
+#[pyclass(
+ name = "LeadLagCrossCorrelation",
+ module = "wickra._wickra",
+ skip_from_py_object
+)]
+#[derive(Clone)]
+struct PyLeadLagCrossCorrelation {
+ inner: wc::LeadLagCrossCorrelation,
+}
+
+#[pymethods]
+impl PyLeadLagCrossCorrelation {
+ #[new]
+ #[pyo3(signature = (window=20, max_lag=10))]
+ fn new(window: usize, max_lag: usize) -> PyResult {
+ Ok(Self {
+ inner: wc::LeadLagCrossCorrelation::new(window, max_lag).map_err(map_err)?,
+ })
+ }
+ /// Returns `(lag, correlation)` or `None` during warmup. A positive lag
+ /// means `a` leads `b`.
+ fn update(&mut self, a: f64, b: f64) -> Option<(i64, f64)> {
+ self.inner.update((a, b)).map(|o| (o.lag, o.correlation))
+ }
+ /// Batch over two equally-sized numpy arrays. Returns a 2D array of shape
+ /// `(n, 2)` with columns `[lag, correlation]`. Warmup rows are NaN.
+ fn batch<'py>(
+ &mut self,
+ py: Python<'py>,
+ a: PyReadonlyArray1<'py, f64>,
+ b: PyReadonlyArray1<'py, f64>,
+ ) -> PyResult>> {
+ let xs = a
+ .as_slice()
+ .map_err(|_| PyValueError::new_err(NON_CONTIGUOUS))?;
+ let ys = b
+ .as_slice()
+ .map_err(|_| PyValueError::new_err(NON_CONTIGUOUS))?;
+ if xs.len() != ys.len() {
+ return Err(PyValueError::new_err("a and b must be equal length"));
+ }
+ let n = xs.len();
+ let mut out = vec![f64::NAN; n * 2];
+ for i in 0..n {
+ if let Some(o) = self.inner.update((xs[i], ys[i])) {
+ out[i * 2] = o.lag as f64;
+ out[i * 2 + 1] = o.correlation;
+ }
+ }
+ Ok(numpy::ndarray::Array2::from_shape_vec((n, 2), out)
+ .expect("shape consistent")
+ .into_pyarray(py))
+ }
+ #[getter]
+ fn window(&self) -> usize {
+ self.inner.window()
+ }
+ #[getter]
+ fn max_lag(&self) -> usize {
+ self.inner.max_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!(
+ "LeadLagCrossCorrelation(window={}, max_lag={})",
+ self.inner.window(),
+ self.inner.max_lag()
+ )
+ }
+}
+
+// ============================== Cointegration ==============================
+
+#[pyclass(name = "Cointegration", module = "wickra._wickra", skip_from_py_object)]
+#[derive(Clone)]
+struct PyCointegration {
+ inner: wc::Cointegration,
+}
+
+#[pymethods]
+impl PyCointegration {
+ #[new]
+ #[pyo3(signature = (period=30, adf_lags=1))]
+ fn new(period: usize, adf_lags: usize) -> PyResult {
+ Ok(Self {
+ inner: wc::Cointegration::new(period, adf_lags).map_err(map_err)?,
+ })
+ }
+ /// Returns `(hedge_ratio, spread, adf_stat)` or `None` during warmup.
+ fn update(&mut self, a: f64, b: f64) -> Option<(f64, f64, f64)> {
+ self.inner
+ .update((a, b))
+ .map(|o| (o.hedge_ratio, o.spread, o.adf_stat))
+ }
+ /// Batch over two equally-sized numpy arrays. Returns a 2D array of shape
+ /// `(n, 3)` with columns `[hedge_ratio, spread, adf_stat]`. Warmup rows are
+ /// NaN.
+ fn batch<'py>(
+ &mut self,
+ py: Python<'py>,
+ a: PyReadonlyArray1<'py, f64>,
+ b: PyReadonlyArray1<'py, f64>,
+ ) -> PyResult>> {
+ let xs = a
+ .as_slice()
+ .map_err(|_| PyValueError::new_err(NON_CONTIGUOUS))?;
+ let ys = b
+ .as_slice()
+ .map_err(|_| PyValueError::new_err(NON_CONTIGUOUS))?;
+ if xs.len() != ys.len() {
+ return Err(PyValueError::new_err("a and b must be equal length"));
+ }
+ let n = xs.len();
+ let mut out = vec![f64::NAN; n * 3];
+ for i in 0..n {
+ if let Some(o) = self.inner.update((xs[i], ys[i])) {
+ out[i * 3] = o.hedge_ratio;
+ out[i * 3 + 1] = o.spread;
+ out[i * 3 + 2] = o.adf_stat;
+ }
+ }
+ Ok(numpy::ndarray::Array2::from_shape_vec((n, 3), out)
+ .expect("shape consistent")
+ .into_pyarray(py))
+ }
+ #[getter]
+ fn period(&self) -> usize {
+ self.inner.period()
+ }
+ #[getter]
+ fn adf_lags(&self) -> usize {
+ self.inner.adf_lags()
+ }
+ 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!(
+ "Cointegration(period={}, adf_lags={})",
+ self.inner.period(),
+ self.inner.adf_lags()
+ )
+ }
+}
+
+// ============================== RelativeStrengthAB ==============================
+
+#[pyclass(
+ name = "RelativeStrengthAB",
+ module = "wickra._wickra",
+ skip_from_py_object
+)]
+#[derive(Clone)]
+struct PyRelativeStrengthAB {
+ inner: wc::RelativeStrengthAB,
+}
+
+#[pymethods]
+impl PyRelativeStrengthAB {
+ #[new]
+ #[pyo3(signature = (ma_period=20, rsi_period=14))]
+ fn new(ma_period: usize, rsi_period: usize) -> PyResult {
+ Ok(Self {
+ inner: wc::RelativeStrengthAB::new(ma_period, rsi_period).map_err(map_err)?,
+ })
+ }
+ /// Returns `(ratio, ratio_ma, ratio_rsi)` or `None` during warmup.
+ fn update(&mut self, a: f64, b: f64) -> Option<(f64, f64, f64)> {
+ self.inner
+ .update((a, b))
+ .map(|o| (o.ratio, o.ratio_ma, o.ratio_rsi))
+ }
+ /// Batch over two equally-sized numpy arrays. Returns a 2D array of shape
+ /// `(n, 3)` with columns `[ratio, ratio_ma, ratio_rsi]`. Warmup rows are
+ /// NaN.
+ fn batch<'py>(
+ &mut self,
+ py: Python<'py>,
+ a: PyReadonlyArray1<'py, f64>,
+ b: PyReadonlyArray1<'py, f64>,
+ ) -> PyResult>> {
+ let xs = a
+ .as_slice()
+ .map_err(|_| PyValueError::new_err(NON_CONTIGUOUS))?;
+ let ys = b
+ .as_slice()
+ .map_err(|_| PyValueError::new_err(NON_CONTIGUOUS))?;
+ if xs.len() != ys.len() {
+ return Err(PyValueError::new_err("a and b must be equal length"));
+ }
+ let n = xs.len();
+ let mut out = vec![f64::NAN; n * 3];
+ for i in 0..n {
+ if let Some(o) = self.inner.update((xs[i], ys[i])) {
+ out[i * 3] = o.ratio;
+ out[i * 3 + 1] = o.ratio_ma;
+ out[i * 3 + 2] = o.ratio_rsi;
+ }
+ }
+ Ok(numpy::ndarray::Array2::from_shape_vec((n, 3), out)
+ .expect("shape consistent")
+ .into_pyarray(py))
+ }
+ #[getter]
+ fn ma_period(&self) -> usize {
+ self.inner.ma_period()
+ }
+ #[getter]
+ fn rsi_period(&self) -> usize {
+ self.inner.rsi_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!(
+ "RelativeStrengthAB(ma_period={}, rsi_period={})",
+ self.inner.ma_period(),
+ self.inner.rsi_period()
+ )
+ }
+}
+
// ============================== SpearmanCorrelation ==============================
#[pyclass(
@@ -12236,6 +12613,11 @@ fn _wickra(_py: Python<'_>, m: &Bound<'_, PyModule>) -> PyResult<()> {
m.add_class::()?;
m.add_class::()?;
m.add_class::()?;
+ m.add_class::()?;
+ m.add_class::()?;
+ m.add_class::()?;
+ m.add_class::()?;
+ m.add_class::()?;
m.add_class::()?;
m.add_class::()?;
m.add_class::()?;
diff --git a/bindings/python/tests/test_input_validation.py b/bindings/python/tests/test_input_validation.py
index 9732436e..e1298fbd 100644
--- a/bindings/python/tests/test_input_validation.py
+++ b/bindings/python/tests/test_input_validation.py
@@ -35,6 +35,72 @@ def test_unequal_length_candle_batch_raises(ohlc_series):
ta.Aroon(14).batch(high, short)
+def test_pairwise_beta_rejects_bad_period():
+ with pytest.raises(ValueError):
+ ta.PairwiseBeta(0)
+ with pytest.raises(ValueError):
+ ta.PairwiseBeta(1)
+
+
+def test_unequal_length_pair_batch_raises(sine_prices):
+ a = np.ascontiguousarray((sine_prices + 100.0).astype(np.float64))
+ b = a[:-1]
+ with pytest.raises(ValueError):
+ ta.PairwiseBeta(20).batch(a, b)
+ with pytest.raises(ValueError):
+ ta.PairSpreadZScore(20, 20).batch(a, b)
+
+
+def test_pair_spread_zscore_rejects_bad_periods():
+ with pytest.raises(ValueError):
+ ta.PairSpreadZScore(1, 20)
+ with pytest.raises(ValueError):
+ ta.PairSpreadZScore(20, 1)
+
+
+def test_lead_lag_rejects_bad_params():
+ with pytest.raises(ValueError):
+ ta.LeadLagCrossCorrelation(1, 5)
+ with pytest.raises(ValueError):
+ ta.LeadLagCrossCorrelation(10, 0)
+
+
+def test_lead_lag_unequal_length_batch_raises(sine_prices):
+ a = np.ascontiguousarray((sine_prices + 100.0).astype(np.float64))
+ b = a[:-1]
+ with pytest.raises(ValueError):
+ ta.LeadLagCrossCorrelation(12, 5).batch(a, b)
+
+
+def test_cointegration_rejects_too_small_period():
+ # period must be >= 2*adf_lags + 4.
+ with pytest.raises(ValueError):
+ ta.Cointegration(3, 0)
+ with pytest.raises(ValueError):
+ ta.Cointegration(5, 1)
+
+
+def test_cointegration_unequal_length_batch_raises(sine_prices):
+ a = np.ascontiguousarray((sine_prices + 100.0).astype(np.float64))
+ b = a[:-1]
+ with pytest.raises(ValueError):
+ ta.Cointegration(20, 1).batch(a, b)
+
+
+def test_relative_strength_rejects_zero_periods():
+ with pytest.raises(ValueError):
+ ta.RelativeStrengthAB(0, 14)
+ with pytest.raises(ValueError):
+ ta.RelativeStrengthAB(20, 0)
+
+
+def test_relative_strength_unequal_length_batch_raises(sine_prices):
+ a = np.ascontiguousarray((sine_prices + 100.0).astype(np.float64))
+ b = a[:-1]
+ with pytest.raises(ValueError):
+ ta.RelativeStrengthAB(10, 14).batch(a, b)
+
+
def test_roc_and_trix_have_default_periods():
# ROC/TRIX gained constructor defaults matching the TA-Lib convention.
assert ta.ROC().period == 10
diff --git a/bindings/python/tests/test_known_values.py b/bindings/python/tests/test_known_values.py
index e629b9e4..46e84a78 100644
--- a/bindings/python/tests/test_known_values.py
+++ b/bindings/python/tests/test_known_values.py
@@ -429,6 +429,67 @@ def test_information_ratio_known_window():
assert math.isclose(out[-1], expected, rel_tol=1e-9)
+def test_pairwise_beta_squared_price_is_two():
+ # a = b² ⇒ a's log-returns are exactly 2× b's ⇒ pairwise beta = 2.
+ # b must have *varying* returns (a constant-return path has zero variance
+ # and an undefined slope, which the indicator reports as 0).
+ b = np.array([100.0 + 10.0 * math.sin(i * 0.5) for i in range(20)])
+ a = b**2
+ out = ta.PairwiseBeta(5).batch(a, b)
+ assert math.isclose(out[-1], 2.0, rel_tol=1e-9)
+
+
+def test_pairwise_beta_inverse_price_is_minus_one():
+ # a = 1/b ⇒ a's log-returns are −1× b's ⇒ pairwise beta = −1.
+ b = np.array([100.0 + 10.0 * math.sin(i * 0.5) for i in range(20)])
+ a = 1.0 / b
+ out = ta.PairwiseBeta(5).batch(a, b)
+ assert math.isclose(out[-1], -1.0, rel_tol=1e-9)
+
+
+def test_pair_spread_zscore_flat_benchmark_sign():
+ # Flat b ⇒ hedge ratio 0 ⇒ spread = ln(a). With z_period = 2 the z-score
+ # collapses to the sign of the last move: rising a ⇒ +1, falling a ⇒ −1.
+ a = np.array([100.0, 100.0, 110.0, 105.0, 130.0])
+ b = np.full_like(a, 100.0)
+ out = ta.PairSpreadZScore(2, 2).batch(a, b)
+ assert math.isclose(out[-1], 1.0, abs_tol=1e-9)
+ assert math.isclose(out[-2], -1.0, abs_tol=1e-9)
+
+
+def test_lead_lag_cross_correlation_negative_lead():
+ # a is a delayed copy of b ⇒ b leads a ⇒ lag = −2, correlation ≈ 1.
+ def sig(t):
+ return math.sin(t * 0.4) + 0.4 * math.sin(t * 1.1) + 0.2 * math.cos(t * 0.27)
+
+ n = 60
+ a = np.array([sig(t - 2) for t in range(n)])
+ b = np.array([sig(t) for t in range(n)])
+ out = ta.LeadLagCrossCorrelation(12, 5).batch(a, b)
+ assert int(out[-1, 0]) == -2
+ assert out[-1, 1] > 0.99
+
+
+def test_cointegration_perfect_pair():
+ # a = 2*b + 5 exactly ⇒ hedge ratio 2, zero spread, degenerate ADF ⇒ 0.
+ b = np.array([100.0 + t for t in range(40)])
+ a = 2.0 * b + 5.0
+ out = ta.Cointegration(20, 1).batch(a, b)
+ assert math.isclose(out[-1, 0], 2.0, rel_tol=1e-9)
+ assert math.isclose(out[-1, 1], 0.0, abs_tol=1e-6)
+ assert math.isclose(out[-1, 2], 0.0, abs_tol=1e-12)
+
+
+def test_relative_strength_rising_ratio_is_overbought():
+ # a rises while b is flat ⇒ ratio strictly increases ⇒ RSI saturates at 100.
+ n = 20
+ a = np.array([100.0 + 2.0 * t for t in range(n)])
+ b = np.full(n, 100.0)
+ out = ta.RelativeStrengthAB(5, 5).batch(a, b)
+ assert out[-1, 0] > 1.0
+ assert math.isclose(out[-1, 2], 100.0, abs_tol=1e-9)
+
+
def test_value_at_risk_known_window():
# returns -5..4 *0.01; q=0.05*9=0.45 -> -0.0455; VaR = 0.0455.
returns = np.array([i * 0.01 for i in range(-5, 5)])
diff --git a/bindings/python/tests/test_new_indicators.py b/bindings/python/tests/test_new_indicators.py
index 03e9c4e9..df3bbfc0 100644
--- a/bindings/python/tests/test_new_indicators.py
+++ b/bindings/python/tests/test_new_indicators.py
@@ -159,6 +159,8 @@ PAIR = [
(ta.TreynorRatio, (20, 0.0)),
(ta.InformationRatio, (20,)),
(ta.Alpha, (20, 0.0)),
+ (ta.PairwiseBeta, (20,)),
+ (ta.PairSpreadZScore, (20, 20)),
]
@@ -178,6 +180,95 @@ def test_pair_streaming_matches_batch(cls, args, sine_prices):
assert _eq_nan(batch, np.array(streamed, dtype=np.float64))
+def _ll_signal(t):
+ return math.sin(t * 0.4) + 0.4 * math.sin(t * 1.1) + 0.2 * math.cos(t * 0.27)
+
+
+def test_lead_lag_detects_lead():
+ n = 60
+ a = np.array([_ll_signal(t) for t in range(n)])
+ # b is a delayed by 3 ⇒ a leads b ⇒ lag = +3, correlation ≈ 1.
+ b = np.array([_ll_signal(t - 3) for t in range(n)])
+ out = ta.LeadLagCrossCorrelation(12, 5).batch(a, b)
+ assert out.shape == (n, 2)
+ assert int(out[-1, 0]) == 3
+ assert out[-1, 1] > 0.99
+
+
+def test_lead_lag_streaming_matches_batch():
+ n = 60
+ a = np.array([_ll_signal(t) for t in range(n)])
+ b = np.array([_ll_signal(t - 2) for t in range(n)])
+ ind = ta.LeadLagCrossCorrelation(12, 5)
+ batch = ind.batch(a, b)
+ streamer = ta.LeadLagCrossCorrelation(12, 5)
+ for i in range(n):
+ v = streamer.update(float(a[i]), float(b[i]))
+ if v is None:
+ assert math.isnan(batch[i, 0]) and math.isnan(batch[i, 1])
+ else:
+ lag, corr = v
+ assert int(batch[i, 0]) == lag
+ assert math.isclose(batch[i, 1], corr, rel_tol=1e-12, abs_tol=1e-12)
+
+
+def test_cointegration_detects_mean_reverting_pair():
+ n = 80
+ b = np.array([50.0 + 0.5 * t for t in range(n)])
+ # a tracks 2*b with a small mean-reverting wobble ⇒ cointegrated.
+ a = 2.0 * b + 1.0 + 0.5 * np.sin(np.arange(n) * 0.6)
+ out = ta.Cointegration(40, 1).batch(a, b)
+ assert out.shape == (n, 3)
+ assert abs(out[-1, 0] - 2.0) < 0.1 # hedge ratio
+ assert out[-1, 2] < -2.0 # ADF statistic: strongly mean-reverting
+
+
+def test_cointegration_streaming_matches_batch():
+ n = 70
+ b = np.array([30.0 + 0.7 * t for t in range(n)])
+ a = 1.8 * b + 2.0 + 0.5 * np.sin(np.arange(n) * 0.4)
+ batch = ta.Cointegration(25, 2).batch(a, b)
+ streamer = ta.Cointegration(25, 2)
+ for i in range(n):
+ v = streamer.update(float(a[i]), float(b[i]))
+ if v is None:
+ assert np.all(np.isnan(batch[i]))
+ else:
+ hr, sp, adf = v
+ assert math.isclose(batch[i, 0], hr, rel_tol=1e-12, abs_tol=1e-12)
+ assert math.isclose(batch[i, 1], sp, rel_tol=1e-12, abs_tol=1e-12)
+ assert math.isclose(batch[i, 2], adf, rel_tol=1e-12, abs_tol=1e-12)
+
+
+def test_relative_strength_constant_ratio():
+ n = 30
+ a = np.full(n, 200.0)
+ b = np.full(n, 100.0) # ratio is a constant 2
+ out = ta.RelativeStrengthAB(5, 5).batch(a, b)
+ assert out.shape == (n, 3)
+ assert math.isclose(out[-1, 0], 2.0, abs_tol=1e-12) # ratio
+ assert math.isclose(out[-1, 1], 2.0, abs_tol=1e-12) # ratio MA
+ assert math.isclose(out[-1, 2], 50.0, abs_tol=1e-9) # flat ratio ⇒ RSI 50
+
+
+def test_relative_strength_streaming_matches_batch():
+ n = 60
+ tt = np.arange(n)
+ a = 100.0 + 5.0 * np.sin(tt * 0.3)
+ b = 100.0 + 2.0 * np.cos(tt * 0.2)
+ batch = ta.RelativeStrengthAB(10, 14).batch(a, b)
+ streamer = ta.RelativeStrengthAB(10, 14)
+ for i in range(n):
+ v = streamer.update(float(a[i]), float(b[i]))
+ if v is None:
+ assert np.all(np.isnan(batch[i]))
+ else:
+ ratio, ma, rsi = v
+ assert math.isclose(batch[i, 0], ratio, rel_tol=1e-12, abs_tol=1e-12)
+ assert math.isclose(batch[i, 1], ma, rel_tol=1e-12, abs_tol=1e-12)
+ assert math.isclose(batch[i, 2], rsi, rel_tol=1e-12, abs_tol=1e-12)
+
+
# --- Candle-input, single-output indicators -------------------------------
#
# Each entry is (factory, batch-call). Streaming always feeds the full
diff --git a/bindings/wasm/src/lib.rs b/bindings/wasm/src/lib.rs
index b4a6bacb..b749ba18 100644
--- a/bindings/wasm/src/lib.rs
+++ b/bindings/wasm/src/lib.rs
@@ -525,12 +525,229 @@ wasm_pair_indicator!(
wc::PearsonCorrelation
);
wasm_pair_indicator!(WasmBeta, "Beta", wc::Beta);
+wasm_pair_indicator!(WasmPairwiseBeta, "PairwiseBeta", wc::PairwiseBeta);
wasm_pair_indicator!(
WasmSpearmanCorrelation,
"SpearmanCorrelation",
wc::SpearmanCorrelation
);
+// ---------- PairSpreadZScore (two params) ----------
+
+#[wasm_bindgen(js_name = "PairSpreadZScore")]
+pub struct WasmPairSpreadZScore {
+ inner: wc::PairSpreadZScore,
+}
+
+#[wasm_bindgen(js_class = "PairSpreadZScore")]
+impl WasmPairSpreadZScore {
+ #[wasm_bindgen(constructor)]
+ pub fn new(beta_period: usize, z_period: usize) -> Result {
+ Ok(Self {
+ inner: wc::PairSpreadZScore::new(beta_period, z_period).map_err(map_err)?,
+ })
+ }
+ pub fn update(&mut self, a: f64, b: f64) -> Option {
+ self.inner.update((a, b))
+ }
+ /// Batch over two equally-sized arrays of prices. Returns one `f64` per
+ /// input position (`NaN` during warmup).
+ pub fn batch(&mut self, a: &[f64], b: &[f64]) -> Result {
+ if a.len() != b.len() {
+ return Err(JsError::new("a and b 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(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()
+ }
+}
+
+// ---------- LeadLagCrossCorrelation (two params, object output) ----------
+
+#[wasm_bindgen(js_name = "LeadLagCrossCorrelation")]
+pub struct WasmLeadLagCrossCorrelation {
+ inner: wc::LeadLagCrossCorrelation,
+}
+
+#[wasm_bindgen(js_class = "LeadLagCrossCorrelation")]
+impl WasmLeadLagCrossCorrelation {
+ #[wasm_bindgen(constructor)]
+ pub fn new(window: usize, max_lag: usize) -> Result {
+ Ok(Self {
+ inner: wc::LeadLagCrossCorrelation::new(window, max_lag).map_err(map_err)?,
+ })
+ }
+ /// Returns `{ lag, correlation }`, or `null` during warmup. Positive lag
+ /// means `a` leads `b`.
+ pub fn update(&mut self, a: f64, b: f64) -> JsValue {
+ match self.inner.update((a, b)) {
+ Some(o) => {
+ let obj = Object::new();
+ Reflect::set(&obj, &"lag".into(), &(o.lag as f64).into()).ok();
+ Reflect::set(&obj, &"correlation".into(), &o.correlation.into()).ok();
+ obj.into()
+ }
+ None => JsValue::NULL,
+ }
+ }
+ /// Flat `Float64Array` of length `2 * n`: `[lag0, corr0, lag1, corr1, ...]`.
+ /// Warmup positions are NaN.
+ pub fn batch(&mut self, a: &[f64], b: &[f64]) -> Result {
+ if a.len() != b.len() {
+ return Err(JsError::new("a and b must be equal length"));
+ }
+ let n = a.len();
+ let mut out = vec![f64::NAN; n * 2];
+ for i in 0..n {
+ if let Some(o) = self.inner.update((a[i], b[i])) {
+ out[i * 2] = o.lag as f64;
+ out[i * 2 + 1] = o.correlation;
+ }
+ }
+ 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()
+ }
+}
+
+// ---------- Cointegration (two params, object output) ----------
+
+#[wasm_bindgen(js_name = "Cointegration")]
+pub struct WasmCointegration {
+ inner: wc::Cointegration,
+}
+
+#[wasm_bindgen(js_class = "Cointegration")]
+impl WasmCointegration {
+ #[wasm_bindgen(constructor)]
+ pub fn new(period: usize, adf_lags: usize) -> Result {
+ Ok(Self {
+ inner: wc::Cointegration::new(period, adf_lags).map_err(map_err)?,
+ })
+ }
+ /// Returns `{ hedgeRatio, spread, adfStat }`, or `null` during warmup.
+ pub fn update(&mut self, a: f64, b: f64) -> JsValue {
+ match self.inner.update((a, b)) {
+ Some(o) => {
+ let obj = Object::new();
+ Reflect::set(&obj, &"hedgeRatio".into(), &o.hedge_ratio.into()).ok();
+ Reflect::set(&obj, &"spread".into(), &o.spread.into()).ok();
+ Reflect::set(&obj, &"adfStat".into(), &o.adf_stat.into()).ok();
+ obj.into()
+ }
+ None => JsValue::NULL,
+ }
+ }
+ /// Flat `Float64Array` of length `3 * n`:
+ /// `[hedgeRatio0, spread0, adfStat0, hedgeRatio1, ...]`. Warmup rows are NaN.
+ pub fn batch(&mut self, a: &[f64], b: &[f64]) -> Result {
+ if a.len() != b.len() {
+ return Err(JsError::new("a and b must be equal length"));
+ }
+ let n = a.len();
+ let mut out = vec![f64::NAN; n * 3];
+ for i in 0..n {
+ if let Some(o) = self.inner.update((a[i], b[i])) {
+ out[i * 3] = o.hedge_ratio;
+ out[i * 3 + 1] = o.spread;
+ out[i * 3 + 2] = o.adf_stat;
+ }
+ }
+ 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()
+ }
+}
+
+// ---------- RelativeStrengthAB (two params, object output) ----------
+
+#[wasm_bindgen(js_name = "RelativeStrengthAB")]
+pub struct WasmRelativeStrengthAb {
+ inner: wc::RelativeStrengthAB,
+}
+
+#[wasm_bindgen(js_class = "RelativeStrengthAB")]
+impl WasmRelativeStrengthAb {
+ #[wasm_bindgen(constructor)]
+ pub fn new(ma_period: usize, rsi_period: usize) -> Result {
+ Ok(Self {
+ inner: wc::RelativeStrengthAB::new(ma_period, rsi_period).map_err(map_err)?,
+ })
+ }
+ /// Returns `{ ratio, ratioMa, ratioRsi }`, or `null` during warmup.
+ pub fn update(&mut self, a: f64, b: f64) -> JsValue {
+ match self.inner.update((a, b)) {
+ Some(o) => {
+ let obj = Object::new();
+ Reflect::set(&obj, &"ratio".into(), &o.ratio.into()).ok();
+ Reflect::set(&obj, &"ratioMa".into(), &o.ratio_ma.into()).ok();
+ Reflect::set(&obj, &"ratioRsi".into(), &o.ratio_rsi.into()).ok();
+ obj.into()
+ }
+ None => JsValue::NULL,
+ }
+ }
+ /// Flat `Float64Array` of length `3 * n`:
+ /// `[ratio0, ratioMa0, ratioRsi0, ratio1, ...]`. Warmup rows are NaN.
+ pub fn batch(&mut self, a: &[f64], b: &[f64]) -> Result {
+ if a.len() != b.len() {
+ return Err(JsError::new("a and b must be equal length"));
+ }
+ let n = a.len();
+ let mut out = vec![f64::NAN; n * 3];
+ for i in 0..n {
+ if let Some(o) = self.inner.update((a[i], b[i])) {
+ out[i * 3] = o.ratio;
+ out[i * 3 + 1] = o.ratio_ma;
+ out[i * 3 + 2] = o.ratio_rsi;
+ }
+ }
+ 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()
+ }
+}
+
// ---------- KAMA (three params) ----------
#[wasm_bindgen(js_name = KAMA)]
diff --git a/crates/wickra-core/src/indicators/cointegration.rs b/crates/wickra-core/src/indicators/cointegration.rs
new file mode 100644
index 00000000..f307b0d8
--- /dev/null
+++ b/crates/wickra-core/src/indicators/cointegration.rs
@@ -0,0 +1,446 @@
+//! Cointegration — rolling Engle–Granger hedge ratio plus an ADF stationarity test.
+
+use std::collections::VecDeque;
+
+use crate::error::{Error, Result};
+use crate::traits::Indicator;
+
+/// Output of [`Cointegration`].
+#[derive(Debug, Clone, Copy, PartialEq)]
+pub struct CointegrationOutput {
+ /// Engle–Granger hedge ratio `β`: the rolling OLS slope of `a` on `b`.
+ pub hedge_ratio: f64,
+ /// The current spread (regression residual) `a − (α + β·b)`.
+ pub spread: f64,
+ /// Augmented Dickey–Fuller `t`-statistic on the spread. **More negative**
+ /// means more strongly mean-reverting (cointegrated); compare against the
+ /// usual ADF/MacKinnon critical values (e.g. roughly `−2.9` at 5%). `0`
+ /// when the test is undefined (a degenerate, zero-variance spread).
+ pub adf_stat: f64,
+}
+
+/// Rolling cointegration test for a pair of assets (Engle–Granger two-step).
+///
+/// Each `update` receives one `(a, b)` pair (price levels, or log-levels if you
+/// prefer). Over the trailing window of `period` pairs the indicator:
+///
+/// 1. fits the **hedge ratio** `β` (and intercept `α`) by ordinary least
+/// squares of `a` on `b`, and forms the **spread** `eₜ = aₜ − (α + β·bₜ)`;
+/// 2. runs an **augmented Dickey–Fuller** test (no constant, no trend, with
+/// `adf_lags` lagged differences) on the spread series and reports its
+/// `t`-statistic.
+///
+/// A strongly negative ADF statistic means the spread reverts to its mean — the
+/// pair is cointegrated and the spread is tradeable. A statistic near zero
+/// means the spread wanders like a random walk (no cointegration). This is the
+/// classic pairs-trading screen: `β` tells you the hedge size, the spread is
+/// what you trade, and the ADF statistic tells you whether it is worth trading.
+///
+/// Each `update` is `O(period + adf_lags³)`: the hedge ratio is maintained from
+/// running sums, while the spread series and the small ADF regression are
+/// recomputed over the window — both bounded by the fixed parameters, not the
+/// series length.
+///
+/// # Example
+///
+/// ```
+/// use wickra_core::{Cointegration, Indicator};
+///
+/// let mut c = Cointegration::new(30, 1).unwrap();
+/// let mut last = None;
+/// for t in 0..60 {
+/// let b = 100.0 + f64::from(t);
+/// // `a` tracks 2·b with a small mean-reverting wobble ⇒ cointegrated.
+/// let a = 2.0 * b + 5.0 + 0.5 * (f64::from(t) * 0.7).sin();
+/// last = c.update((a, b));
+/// }
+/// let out = last.unwrap();
+/// assert!((out.hedge_ratio - 2.0).abs() < 0.1);
+/// assert!(out.adf_stat < 0.0); // mean-reverting spread
+/// ```
+#[derive(Debug, Clone)]
+pub struct Cointegration {
+ period: usize,
+ adf_lags: usize,
+ window: VecDeque<(f64, f64)>,
+ sum_a: f64,
+ sum_b: f64,
+ sum_bb: f64,
+ sum_ab: f64,
+}
+
+impl Cointegration {
+ /// Construct a new rolling cointegration test.
+ ///
+ /// `period` is the look-back window; `adf_lags` is the number of lagged
+ /// differences in the augmented Dickey–Fuller regression (`0` is the plain
+ /// Dickey–Fuller test).
+ ///
+ /// # Errors
+ /// Returns [`Error::InvalidPeriod`] if `period < 2·adf_lags + 4`, which is
+ /// the smallest window that leaves the ADF regression at least one degree
+ /// of freedom.
+ pub fn new(period: usize, adf_lags: usize) -> Result {
+ let min_period = 2 * adf_lags + 4;
+ if period < min_period {
+ return Err(Error::InvalidPeriod {
+ message: "cointegration needs period >= 2*adf_lags + 4",
+ });
+ }
+ Ok(Self {
+ period,
+ adf_lags,
+ window: VecDeque::with_capacity(period),
+ sum_a: 0.0,
+ sum_b: 0.0,
+ sum_bb: 0.0,
+ sum_ab: 0.0,
+ })
+ }
+
+ /// Look-back window length.
+ pub const fn period(&self) -> usize {
+ self.period
+ }
+
+ /// Number of lagged differences in the ADF regression.
+ pub const fn adf_lags(&self) -> usize {
+ self.adf_lags
+ }
+}
+
+impl Indicator for Cointegration {
+ /// `(a, b)` price pair.
+ type Input = (f64, f64);
+ type Output = CointegrationOutput;
+
+ fn update(&mut self, input: (f64, f64)) -> Option {
+ let (a, b) = input;
+ if self.window.len() == self.period {
+ let (oa, ob) = self.window.pop_front().expect("non-empty");
+ self.sum_a -= oa;
+ self.sum_b -= ob;
+ self.sum_bb -= ob * ob;
+ self.sum_ab -= oa * ob;
+ }
+ self.window.push_back((a, b));
+ self.sum_a += a;
+ self.sum_b += b;
+ self.sum_bb += b * b;
+ self.sum_ab += a * b;
+ if self.window.len() < self.period {
+ return None;
+ }
+ let n = self.period as f64;
+ let mean_a = self.sum_a / n;
+ let mean_b = self.sum_b / n;
+ let var_b = (self.sum_bb / n - mean_b * mean_b).max(0.0);
+ let (hedge_ratio, intercept) = if var_b == 0.0 {
+ // A flat `b` window has no defined slope; fall back to a level shift.
+ (0.0, mean_a)
+ } else {
+ let cov = self.sum_ab / n - mean_a * mean_b;
+ let beta = cov / var_b;
+ (beta, mean_a - beta * mean_b)
+ };
+ // Build the spread (residual) series over the window, oldest → newest.
+ let spreads: Vec = self
+ .window
+ .iter()
+ .map(|&(ai, bi)| ai - (intercept + hedge_ratio * bi))
+ .collect();
+ let spread = *spreads.last().expect("window is full");
+ let adf_stat = adf_no_constant(&spreads, self.adf_lags);
+ Some(CointegrationOutput {
+ hedge_ratio,
+ spread,
+ adf_stat,
+ })
+ }
+
+ 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 {
+ "Cointegration"
+ }
+}
+
+/// Solve the linear system `mat·x = rhs` for a small square system by Gaussian
+/// elimination, returning `None` if the matrix is (numerically) singular.
+///
+/// `mat` is row-major and consumed; `rhs` is the right-hand side.
+fn solve(mut mat: Vec>, mut rhs: Vec) -> Option> {
+ let dim = rhs.len();
+ for col in 0..dim {
+ let pivot = mat[col][col];
+ if pivot.abs() < 1e-12 {
+ return None;
+ }
+ let pivot_row = mat[col].clone();
+ for row in (col + 1)..dim {
+ let factor = mat[row][col] / pivot;
+ for (cell, &above) in mat[row].iter_mut().zip(&pivot_row).skip(col) {
+ *cell -= factor * above;
+ }
+ rhs[row] -= factor * rhs[col];
+ }
+ }
+ let mut sol = vec![0.0; dim];
+ for row in (0..dim).rev() {
+ let known: f64 = mat[row]
+ .iter()
+ .zip(&sol)
+ .skip(row + 1)
+ .map(|(coeff, value)| coeff * value)
+ .sum();
+ sol[row] = (rhs[row] - known) / mat[row][row];
+ }
+ Some(sol)
+}
+
+/// Augmented Dickey–Fuller `t`-statistic on `series`, with `lags` lagged
+/// differences and **no** constant or trend term (the Engle–Granger residual
+/// form). Returns `0.0` when the regression is degenerate.
+///
+/// The regression is `Δeₜ = ρ·eₜ₋₁ + Σ γᵢ·Δeₜ₋ᵢ + εₜ`; the reported statistic
+/// is `ρ̂ / se(ρ̂)`.
+fn adf_no_constant(series: &[f64], lags: usize) -> f64 {
+ let len = series.len();
+ let num_reg = lags + 1; // regressors: eₜ₋₁ plus `lags` lagged differences
+ let first = lags + 1; // first usable observation index
+ if len <= first {
+ return 0.0;
+ }
+ let num_obs = len - first;
+ if num_obs <= num_reg {
+ return 0.0; // need at least one residual degree of freedom
+ }
+ let regressors = |idx: usize| -> Vec {
+ let mut row = vec![0.0; num_reg];
+ row[0] = series[idx - 1];
+ for lag in 1..=lags {
+ row[lag] = series[idx - lag] - series[idx - lag - 1];
+ }
+ row
+ };
+ let mut xtx = vec![vec![0.0; num_reg]; num_reg];
+ let mut xty = vec![0.0; num_reg];
+ for idx in first..len {
+ let diff = series[idx] - series[idx - 1];
+ let row = regressors(idx);
+ for (ri, &left) in row.iter().enumerate() {
+ xty[ri] += left * diff;
+ for (ci, &right) in row.iter().enumerate() {
+ xtx[ri][ci] += left * right;
+ }
+ }
+ }
+ let Some(theta) = solve(xtx.clone(), xty) else {
+ return 0.0;
+ };
+ let rho = theta[0];
+ let mut rss = 0.0;
+ for idx in first..len {
+ let diff = series[idx] - series[idx - 1];
+ let pred: f64 = regressors(idx)
+ .iter()
+ .zip(&theta)
+ .map(|(coeff, value)| coeff * value)
+ .sum();
+ let resid = diff - pred;
+ rss += resid * resid;
+ }
+ let dof = (num_obs - num_reg) as f64;
+ let sigma2 = rss / dof;
+ // (XᵀX)⁻¹₀₀ from solving XᵀX·x = e₀. `xtx` is the same matrix the first
+ // solve already factored successfully, so this one cannot be singular.
+ let mut unit = vec![0.0; num_reg];
+ unit[0] = 1.0;
+ let inverse = solve(xtx, unit).expect("xtx is non-singular: the coefficient solve succeeded");
+ let var_rho = sigma2 * inverse[0];
+ if var_rho <= 0.0 {
+ return 0.0;
+ }
+ rho / var_rho.sqrt()
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use crate::traits::BatchExt;
+ use approx::assert_relative_eq;
+
+ #[test]
+ fn rejects_too_small_period() {
+ // period must be >= 2*lags + 4.
+ assert!(Cointegration::new(3, 0).is_err()); // needs >= 4
+ assert!(Cointegration::new(4, 0).is_ok());
+ assert!(Cointegration::new(5, 1).is_err()); // needs >= 6
+ assert!(Cointegration::new(6, 1).is_ok());
+ }
+
+ #[test]
+ fn accessors_and_metadata() {
+ let c = Cointegration::new(30, 2).unwrap();
+ assert_eq!(c.period(), 30);
+ assert_eq!(c.adf_lags(), 2);
+ assert_eq!(c.warmup_period(), 30);
+ assert_eq!(c.name(), "Cointegration");
+ }
+
+ #[test]
+ fn adf_guards_and_degenerate_spread() {
+ // Series too short for any observation ⇒ 0.
+ assert_eq!(adf_no_constant(&[1.0], 1), 0.0);
+ // Long enough but too few degrees of freedom ⇒ 0.
+ assert_eq!(adf_no_constant(&[1.0, 2.0, 3.0], 1), 0.0);
+ // A perfect deterministic AR(1) spread (eₜ = 0.5·eₜ₋₁) is fit exactly,
+ // so the residual variance — and hence the t-statistic — is 0.
+ let geom: Vec = (0..8).map(|t| 0.5_f64.powi(t)).collect();
+ assert_eq!(adf_no_constant(&geom, 0), 0.0);
+ }
+
+ #[test]
+ fn recovers_hedge_ratio() {
+ // a = 2·b + 5 + small wobble ⇒ β ≈ 2.
+ let pairs: Vec<(f64, f64)> = (0..60)
+ .map(|t| {
+ let b = 100.0 + f64::from(t);
+ let a = 2.0 * b + 5.0 + 0.4 * (f64::from(t) * 0.9).sin();
+ (a, b)
+ })
+ .collect();
+ let out = Cointegration::new(30, 1)
+ .unwrap()
+ .batch(&pairs)
+ .into_iter()
+ .flatten()
+ .last()
+ .unwrap();
+ assert!(
+ (out.hedge_ratio - 2.0).abs() < 0.1,
+ "beta {}",
+ out.hedge_ratio
+ );
+ }
+
+ #[test]
+ fn stationary_spread_is_strongly_negative() {
+ // A clean mean-reverting (sinusoidal) spread ⇒ very negative ADF.
+ let pairs: Vec<(f64, f64)> = (0..80)
+ .map(|t| {
+ let b = 50.0 + 0.5 * f64::from(t);
+ let a = 2.0 * b + 1.0 + 0.5 * (f64::from(t) * 0.6).sin();
+ (a, b)
+ })
+ .collect();
+ let out = Cointegration::new(40, 1)
+ .unwrap()
+ .batch(&pairs)
+ .into_iter()
+ .flatten()
+ .last()
+ .unwrap();
+ assert!(out.adf_stat < -2.0, "adf {}", out.adf_stat);
+ }
+
+ #[test]
+ fn perfect_cointegration_has_zero_spread_and_defined_ratio() {
+ // a = 2·b + 5 exactly ⇒ residuals all zero ⇒ ADF degenerate ⇒ 0.
+ let pairs: Vec<(f64, f64)> = (0..40)
+ .map(|t| {
+ let b = 100.0 + f64::from(t);
+ (2.0 * b + 5.0, b)
+ })
+ .collect();
+ let out = Cointegration::new(20, 1)
+ .unwrap()
+ .batch(&pairs)
+ .into_iter()
+ .flatten()
+ .last()
+ .unwrap();
+ assert_relative_eq!(out.hedge_ratio, 2.0, epsilon = 1e-9);
+ assert_relative_eq!(out.spread, 0.0, epsilon = 1e-6);
+ assert_relative_eq!(out.adf_stat, 0.0, epsilon = 1e-12);
+ }
+
+ #[test]
+ fn flat_b_falls_back_to_level() {
+ // Constant b ⇒ no slope ⇒ hedge ratio 0, spread = a − mean(a).
+ let pairs: Vec<(f64, f64)> = (0..20)
+ .map(|t| (10.0 + 0.3 * (f64::from(t) * 0.5).sin(), 7.0))
+ .collect();
+ let out = Cointegration::new(10, 0)
+ .unwrap()
+ .batch(&pairs)
+ .into_iter()
+ .flatten()
+ .last()
+ .unwrap();
+ assert_relative_eq!(out.hedge_ratio, 0.0, epsilon = 1e-12);
+ }
+
+ #[test]
+ fn plain_dickey_fuller_lags_zero() {
+ // Exercise the lags = 0 path (1×1 ADF system).
+ let pairs: Vec<(f64, f64)> = (0..40)
+ .map(|t| {
+ let b = 20.0 + 0.4 * f64::from(t);
+ let a = 1.5 * b + 0.6 * (f64::from(t) * 0.7).sin();
+ (a, b)
+ })
+ .collect();
+ let out = Cointegration::new(20, 0)
+ .unwrap()
+ .batch(&pairs)
+ .into_iter()
+ .flatten()
+ .last()
+ .unwrap();
+ assert!((out.hedge_ratio - 1.5).abs() < 0.1);
+ assert!(out.adf_stat < 0.0);
+ }
+
+ #[test]
+ fn reset_clears_state() {
+ let mut c = Cointegration::new(10, 1).unwrap();
+ for t in 0..20 {
+ let b = 100.0 + f64::from(t);
+ c.update((2.0 * b + (f64::from(t) * 0.5).sin(), b));
+ }
+ assert!(c.is_ready());
+ c.reset();
+ assert!(!c.is_ready());
+ assert_eq!(c.update((1.0, 1.0)), None);
+ }
+
+ #[test]
+ fn batch_equals_streaming() {
+ let pairs: Vec<(f64, f64)> = (0..80)
+ .map(|t| {
+ let b = 30.0 + 0.7 * f64::from(t);
+ let a = 1.8 * b + 2.0 + 0.5 * (f64::from(t) * 0.4).sin();
+ (a, b)
+ })
+ .collect();
+ let batch = Cointegration::new(25, 2).unwrap().batch(&pairs);
+ let mut c = Cointegration::new(25, 2).unwrap();
+ let streamed: Vec<_> = pairs.iter().map(|p| c.update(*p)).collect();
+ assert_eq!(batch, streamed);
+ }
+}
diff --git a/crates/wickra-core/src/indicators/lead_lag_cross_correlation.rs b/crates/wickra-core/src/indicators/lead_lag_cross_correlation.rs
new file mode 100644
index 00000000..dad02f84
--- /dev/null
+++ b/crates/wickra-core/src/indicators/lead_lag_cross_correlation.rs
@@ -0,0 +1,324 @@
+//! Lead–Lag Cross-Correlation — which of two assets leads the other, and by how much.
+
+use std::collections::VecDeque;
+
+use crate::error::{Error, Result};
+use crate::traits::Indicator;
+
+/// Output of [`LeadLagCrossCorrelation`]: the lead/lag offset and its correlation.
+#[derive(Debug, Clone, Copy, PartialEq)]
+pub struct LeadLagCrossCorrelationOutput {
+ /// The offset `k ∈ [−max_lag, max_lag]` that maximises `|corr(a[t], b[t+k])|`.
+ ///
+ /// A **positive** lag means `a` leads `b` by `lag` samples (a's pattern
+ /// shows up in `b` that many steps later); a **negative** lag means `b`
+ /// leads `a`; `0` means the two are most correlated contemporaneously.
+ pub lag: i64,
+ /// The (signed) Pearson correlation at that lag, in `[−1, +1]`.
+ pub correlation: f64,
+}
+
+/// Rolling lead–lag cross-correlation between two synchronised series.
+///
+/// Each `update` receives one `(a, b)` pair. The indicator keeps the most
+/// recent `window + 2·max_lag` samples of each series and, once full, reports
+/// the integer offset `k ∈ [−max_lag, +max_lag]` that maximises the absolute
+/// Pearson correlation between `a` and a copy of `b` shifted by `k`:
+///
+/// ```text
+/// lag = argmax_k | corr( a[t], b[t+k] ) |
+/// ```
+///
+/// This answers "does BTC lead ETH on this timescale, and by how many bars?".
+/// A positive lag means `a` leads `b`; a negative lag means `b` leads `a`. The
+/// reported `correlation` is the signed correlation at that lag, so its sign
+/// tells you whether the lead relationship is positive or inverse.
+///
+/// The comparison is fully causal: `a`'s window is held fixed in the centre of
+/// the buffer and `b`'s window slides across it, so every lag — positive and
+/// negative — is evaluated only against data already seen. The candidate lags
+/// are scanned in order of increasing `|k|`, so ties resolve to the smallest
+/// absolute offset (lag `0` wins an exact tie).
+///
+/// Each `update` is `O(window · max_lag)` — proportional to the fixed
+/// parameters, not the series length. A flat window in either channel makes a
+/// correlation undefined; it is reported as `0` rather than `NaN`.
+///
+/// Feed raw prices or returns depending on your convention; lead–lag on
+/// returns is the more common choice for relating two assets.
+///
+/// # Example
+///
+/// ```
+/// use wickra_core::{Indicator, LeadLagCrossCorrelation};
+///
+/// let mut ll = LeadLagCrossCorrelation::new(12, 5).unwrap();
+/// let mut last = None;
+/// for t in 0..60 {
+/// let a = (f64::from(t) * 0.4).sin() + 0.4 * (f64::from(t) * 1.1).sin();
+/// // `b` is `a` delayed by 3 samples, so `a` leads `b` by 3.
+/// let b = (f64::from(t - 3) * 0.4).sin() + 0.4 * (f64::from(t - 3) * 1.1).sin();
+/// last = ll.update((a, b));
+/// }
+/// let out = last.unwrap();
+/// assert_eq!(out.lag, 3);
+/// assert!(out.correlation > 0.99);
+/// ```
+#[derive(Debug, Clone)]
+pub struct LeadLagCrossCorrelation {
+ window: usize,
+ max_lag: usize,
+ len: usize,
+ a_buf: VecDeque,
+ b_buf: VecDeque,
+}
+
+impl LeadLagCrossCorrelation {
+ /// Construct a new lead–lag cross-correlation.
+ ///
+ /// `window` is the number of overlapping points each correlation is
+ /// computed over; `max_lag` is the largest offset (in either direction)
+ /// that is searched.
+ ///
+ /// # Errors
+ /// Returns [`Error::InvalidPeriod`] if `window < 2` or `max_lag == 0`.
+ pub fn new(window: usize, max_lag: usize) -> Result {
+ if window < 2 {
+ return Err(Error::InvalidPeriod {
+ message: "lead-lag cross-correlation needs window >= 2",
+ });
+ }
+ if max_lag == 0 {
+ return Err(Error::InvalidPeriod {
+ message: "lead-lag cross-correlation needs max_lag >= 1",
+ });
+ }
+ let len = window + 2 * max_lag;
+ Ok(Self {
+ window,
+ max_lag,
+ len,
+ a_buf: VecDeque::with_capacity(len),
+ b_buf: VecDeque::with_capacity(len),
+ })
+ }
+
+ /// Number of overlapping points per correlation.
+ pub const fn window(&self) -> usize {
+ self.window
+ }
+
+ /// Largest offset searched in either direction.
+ pub const fn max_lag(&self) -> usize {
+ self.max_lag
+ }
+
+ /// Pearson correlation between `a[a_start .. a_start+window]` and
+ /// `b[b_start .. b_start+window]`, clamped to `[−1, 1]`. Returns `0` when
+ /// either window has zero variance.
+ fn corr_at(&self, a_start: usize, b_start: usize) -> f64 {
+ let n = self.window as f64;
+ let mut sa = 0.0;
+ let mut sb = 0.0;
+ let mut saa = 0.0;
+ let mut sbb = 0.0;
+ let mut sab = 0.0;
+ for j in 0..self.window {
+ let x = self.a_buf[a_start + j];
+ let y = self.b_buf[b_start + j];
+ sa += x;
+ sb += y;
+ saa += x * x;
+ sbb += y * y;
+ sab += x * y;
+ }
+ let mean_a = sa / n;
+ let mean_b = sb / n;
+ let var_a = (saa / n - mean_a * mean_a).max(0.0);
+ let var_b = (sbb / n - mean_b * mean_b).max(0.0);
+ let denom = (var_a * var_b).sqrt();
+ if denom == 0.0 {
+ return 0.0;
+ }
+ let cov = sab / n - mean_a * mean_b;
+ (cov / denom).clamp(-1.0, 1.0)
+ }
+}
+
+impl Indicator for LeadLagCrossCorrelation {
+ /// `(a, b)` pair.
+ type Input = (f64, f64);
+ type Output = LeadLagCrossCorrelationOutput;
+
+ fn update(&mut self, input: (f64, f64)) -> Option {
+ let (a, b) = input;
+ if self.a_buf.len() == self.len {
+ self.a_buf.pop_front();
+ self.b_buf.pop_front();
+ }
+ self.a_buf.push_back(a);
+ self.b_buf.push_back(b);
+ if self.a_buf.len() < self.len {
+ return None;
+ }
+ // `a`'s window sits in the centre; `b`'s window slides ±max_lag.
+ let a_start = self.max_lag;
+ // Start at lag 0, then widen outward so ties prefer the smallest |lag|.
+ // The lag is tracked as a signed counter incremented by ±1, so no
+ // unsigned index is ever cast to a signed type.
+ let mut best_lag: i64 = 0;
+ let mut best_corr = self.corr_at(a_start, a_start);
+ let mut best_abs = best_corr.abs();
+ let mut lag_neg: i64 = 0;
+ let mut lag_pos: i64 = 0;
+ for d in 1..=self.max_lag {
+ lag_neg -= 1;
+ lag_pos += 1;
+ // Negative lag: b shifted earlier (b leads a).
+ let c_neg = self.corr_at(a_start, a_start - d);
+ if c_neg.abs() > best_abs {
+ best_abs = c_neg.abs();
+ best_corr = c_neg;
+ best_lag = lag_neg;
+ }
+ // Positive lag: b shifted later (a leads b).
+ let c_pos = self.corr_at(a_start, a_start + d);
+ if c_pos.abs() > best_abs {
+ best_abs = c_pos.abs();
+ best_corr = c_pos;
+ best_lag = lag_pos;
+ }
+ }
+ Some(LeadLagCrossCorrelationOutput {
+ lag: best_lag,
+ correlation: best_corr,
+ })
+ }
+
+ fn reset(&mut self) {
+ self.a_buf.clear();
+ self.b_buf.clear();
+ }
+
+ fn warmup_period(&self) -> usize {
+ self.len
+ }
+
+ fn is_ready(&self) -> bool {
+ self.a_buf.len() == self.len
+ }
+
+ fn name(&self) -> &'static str {
+ "LeadLagCrossCorrelation"
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use crate::traits::BatchExt;
+ use approx::assert_relative_eq;
+
+ fn signal(t: i64) -> f64 {
+ let t = t as f64;
+ (t * 0.4).sin() + 0.4 * (t * 1.1).sin() + 0.2 * (t * 0.27).cos()
+ }
+
+ #[test]
+ fn rejects_invalid_params() {
+ assert!(LeadLagCrossCorrelation::new(1, 5).is_err());
+ assert!(LeadLagCrossCorrelation::new(10, 0).is_err());
+ assert!(LeadLagCrossCorrelation::new(10, 5).is_ok());
+ }
+
+ #[test]
+ fn accessors_and_metadata() {
+ let ll = LeadLagCrossCorrelation::new(10, 4).unwrap();
+ assert_eq!(ll.window(), 10);
+ assert_eq!(ll.max_lag(), 4);
+ // len = window + 2*max_lag = 10 + 8 = 18.
+ assert_eq!(ll.warmup_period(), 18);
+ assert_eq!(ll.name(), "LeadLagCrossCorrelation");
+ }
+
+ #[test]
+ fn detects_positive_lead() {
+ // b is a delayed by 3 ⇒ a leads b ⇒ lag = +3, correlation ≈ 1.
+ let pairs: Vec<(f64, f64)> = (0..60).map(|t| (signal(t), signal(t - 3))).collect();
+ let out = LeadLagCrossCorrelation::new(12, 5)
+ .unwrap()
+ .batch(&pairs)
+ .into_iter()
+ .flatten()
+ .last()
+ .unwrap();
+ assert_eq!(out.lag, 3);
+ assert!(out.correlation > 0.99, "corr was {}", out.correlation);
+ }
+
+ #[test]
+ fn detects_negative_lead() {
+ // a is a delayed copy of b ⇒ b leads a ⇒ lag = −2.
+ let pairs: Vec<(f64, f64)> = (0..60).map(|t| (signal(t - 2), signal(t))).collect();
+ let out = LeadLagCrossCorrelation::new(12, 5)
+ .unwrap()
+ .batch(&pairs)
+ .into_iter()
+ .flatten()
+ .last()
+ .unwrap();
+ assert_eq!(out.lag, -2);
+ assert!(out.correlation > 0.99, "corr was {}", out.correlation);
+ }
+
+ #[test]
+ fn contemporaneous_is_lag_zero() {
+ // Identical streams correlate best at lag 0 with correlation 1.
+ let pairs: Vec<(f64, f64)> = (0..60).map(|t| (signal(t), signal(t))).collect();
+ let out = LeadLagCrossCorrelation::new(12, 5)
+ .unwrap()
+ .batch(&pairs)
+ .into_iter()
+ .flatten()
+ .last()
+ .unwrap();
+ assert_eq!(out.lag, 0);
+ assert_relative_eq!(out.correlation, 1.0, epsilon = 1e-9);
+ }
+
+ #[test]
+ fn flat_channel_yields_zero_correlation() {
+ // A constant `a` has no variance ⇒ every correlation is 0 ⇒ lag 0.
+ let pairs: Vec<(f64, f64)> = (0..40).map(|t| (5.0, signal(t))).collect();
+ let out = LeadLagCrossCorrelation::new(10, 4)
+ .unwrap()
+ .batch(&pairs)
+ .into_iter()
+ .flatten()
+ .last()
+ .unwrap();
+ assert_eq!(out.lag, 0);
+ assert_relative_eq!(out.correlation, 0.0, epsilon = 1e-12);
+ }
+
+ #[test]
+ fn reset_clears_state() {
+ let mut ll = LeadLagCrossCorrelation::new(10, 4).unwrap();
+ for t in 0..40 {
+ ll.update((signal(t), signal(t - 2)));
+ }
+ assert!(ll.is_ready());
+ ll.reset();
+ assert!(!ll.is_ready());
+ assert_eq!(ll.update((1.0, 1.0)), None);
+ }
+
+ #[test]
+ fn batch_equals_streaming() {
+ let pairs: Vec<(f64, f64)> = (0..80).map(|t| (signal(t), signal(t - 1))).collect();
+ let batch = LeadLagCrossCorrelation::new(12, 5).unwrap().batch(&pairs);
+ let mut ll = LeadLagCrossCorrelation::new(12, 5).unwrap();
+ let streamed: Vec<_> = pairs.iter().map(|p| ll.update(*p)).collect();
+ assert_eq!(batch, streamed);
+ }
+}
diff --git a/crates/wickra-core/src/indicators/mod.rs b/crates/wickra-core/src/indicators/mod.rs
index 56be301c..7a58ccda 100644
--- a/crates/wickra-core/src/indicators/mod.rs
+++ b/crates/wickra-core/src/indicators/mod.rs
@@ -43,6 +43,7 @@ mod classic_pivots;
mod cmf;
mod cmo;
mod coefficient_of_variation;
+mod cointegration;
mod conditional_value_at_risk;
mod connors_rsi;
mod coppock;
@@ -99,6 +100,7 @@ mod kst;
mod kurtosis;
mod kvo;
mod laguerre_rsi;
+mod lead_lag_cross_correlation;
mod linreg;
mod linreg_angle;
mod linreg_channel;
@@ -122,6 +124,8 @@ mod obv;
mod omega_ratio;
mod opening_range;
mod pain_index;
+mod pair_spread_zscore;
+mod pairwise_beta;
mod parkinson;
mod pearson_correlation;
mod percent_b;
@@ -135,6 +139,7 @@ mod psar;
mod pvi;
mod r_squared;
mod recovery_factor;
+mod relative_strength_ab;
mod renko_trailing_stop;
mod roc;
mod rogers_satchell;
@@ -257,6 +262,7 @@ pub use classic_pivots::{ClassicPivots, ClassicPivotsOutput};
pub use cmf::ChaikinMoneyFlow;
pub use cmo::Cmo;
pub use coefficient_of_variation::CoefficientOfVariation;
+pub use cointegration::{Cointegration, CointegrationOutput};
pub use conditional_value_at_risk::ConditionalValueAtRisk;
pub use connors_rsi::ConnorsRsi;
pub use coppock::Coppock;
@@ -313,6 +319,7 @@ pub use kst::{Kst, KstOutput};
pub use kurtosis::Kurtosis;
pub use kvo::Kvo;
pub use laguerre_rsi::LaguerreRsi;
+pub use lead_lag_cross_correlation::{LeadLagCrossCorrelation, LeadLagCrossCorrelationOutput};
pub use linreg::LinearRegression;
pub use linreg_angle::LinRegAngle;
pub use linreg_channel::{LinRegChannel, LinRegChannelOutput};
@@ -336,6 +343,8 @@ pub use obv::Obv;
pub use omega_ratio::OmegaRatio;
pub use opening_range::{OpeningRange, OpeningRangeOutput};
pub use pain_index::PainIndex;
+pub use pair_spread_zscore::PairSpreadZScore;
+pub use pairwise_beta::PairwiseBeta;
pub use parkinson::ParkinsonVolatility;
pub use pearson_correlation::PearsonCorrelation;
pub use percent_b::PercentB;
@@ -349,6 +358,7 @@ pub use psar::Psar;
pub use pvi::Pvi;
pub use r_squared::RSquared;
pub use recovery_factor::RecoveryFactor;
+pub use relative_strength_ab::{RelativeStrengthAB, RelativeStrengthOutput};
pub use renko_trailing_stop::RenkoTrailingStop;
pub use roc::Roc;
pub use rogers_satchell::RogersSatchellVolatility;
diff --git a/crates/wickra-core/src/indicators/pair_spread_zscore.rs b/crates/wickra-core/src/indicators/pair_spread_zscore.rs
new file mode 100644
index 00000000..f3bedd7c
--- /dev/null
+++ b/crates/wickra-core/src/indicators/pair_spread_zscore.rs
@@ -0,0 +1,299 @@
+//! Pair Spread Z-Score — the standardised log-spread of two cointegrated assets.
+
+use std::collections::VecDeque;
+
+use crate::error::{Error, Result};
+use crate::traits::Indicator;
+
+/// Z-score of the log-spread `ln(a) − β·ln(b)` between two assets.
+///
+/// This is the canonical mean-reversion / statistical-arbitrage signal for a
+/// pair. Each `update` receives one `(a, b)` pair of raw **prices** and the
+/// indicator does two things:
+///
+/// 1. **Hedge ratio.** A rolling ordinary-least-squares regression of
+/// `ln(a)` on `ln(b)` over the trailing `beta_period` samples gives the
+/// slope `β = cov(ln a, ln b) / var(ln b)`. The instantaneous spread is the
+/// residual against the origin, `s = ln(a) − β·ln(b)`.
+/// 2. **Standardisation.** The spread is then z-scored over the trailing
+/// `z_period` spreads: `z = (s − mean_s) / std_s`.
+///
+/// A large positive `z` means `a` is rich relative to `b` (sell the spread); a
+/// large negative `z` means `a` is cheap (buy the spread); `z` near zero means
+/// the pair is at its typical relationship. The two windows are independent:
+/// `beta_period` controls how much history the hedge ratio adapts over, and
+/// `z_period` controls the look-back for the mean and dispersion of the spread.
+///
+/// Each `update` is O(1): five running sums maintain the rolling OLS and two
+/// more maintain the rolling spread mean/variance. A flat `ln(b)` window has
+/// zero variance and the hedge ratio is undefined; `β` is then taken as `0`,
+/// reducing the spread to `ln(a)`. A flat spread window (zero dispersion)
+/// yields a z-score of `0` rather than `NaN`.
+///
+/// Prices must be strictly positive and finite for the logarithm to be
+/// defined; a non-positive or non-finite price is skipped (it does not enter
+/// either window), exactly as a real feed would discard a bad tick.
+///
+/// # Example
+///
+/// ```
+/// use wickra_core::{Indicator, PairSpreadZScore};
+///
+/// let mut zs = PairSpreadZScore::new(2, 2).unwrap();
+/// // A flat benchmark gives hedge ratio 0, so the spread is just ln(a); with
+/// // a 2-sample z-window the z-score collapses to the sign of the last move.
+/// let mut last = None;
+/// for a in [100.0, 100.0, 110.0, 120.0] {
+/// last = zs.update((a, 100.0));
+/// }
+/// assert!((last.unwrap() - 1.0).abs() < 1e-9);
+/// ```
+#[derive(Debug, Clone)]
+pub struct PairSpreadZScore {
+ beta_period: usize,
+ z_period: usize,
+ // Rolling OLS of y = ln(a) on x = ln(b).
+ reg: VecDeque<(f64, f64)>,
+ sum_x: f64,
+ sum_y: f64,
+ sum_xx: f64,
+ sum_xy: f64,
+ // Rolling mean/variance of the spread.
+ spreads: VecDeque,
+ sum_s: f64,
+ sum_ss: f64,
+}
+
+impl PairSpreadZScore {
+ /// Construct a new pair spread z-score.
+ ///
+ /// `beta_period` is the look-back for the rolling hedge ratio; `z_period`
+ /// is the look-back for standardising the spread.
+ ///
+ /// # Errors
+ /// Returns [`Error::InvalidPeriod`] if either period is below `2`
+ /// (variance needs at least two points).
+ pub fn new(beta_period: usize, z_period: usize) -> Result {
+ if beta_period < 2 {
+ return Err(Error::InvalidPeriod {
+ message: "pair spread z-score needs beta_period >= 2",
+ });
+ }
+ if z_period < 2 {
+ return Err(Error::InvalidPeriod {
+ message: "pair spread z-score needs z_period >= 2",
+ });
+ }
+ Ok(Self {
+ beta_period,
+ z_period,
+ reg: VecDeque::with_capacity(beta_period),
+ sum_x: 0.0,
+ sum_y: 0.0,
+ sum_xx: 0.0,
+ sum_xy: 0.0,
+ spreads: VecDeque::with_capacity(z_period),
+ sum_s: 0.0,
+ sum_ss: 0.0,
+ })
+ }
+
+ /// Look-back of the rolling hedge-ratio regression.
+ pub const fn beta_period(&self) -> usize {
+ self.beta_period
+ }
+
+ /// Look-back of the rolling spread standardisation.
+ pub const fn z_period(&self) -> usize {
+ self.z_period
+ }
+
+ /// The current hedge ratio `β`, or `None` while the regression is warming
+ /// up. A flat `ln(b)` window reports `0`.
+ fn hedge_ratio(&self) -> Option {
+ if self.reg.len() < self.beta_period {
+ return None;
+ }
+ let n = self.beta_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);
+ if var_x == 0.0 {
+ return Some(0.0);
+ }
+ let cov = self.sum_xy / n - mean_x * mean_y;
+ Some(cov / var_x)
+ }
+
+ fn push_spread(&mut self, s: f64) -> Option {
+ if self.spreads.len() == self.z_period {
+ let old = self.spreads.pop_front().expect("non-empty");
+ self.sum_s -= old;
+ self.sum_ss -= old * old;
+ }
+ self.spreads.push_back(s);
+ self.sum_s += s;
+ self.sum_ss += s * s;
+ if self.spreads.len() < self.z_period {
+ return None;
+ }
+ let m = self.z_period as f64;
+ let mean_s = self.sum_s / m;
+ let var_s = (self.sum_ss / m - mean_s * mean_s).max(0.0);
+ let std_s = var_s.sqrt();
+ if std_s == 0.0 {
+ // A flat spread window has no dispersion to standardise against.
+ return Some(0.0);
+ }
+ Some((s - mean_s) / std_s)
+ }
+}
+
+impl Indicator for PairSpreadZScore {
+ /// `(a, b)` price pair.
+ type Input = (f64, f64);
+ type Output = f64;
+
+ fn update(&mut self, input: (f64, f64)) -> Option {
+ let (a, b) = input;
+ if !(a > 0.0 && b > 0.0 && a.is_finite() && b.is_finite()) {
+ // Bad tick: skip it without disturbing either window.
+ return None;
+ }
+ let x = b.ln();
+ let y = a.ln();
+ if self.reg.len() == self.beta_period {
+ let (ox, oy) = self.reg.pop_front().expect("non-empty");
+ self.sum_x -= ox;
+ self.sum_y -= oy;
+ self.sum_xx -= ox * ox;
+ self.sum_xy -= ox * oy;
+ }
+ self.reg.push_back((x, y));
+ self.sum_x += x;
+ self.sum_y += y;
+ self.sum_xx += x * x;
+ self.sum_xy += x * y;
+ let beta = self.hedge_ratio()?;
+ let spread = y - beta * x;
+ self.push_spread(spread)
+ }
+
+ fn reset(&mut self) {
+ self.reg.clear();
+ self.sum_x = 0.0;
+ self.sum_y = 0.0;
+ self.sum_xx = 0.0;
+ self.sum_xy = 0.0;
+ self.spreads.clear();
+ self.sum_s = 0.0;
+ self.sum_ss = 0.0;
+ }
+
+ fn warmup_period(&self) -> usize {
+ // `beta_period` samples to define the hedge ratio (and the first
+ // spread), then `z_period − 1` more to fill the spread window.
+ self.beta_period + self.z_period - 1
+ }
+
+ fn is_ready(&self) -> bool {
+ self.spreads.len() == self.z_period
+ }
+
+ fn name(&self) -> &'static str {
+ "PairSpreadZScore"
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use crate::traits::BatchExt;
+ use approx::assert_relative_eq;
+
+ #[test]
+ fn rejects_periods_below_two() {
+ assert!(PairSpreadZScore::new(1, 5).is_err());
+ assert!(PairSpreadZScore::new(5, 1).is_err());
+ assert!(PairSpreadZScore::new(2, 2).is_ok());
+ }
+
+ #[test]
+ fn accessors_and_metadata() {
+ let z = PairSpreadZScore::new(10, 20).unwrap();
+ assert_eq!(z.beta_period(), 10);
+ assert_eq!(z.z_period(), 20);
+ assert_eq!(z.warmup_period(), 29);
+ assert_eq!(z.name(), "PairSpreadZScore");
+ }
+
+ #[test]
+ fn flat_benchmark_two_sample_window_is_sign_of_move() {
+ // Flat b ⇒ β = 0 ⇒ spread = ln(a); z_period = 2 ⇒ z = sign of last move.
+ let mut z = PairSpreadZScore::new(2, 2).unwrap();
+ assert_eq!(z.update((100.0, 100.0)), None);
+ assert_eq!(z.update((100.0, 100.0)), None);
+ // The ±1 result is exact in real arithmetic; the variance is computed
+ // via Σs²−mean² so a few ulps of cancellation error remain.
+ assert_relative_eq!(z.update((110.0, 100.0)).unwrap(), 1.0, epsilon = 1e-9);
+ assert_relative_eq!(z.update((105.0, 100.0)).unwrap(), -1.0, epsilon = 1e-9);
+ assert_relative_eq!(z.update((130.0, 100.0)).unwrap(), 1.0, epsilon = 1e-9);
+ }
+
+ #[test]
+ fn constant_spread_yields_zero() {
+ // Both legs flat ⇒ spread constant ⇒ zero dispersion ⇒ z = 0.
+ let pairs: Vec<(f64, f64)> = (0..10).map(|_| (50.0, 100.0)).collect();
+ let last = PairSpreadZScore::new(3, 4)
+ .unwrap()
+ .batch(&pairs)
+ .into_iter()
+ .flatten()
+ .last()
+ .unwrap();
+ assert_relative_eq!(last, 0.0, epsilon = 1e-12);
+ }
+
+ #[test]
+ fn bad_tick_is_skipped() {
+ let mut z = PairSpreadZScore::new(2, 2).unwrap();
+ // A non-positive or non-finite price never enters the windows.
+ assert_eq!(z.update((0.0, 100.0)), None);
+ assert_eq!(z.update((100.0, f64::NAN)), None);
+ assert!(!z.is_ready());
+ // Valid ticks then warm the indicator normally.
+ z.update((100.0, 100.0));
+ z.update((100.0, 100.0));
+ z.update((110.0, 100.0));
+ assert!(z.is_ready());
+ }
+
+ #[test]
+ fn reset_clears_state() {
+ let mut z = PairSpreadZScore::new(3, 3).unwrap();
+ for i in 0..10 {
+ let b = 100.0 + 5.0 * f64::from(i).sin();
+ z.update((b * 1.5, b));
+ }
+ assert!(z.is_ready());
+ z.reset();
+ assert!(!z.is_ready());
+ assert_eq!(z.update((100.0, 100.0)), None);
+ }
+
+ #[test]
+ fn batch_equals_streaming() {
+ let pairs: Vec<(f64, f64)> = (0..80)
+ .map(|i| {
+ let t = f64::from(i);
+ let b = 100.0 + 10.0 * (t * 0.2).sin();
+ let a = b * (1.0 + 0.05 * (t * 0.5).cos());
+ (a, b)
+ })
+ .collect();
+ let batch = PairSpreadZScore::new(14, 10).unwrap().batch(&pairs);
+ let mut z = PairSpreadZScore::new(14, 10).unwrap();
+ let streamed: Vec<_> = pairs.iter().map(|p| z.update(*p)).collect();
+ assert_eq!(batch, streamed);
+ }
+}
diff --git a/crates/wickra-core/src/indicators/pairwise_beta.rs b/crates/wickra-core/src/indicators/pairwise_beta.rs
new file mode 100644
index 00000000..f0e78919
--- /dev/null
+++ b/crates/wickra-core/src/indicators/pairwise_beta.rs
@@ -0,0 +1,292 @@
+//! Pairwise Beta — rolling OLS slope of one asset's log-returns on another's.
+
+use std::collections::VecDeque;
+
+use crate::error::{Error, Result};
+use crate::traits::Indicator;
+
+/// Rolling Beta of asset `a`'s **log-returns** on asset `b`'s log-returns.
+///
+/// Each `update` receives one `(a, b)` pair of raw **prices**. Internally the
+/// indicator differences consecutive prices into log-returns
+/// `rₜ = ln(pₜ / pₜ₋₁)` and runs a rolling ordinary-least-squares regression of
+/// `a`'s returns on `b`'s returns over the trailing window of `period` return
+/// pairs:
+///
+/// ```text
+/// cov_ab = (1/n) · Σ rₐ·r_b − r̄ₐ·r̄_b
+/// var_b = (1/n) · Σ r_b² − r̄_b²
+/// Beta = cov_ab / var_b
+/// ```
+///
+/// This is the slope of the OLS line and measures how much asset `a` moves, in
+/// return space, for a unit return of asset `b`. A reading of `1.0` means the
+/// two move together one-for-one; `2.0` means `a` typically doubles `b`'s
+/// moves; negative readings signal an inverse relationship and the basis for a
+/// hedge.
+///
+/// This differs from [`crate::Beta`], which regresses the raw inputs it is
+/// fed. `PairwiseBeta` always works in return space: feed it raw price levels
+/// and it computes the returns for you, which is the conventional way to
+/// measure cross-asset Beta (a Beta on price *levels* is dominated by the
+/// shared trend and rarely what you want).
+///
+/// Each `update` is O(1): four running sums (`Σrₐ`, `Σr_b`, `Σr_b²`,
+/// `Σrₐ·r_b`) are maintained as the window of returns slides. A flat `b`
+/// window has zero return variance and Beta is undefined; the indicator
+/// returns `0` in that case rather than producing `NaN`.
+///
+/// Prices must be strictly positive and finite for the log-return to be
+/// defined. A non-positive or non-finite price breaks the return chain: that
+/// sample is dropped and the next valid price re-seeds the previous-price
+/// reference, exactly as a real feed would resume after a bad tick.
+///
+/// # Example
+///
+/// ```
+/// use wickra_core::{Indicator, PairwiseBeta};
+///
+/// let mut indicator = PairwiseBeta::new(10).unwrap();
+/// let mut last = None;
+/// for i in 0..30 {
+/// // A varying (non-constant-return) positive price path.
+/// let b = 100.0 + 10.0 * (f64::from(i) * 0.5).sin();
+/// // `a = b²`, so a's log-returns are exactly twice b's.
+/// last = indicator.update((b * b, b));
+/// }
+/// assert!((last.unwrap() - 2.0).abs() < 1e-9);
+/// ```
+#[derive(Debug, Clone)]
+pub struct PairwiseBeta {
+ period: usize,
+ prev: Option<(f64, f64)>,
+ window: VecDeque<(f64, f64)>,
+ sum_a: f64,
+ sum_b: f64,
+ sum_bb: f64,
+ sum_ab: f64,
+}
+
+impl PairwiseBeta {
+ /// Construct a new rolling pairwise Beta over `period` return pairs.
+ ///
+ /// # Errors
+ /// Returns [`Error::InvalidPeriod`] if `period < 2` (variance needs at
+ /// least two returns).
+ pub fn new(period: usize) -> Result {
+ if period < 2 {
+ return Err(Error::InvalidPeriod {
+ message: "pairwise beta needs period >= 2",
+ });
+ }
+ Ok(Self {
+ period,
+ prev: None,
+ window: VecDeque::with_capacity(period),
+ sum_a: 0.0,
+ sum_b: 0.0,
+ sum_bb: 0.0,
+ sum_ab: 0.0,
+ })
+ }
+
+ /// Configured period (number of return pairs in the rolling window).
+ pub const fn period(&self) -> usize {
+ self.period
+ }
+
+ fn push_return(&mut self, ra: f64, rb: f64) -> Option {
+ 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((ra, rb));
+ self.sum_a += ra;
+ self.sum_b += rb;
+ self.sum_bb += rb * rb;
+ self.sum_ab += ra * rb;
+ 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-return window has no defined beta.
+ return Some(0.0);
+ }
+ Some(cov / var_b)
+ }
+}
+
+impl Indicator for PairwiseBeta {
+ /// `(a, b)` price pair.
+ type Input = (f64, f64);
+ type Output = f64;
+
+ fn update(&mut self, input: (f64, f64)) -> Option {
+ let (a, b) = input;
+ if !(a > 0.0 && b > 0.0 && a.is_finite() && b.is_finite()) {
+ // Bad tick: drop it and restart the return chain.
+ self.prev = None;
+ return None;
+ }
+ let Some((pa, pb)) = self.prev else {
+ self.prev = Some((a, b));
+ return None;
+ };
+ self.prev = Some((a, b));
+ let ra = (a / pa).ln();
+ let rb = (b / pb).ln();
+ self.push_return(ra, rb)
+ }
+
+ fn reset(&mut self) {
+ self.prev = None;
+ 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 {
+ // One prior price to seed, then `period` return pairs.
+ self.period + 1
+ }
+
+ fn is_ready(&self) -> bool {
+ self.window.len() == self.period
+ }
+
+ fn name(&self) -> &'static str {
+ "PairwiseBeta"
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use crate::traits::BatchExt;
+ use approx::assert_relative_eq;
+
+ #[test]
+ fn rejects_period_below_two() {
+ assert!(PairwiseBeta::new(0).is_err());
+ assert!(PairwiseBeta::new(1).is_err());
+ assert!(PairwiseBeta::new(2).is_ok());
+ }
+
+ #[test]
+ fn accessors_and_metadata() {
+ let b = PairwiseBeta::new(14).unwrap();
+ assert_eq!(b.period(), 14);
+ assert_eq!(b.warmup_period(), 15);
+ assert_eq!(b.name(), "PairwiseBeta");
+ }
+
+ #[test]
+ fn squared_price_gives_beta_two() {
+ // a = b² ⇒ a's log-returns are exactly 2× b's ⇒ beta = 2.
+ let pairs: Vec<(f64, f64)> = (0..20)
+ .map(|i| {
+ let b = 100.0 + 10.0 * (f64::from(i) * 0.5).sin();
+ (b * b, b)
+ })
+ .collect();
+ let last = PairwiseBeta::new(5)
+ .unwrap()
+ .batch(&pairs)
+ .into_iter()
+ .flatten()
+ .last()
+ .unwrap();
+ assert_relative_eq!(last, 2.0, epsilon = 1e-9);
+ }
+
+ #[test]
+ fn inverse_price_gives_beta_minus_one() {
+ // a = 1/b ⇒ a's log-returns are −1× b's ⇒ beta = −1.
+ let pairs: Vec<(f64, f64)> = (0..20)
+ .map(|i| {
+ let b = 100.0 + 10.0 * (f64::from(i) * 0.5).sin();
+ (1.0 / b, b)
+ })
+ .collect();
+ let last = PairwiseBeta::new(5)
+ .unwrap()
+ .batch(&pairs)
+ .into_iter()
+ .flatten()
+ .last()
+ .unwrap();
+ assert_relative_eq!(last, -1.0, epsilon = 1e-9);
+ }
+
+ #[test]
+ fn flat_benchmark_returns_zero() {
+ // b constant ⇒ zero return variance ⇒ beta defined as 0.
+ let pairs: Vec<(f64, f64)> = (0..10).map(|i| (100.0 * 1.01_f64.powi(i), 7.0)).collect();
+ let last = PairwiseBeta::new(5)
+ .unwrap()
+ .batch(&pairs)
+ .into_iter()
+ .flatten()
+ .last()
+ .unwrap();
+ assert_relative_eq!(last, 0.0, epsilon = 1e-12);
+ }
+
+ #[test]
+ fn bad_tick_breaks_return_chain() {
+ let mut b = PairwiseBeta::new(3).unwrap();
+ // Seed, one good return, then a non-positive price drops the chain.
+ assert_eq!(b.update((100.0, 100.0)), None);
+ assert_eq!(b.update((101.0, 101.0)), None);
+ assert_eq!(b.update((0.0, 50.0)), None); // bad tick, prev reset
+ assert!(!b.is_ready());
+ // A non-finite price is rejected the same way.
+ assert_eq!(b.update((f64::NAN, 50.0)), None);
+ assert!(!b.is_ready());
+ // Recovery: subsequent valid prices rebuild the window cleanly.
+ for i in 0..5 {
+ let p = 100.0 * 1.01_f64.powi(i);
+ b.update((p * p, p));
+ }
+ assert!(b.is_ready());
+ }
+
+ #[test]
+ fn reset_clears_state() {
+ let mut b = PairwiseBeta::new(3).unwrap();
+ for i in 0..6 {
+ let p = 100.0 * 1.01_f64.powi(i);
+ b.update((p * p, p));
+ }
+ assert!(b.is_ready());
+ b.reset();
+ assert!(!b.is_ready());
+ assert_eq!(b.update((100.0, 100.0)), None);
+ }
+
+ #[test]
+ fn batch_equals_streaming() {
+ let pairs: Vec<(f64, f64)> = (0..60)
+ .map(|i| {
+ let t = f64::from(i);
+ let b = 100.0 + 5.0 * t.sin();
+ let a = 100.0 + 3.0 * t.sin() + 0.5 * t.cos();
+ (a, b)
+ })
+ .collect();
+ let batch = PairwiseBeta::new(14).unwrap().batch(&pairs);
+ let mut b = PairwiseBeta::new(14).unwrap();
+ let streamed: Vec<_> = pairs.iter().map(|p| b.update(*p)).collect();
+ assert_eq!(batch, streamed);
+ }
+}
diff --git a/crates/wickra-core/src/indicators/relative_strength_ab.rs b/crates/wickra-core/src/indicators/relative_strength_ab.rs
new file mode 100644
index 00000000..27dbd88a
--- /dev/null
+++ b/crates/wickra-core/src/indicators/relative_strength_ab.rs
@@ -0,0 +1,233 @@
+//! Relative Strength A-vs-B — the price ratio of two assets, plus its MA and RSI.
+
+use crate::error::Result;
+use crate::indicators::{Rsi, Sma};
+use crate::traits::Indicator;
+
+/// Output of [`RelativeStrengthAB`].
+#[derive(Debug, Clone, Copy, PartialEq)]
+pub struct RelativeStrengthOutput {
+ /// The raw relative-strength ratio `a / b`.
+ pub ratio: f64,
+ /// Simple moving average of the ratio over `ma_period`.
+ pub ratio_ma: f64,
+ /// Relative Strength Index of the ratio over `rsi_period`.
+ pub ratio_rsi: f64,
+}
+
+/// Comparative relative strength of asset `a` against asset `b`.
+///
+/// Each `update` receives one `(a, b)` price pair and forms the **ratio line**
+/// `a / b`. The ratio is then smoothed with a simple moving average and run
+/// through an RSI, so a single indicator gives you the relative-strength level,
+/// its trend, and whether that trend is overbought or oversold:
+///
+/// ```text
+/// ratio = a / b
+/// ratio_ma = SMA(ratio, ma_period)
+/// ratio_rsi = RSI(ratio, rsi_period)
+/// ```
+///
+/// A rising ratio means `a` is outperforming `b`; `ratio_ma` shows the trend of
+/// that outperformance and `ratio_rsi` flags exhaustion (e.g. `> 70` after a
+/// strong run of `a` over `b`). This is the classic "asset-vs-asset" or
+/// "asset-vs-index" rotation screen.
+///
+/// The first output appears once both the moving average and the RSI have
+/// warmed up; the ratio itself is computed from the first valid pair. A
+/// non-finite price or a zero denominator (`b == 0`) makes the ratio undefined
+/// and is skipped, leaving the internal averages untouched.
+///
+/// # Example
+///
+/// ```
+/// use wickra_core::{Indicator, RelativeStrengthAB};
+///
+/// let mut rs = RelativeStrengthAB::new(5, 5).unwrap();
+/// let mut last = None;
+/// for _ in 0..20 {
+/// last = rs.update((200.0, 100.0)); // ratio is a constant 2.0
+/// }
+/// let out = last.unwrap();
+/// assert!((out.ratio - 2.0).abs() < 1e-12);
+/// assert!((out.ratio_ma - 2.0).abs() < 1e-12);
+/// // A flat ratio has no gains or losses, so its RSI sits at the neutral 50.
+/// assert!((out.ratio_rsi - 50.0).abs() < 1e-9);
+/// ```
+#[derive(Debug, Clone)]
+pub struct RelativeStrengthAB {
+ ma_period: usize,
+ rsi_period: usize,
+ ma: Sma,
+ rsi: Rsi,
+}
+
+impl RelativeStrengthAB {
+ /// Construct a new comparative relative-strength indicator.
+ ///
+ /// `ma_period` is the moving-average look-back of the ratio; `rsi_period`
+ /// is the RSI look-back of the ratio.
+ ///
+ /// # Errors
+ /// Returns [`Error::PeriodZero`](crate::Error::PeriodZero) if either period
+ /// is zero.
+ pub fn new(ma_period: usize, rsi_period: usize) -> Result {
+ Ok(Self {
+ ma_period,
+ rsi_period,
+ ma: Sma::new(ma_period)?,
+ rsi: Rsi::new(rsi_period)?,
+ })
+ }
+
+ /// Moving-average look-back of the ratio.
+ pub const fn ma_period(&self) -> usize {
+ self.ma_period
+ }
+
+ /// RSI look-back of the ratio.
+ pub const fn rsi_period(&self) -> usize {
+ self.rsi_period
+ }
+}
+
+impl Indicator for RelativeStrengthAB {
+ /// `(a, b)` price pair.
+ type Input = (f64, f64);
+ type Output = RelativeStrengthOutput;
+
+ fn update(&mut self, input: (f64, f64)) -> Option {
+ let (a, b) = input;
+ if b == 0.0 || !a.is_finite() || !b.is_finite() {
+ // Undefined ratio: skip without disturbing the internal averages.
+ return None;
+ }
+ let ratio = a / b;
+ let ma = self.ma.update(ratio);
+ let rsi = self.rsi.update(ratio);
+ match (ma, rsi) {
+ (Some(ratio_ma), Some(ratio_rsi)) => Some(RelativeStrengthOutput {
+ ratio,
+ ratio_ma,
+ ratio_rsi,
+ }),
+ _ => None,
+ }
+ }
+
+ fn reset(&mut self) {
+ self.ma.reset();
+ self.rsi.reset();
+ }
+
+ fn warmup_period(&self) -> usize {
+ self.ma.warmup_period().max(self.rsi.warmup_period())
+ }
+
+ fn is_ready(&self) -> bool {
+ self.ma.is_ready() && self.rsi.is_ready()
+ }
+
+ fn name(&self) -> &'static str {
+ "RelativeStrengthAB"
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use crate::traits::BatchExt;
+ use approx::assert_relative_eq;
+
+ #[test]
+ fn rejects_zero_periods() {
+ assert!(RelativeStrengthAB::new(0, 5).is_err());
+ assert!(RelativeStrengthAB::new(5, 0).is_err());
+ assert!(RelativeStrengthAB::new(5, 5).is_ok());
+ }
+
+ #[test]
+ fn accessors_and_metadata() {
+ let rs = RelativeStrengthAB::new(10, 14).unwrap();
+ assert_eq!(rs.ma_period(), 10);
+ assert_eq!(rs.rsi_period(), 14);
+ // SMA warmup = 10, RSI warmup = 15 ⇒ combined = 15.
+ assert_eq!(rs.warmup_period(), 15);
+ assert_eq!(rs.name(), "RelativeStrengthAB");
+ }
+
+ #[test]
+ fn constant_ratio_is_flat() {
+ // a = 2·b ⇒ ratio is a constant 2 ⇒ MA = 2, RSI = neutral 50.
+ let pairs: Vec<(f64, f64)> = (0..20).map(|_| (200.0, 100.0)).collect();
+ let out = RelativeStrengthAB::new(5, 5)
+ .unwrap()
+ .batch(&pairs)
+ .into_iter()
+ .flatten()
+ .last()
+ .unwrap();
+ assert_relative_eq!(out.ratio, 2.0, epsilon = 1e-12);
+ assert_relative_eq!(out.ratio_ma, 2.0, epsilon = 1e-12);
+ assert_relative_eq!(out.ratio_rsi, 50.0, epsilon = 1e-9);
+ }
+
+ #[test]
+ fn rising_ratio_is_overbought() {
+ // a grows while b is flat ⇒ ratio strictly rises ⇒ RSI saturates at 100.
+ let pairs: Vec<(f64, f64)> = (0..20)
+ .map(|t| (100.0 + 2.0 * f64::from(t), 100.0))
+ .collect();
+ let out = RelativeStrengthAB::new(5, 5)
+ .unwrap()
+ .batch(&pairs)
+ .into_iter()
+ .flatten()
+ .last()
+ .unwrap();
+ assert!(out.ratio > 1.0);
+ assert_relative_eq!(out.ratio_rsi, 100.0, epsilon = 1e-9);
+ }
+
+ #[test]
+ fn zero_denominator_is_skipped() {
+ let mut rs = RelativeStrengthAB::new(3, 3).unwrap();
+ // b == 0 and non-finite inputs never reach the internal averages.
+ assert_eq!(rs.update((100.0, 0.0)), None);
+ assert_eq!(rs.update((f64::NAN, 100.0)), None);
+ assert!(!rs.is_ready());
+ for _ in 0..8 {
+ rs.update((150.0, 100.0));
+ }
+ assert!(rs.is_ready());
+ }
+
+ #[test]
+ fn reset_clears_state() {
+ let mut rs = RelativeStrengthAB::new(3, 3).unwrap();
+ for t in 0..10 {
+ rs.update((100.0 + f64::from(t), 100.0));
+ }
+ assert!(rs.is_ready());
+ rs.reset();
+ assert!(!rs.is_ready());
+ assert_eq!(rs.update((100.0, 100.0)), None);
+ }
+
+ #[test]
+ fn batch_equals_streaming() {
+ let pairs: Vec<(f64, f64)> = (0..60)
+ .map(|t| {
+ let tt = f64::from(t);
+ (
+ 100.0 + 5.0 * (tt * 0.3).sin(),
+ 100.0 + 2.0 * (tt * 0.2).cos(),
+ )
+ })
+ .collect();
+ let batch = RelativeStrengthAB::new(10, 14).unwrap().batch(&pairs);
+ let mut rs = RelativeStrengthAB::new(10, 14).unwrap();
+ let streamed: Vec<_> = pairs.iter().map(|p| rs.update(*p)).collect();
+ assert_eq!(batch, streamed);
+ }
+}
diff --git a/crates/wickra-core/src/lib.rs b/crates/wickra-core/src/lib.rs
index c39ff2ba..23bd72d8 100644
--- a/crates/wickra-core/src/lib.rs
+++ b/crates/wickra-core/src/lib.rs
@@ -52,31 +52,33 @@ pub use indicators::{
CamarillaPivotsOutput, Cci, CenterOfGravity, Cfo, ChaikinMoneyFlow, ChaikinOscillator,
ChaikinVolatility, ChandeKrollStop, ChandeKrollStopOutput, ChandelierExit,
ChandelierExitOutput, ChoppinessIndex, ClassicPivots, ClassicPivotsOutput, Cmo,
- CoefficientOfVariation, ConditionalValueAtRisk, ConnorsRsi, Coppock, CyberneticCycle, Decycler,
- DecyclerOscillator, Dema, DemandIndex, DemarkPivots, DemarkPivotsOutput, DetrendedStdDev, Doji,
- Donchian, DonchianOutput, DonchianStop, DonchianStopOutput, DoubleBollinger,
- DoubleBollingerOutput, Dpo, DrawdownDuration, EaseOfMovement, EhlersStochastic, ElderImpulse,
- Ema, EmpiricalModeDecomposition, Engulfing, Evwma, Fama, FibonacciPivots,
- FibonacciPivotsOutput, FisherTransform, ForceIndex, FractalChaosBands, FractalChaosBandsOutput,
- Frama, GainLossRatio, GarmanKlassVolatility, Hammer, HangingMan, Harami, HeikinAshi,
- HeikinAshiOutput, HiLoActivator, HilbertDominantCycle, HistoricalVolatility, Hma, HurstChannel,
- HurstChannelOutput, HurstExponent, Ichimoku, IchimokuOutput, Inertia, InformationRatio,
- InitialBalance, InitialBalanceOutput, InstantaneousTrendline, InverseFisherTransform,
- InvertedHammer, Jma, Kama, KellyCriterion, Keltner, KeltnerOutput, Kst, KstOutput, Kurtosis,
- Kvo, LaguerreRsi, LinRegAngle, LinRegChannel, LinRegChannelOutput, LinRegSlope,
+ CoefficientOfVariation, Cointegration, CointegrationOutput, ConditionalValueAtRisk, ConnorsRsi,
+ Coppock, CyberneticCycle, Decycler, DecyclerOscillator, Dema, DemandIndex, DemarkPivots,
+ DemarkPivotsOutput, DetrendedStdDev, Doji, Donchian, DonchianOutput, DonchianStop,
+ DonchianStopOutput, DoubleBollinger, DoubleBollingerOutput, Dpo, DrawdownDuration,
+ EaseOfMovement, EhlersStochastic, ElderImpulse, Ema, EmpiricalModeDecomposition, Engulfing,
+ Evwma, Fama, FibonacciPivots, FibonacciPivotsOutput, FisherTransform, ForceIndex,
+ FractalChaosBands, FractalChaosBandsOutput, Frama, GainLossRatio, GarmanKlassVolatility,
+ Hammer, HangingMan, Harami, HeikinAshi, HeikinAshiOutput, HiLoActivator, HilbertDominantCycle,
+ HistoricalVolatility, Hma, HurstChannel, HurstChannelOutput, HurstExponent, Ichimoku,
+ IchimokuOutput, Inertia, InformationRatio, InitialBalance, InitialBalanceOutput,
+ InstantaneousTrendline, InverseFisherTransform, InvertedHammer, Jma, Kama, KellyCriterion,
+ Keltner, KeltnerOutput, Kst, KstOutput, Kurtosis, Kvo, LaguerreRsi, LeadLagCrossCorrelation,
+ LeadLagCrossCorrelationOutput, LinRegAngle, LinRegChannel, LinRegChannelOutput, LinRegSlope,
LinearRegression, MaEnvelope, MaEnvelopeOutput, MacdIndicator, MacdOutput, Mama, MamaOutput,
MarketFacilitationIndex, Marubozu, MassIndex, MaxDrawdown, McGinleyDynamic,
MedianAbsoluteDeviation, MedianPrice, Mfi, Mom, MorningEveningStar, Natr, Nvi, Obv, OmegaRatio,
- OpeningRange, OpeningRangeOutput, PainIndex, ParkinsonVolatility, PearsonCorrelation, PercentB,
- PercentageTrailingStop, Pgo, PiercingDarkCloud, Pmo, Ppo, ProfitFactor, Psar, Pvi, RSquared,
- RecoveryFactor, RenkoTrailingStop, Roc, RogersSatchellVolatility, RollingVwap, RoofingFilter,
- Rsi, Rvi, RviVolatility, Rwi, RwiOutput, SharpeRatio, ShootingStar, SineWave, Skewness, Sma,
- Smi, Smma, SortinoRatio, SpearmanCorrelation, SpinningTop, 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, ThreeInside, ThreeOutside,
+ OpeningRange, OpeningRangeOutput, PainIndex, PairSpreadZScore, PairwiseBeta,
+ ParkinsonVolatility, PearsonCorrelation, PercentB, PercentageTrailingStop, Pgo,
+ PiercingDarkCloud, Pmo, Ppo, ProfitFactor, Psar, Pvi, RSquared, RecoveryFactor,
+ RelativeStrengthAB, RelativeStrengthOutput, RenkoTrailingStop, Roc, RogersSatchellVolatility,
+ RollingVwap, RoofingFilter, Rsi, Rvi, RviVolatility, Rwi, RwiOutput, SharpeRatio, ShootingStar,
+ SineWave, Skewness, Sma, Smi, Smma, SortinoRatio, SpearmanCorrelation, SpinningTop,
+ 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, ThreeInside, ThreeOutside,
ThreeSoldiersOrCrows, Tii, TreynorRatio, Trima, Trix, TrueRange, Tsi, Tsv, TtmSqueeze,
TtmSqueezeOutput, Tweezer, TypicalPrice, UlcerIndex, UltimateOscillator, ValueArea,
ValueAreaOutput, ValueAtRisk, Variance, VerticalHorizontalFilter, Vidya, VoltyStop,
diff --git a/fuzz/fuzz_targets/indicator_update_pair.rs b/fuzz/fuzz_targets/indicator_update_pair.rs
index 2af4f4e7..265f45ae 100644
--- a/fuzz/fuzz_targets/indicator_update_pair.rs
+++ b/fuzz/fuzz_targets/indicator_update_pair.rs
@@ -8,7 +8,10 @@
//! panic.
use libfuzzer_sys::fuzz_target;
-use wickra_core::{Alpha, BatchExt, Indicator, InformationRatio, TreynorRatio};
+use wickra_core::{
+ Alpha, BatchExt, Cointegration, Indicator, InformationRatio, LeadLagCrossCorrelation,
+ PairSpreadZScore, PairwiseBeta, RelativeStrengthAB, TreynorRatio,
+};
#[inline(never)]
fn drive(make: impl Fn() -> I, data: &[(f64, f64)])
@@ -36,4 +39,26 @@ fuzz_target!(|data: &[u8]| {
drive(|| TreynorRatio::new(10, 0.0).unwrap(), &pairs);
drive(|| InformationRatio::new(10).unwrap(), &pairs);
drive(|| Alpha::new(10, 0.0).unwrap(), &pairs);
+ drive(|| PairwiseBeta::new(10).unwrap(), &pairs);
+ drive(|| PairSpreadZScore::new(10, 10).unwrap(), &pairs);
+
+ // Struct-output pair indicator: drive update + batch directly (the generic
+ // `drive` above only covers `Output = f64`).
+ let mut ll = LeadLagCrossCorrelation::new(8, 3).unwrap();
+ for &x in &pairs {
+ let _ = ll.update(x);
+ }
+ let _ = LeadLagCrossCorrelation::new(8, 3).unwrap().batch(&pairs);
+
+ let mut co = Cointegration::new(12, 1).unwrap();
+ for &x in &pairs {
+ let _ = co.update(x);
+ }
+ let _ = Cointegration::new(12, 1).unwrap().batch(&pairs);
+
+ let mut rs = RelativeStrengthAB::new(10, 14).unwrap();
+ for &x in &pairs {
+ let _ = rs.update(x);
+ }
+ let _ = RelativeStrengthAB::new(10, 14).unwrap().batch(&pairs);
});