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); });