feat: cross-asset / pairwise indicators (5 new) (#109)

* feat(core): add PairwiseBeta cross-asset indicator

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.

Two-series Indicator<Input = (f64, f64)>, exposed in Rust, Python, Node
and WASM, with unit/known-value/streaming tests and a pair fuzz target.

* feat(core): add PairSpreadZScore cross-asset indicator

Standardised log-spread ln(a) - beta*ln(b) of a pair, where beta 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.

Two-series Indicator<Input = (f64, f64)>, exposed in Rust, Python, Node
and WASM, with sign/known-value/streaming tests and a pair fuzz target.

* feat(core): add LeadLagCrossCorrelation cross-asset indicator

Reports the integer offset k in [-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. A positive lag means a leads b. Fully causal: a's window is
held centred while b's window slides across the buffered history, so every
lag is evaluated only against data already seen.

Struct output { lag, correlation }, exposed in Rust, Python, Node and WASM
with lead-detection/streaming tests and a pair fuzz driver.

* feat(core): add Cointegration (Engle-Granger + ADF) indicator

Rolling pairs-trading screen: an OLS hedge ratio of a on b, the spread
(residual) a - (alpha + beta*b), and an augmented Dickey-Fuller t-statistic
on the spread with configurable lags. A strongly negative statistic flags a
mean-reverting, tradeable spread. Includes a small Gaussian-elimination
solver for the augmented regression.

Struct output { hedge_ratio, spread, adf_stat }, exposed in Rust, Python,
Node and WASM with stationarity/hedge-ratio/streaming tests and a pair fuzz
driver.

* feat(core): add RelativeStrengthAB cross-asset indicator

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. Composes the existing Sma and Rsi over the
ratio; a zero denominator or non-finite price is skipped.

Struct output { ratio, ratio_ma, ratio_rsi }, exposed in Rust, Python, Node
and WASM with flat/rising-ratio/streaming tests and a pair fuzz driver.

* test(cointegration): cover ADF guard branches

The ADF helper's short-series and degrees-of-freedom guards and the
zero-dispersion (perfect AR) path are unreachable through the public
Cointegration API (period >= 2*adf_lags + 4), so exercise them with direct
unit tests on adf_no_constant. The second linear solve cannot be singular
once the coefficient solve on the same matrix has succeeded, so it now uses
expect() instead of a dead error branch.
This commit is contained in:
kingchenc
2026-06-01 13:45:21 +02:00
committed by GitHub
parent 1ab9bc70d1
commit 0b85142ad1
20 changed files with 3008 additions and 77 deletions
@@ -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);
+100
View File
@@ -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 {
/** EngleGranger hedge ratio (OLS slope of `a` on `b`). */
hedgeRatio: number
/** Current spread (regression residual) `a - (alpha + beta*b)`. */
spread: number
/**
* Augmented DickeyFuller 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<number>, y: Array<number>): Array<number>
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<number>, b: Array<number>): Array<number>
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<number>, b: Array<number>): Array<number>
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<number>, b: Array<number>): Array<number>
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<number>, b: Array<number>): Array<number>
reset(): void
isReady(): boolean
warmupPeriod(): number
}
export type MacdNode = MACD
export declare class MACD {
constructor(fast: number, slow: number, signal: number)
+54 -50
View File
@@ -310,7 +310,7 @@ if (!nativeBinding) {
throw new Error(`Failed to load native binding`)
}
const { version, SMA, EMA, WMA, RSI, DEMA, TEMA, HMA, ROC, TRIX, SMMA, TRIMA, ZLEMA, MOM, CMO, DPO, StdDev, UlcerIndex, VerticalHorizontalFilter, ZScore, MACD, BollingerBands, ATR, Stochastic, OBV, ADX, ADXR, CCI, WilliamsR, MFI, PSAR, Keltner, Donchian, VWAP, RollingVWAP, AwesomeOscillator, Aroon, KAMA, RVI, PGO, KST, SMI, LaguerreRSI, ConnorsRSI, Inertia, ALMA, McGinleyDynamic, FRAMA, VIDYA, JMA, Alligator, EVWMA, APO, AwesomeOscillatorHistogram, CFO, ZeroLagMACD, ElderImpulse, STC, T3, TSI, PMO, TII, ADL, VolumePriceTrend, ChaikinMoneyFlow, ChaikinOscillator, ForceIndex, EaseOfMovement, KVO, VolumeOscillator, NVI, PVI, WilliamsAD, AnchoredVWAP, DemandIndex, TSV, VZO, MarketFacilitationIndex, SuperTrend, ChandelierExit, ChandeKrollStop, AtrTrailingStop, HiLoActivator, VoltyStop, YoyoExit, DonchianStop, PercentageTrailingStop, StepTrailingStop, RenkoTrailingStop, TypicalPrice, MedianPrice, WeightedClose, LinearRegression, LinRegSlope, AcceleratorOscillator, BalanceOfPower, ChoppinessIndex, TrueRange, ChaikinVolatility, LinRegAngle, BollingerBandwidth, PercentB, NATR, HistoricalVolatility, AroonOscillator, Vortex, RWI, WaveTrend, MassIndex, StochRSI, UltimateOscillator, PPO, Coppock, VWMA, RVIVolatility, ParkinsonVolatility, GarmanKlassVolatility, RogersSatchellVolatility, YangZhangVolatility, MaEnvelope, AccelerationBands, StarcBands, AtrBands, HurstChannel, LinRegChannel, StandardErrorBands, DoubleBollinger, TtmSqueeze, FractalChaosBands, VwapStdDevBands, ClassicPivots, FibonacciPivots, Camarilla, WoodiePivots, DemarkPivots, WilliamsFractals, ZigZag, TDSetup, TDSequential, TDDeMarker, TDREI, TDPressure, TDCombo, TDCountdown, TDLines, TDRangeProjection, TDDifferential, TDOpen, TDRiskLevel, SuperSmoother, FisherTransform, InverseFisherTransform, Decycler, DecyclerOscillator, RoofingFilter, CenterOfGravity, CyberneticCycle, InstantaneousTrendline, EhlersStochastic, EmpiricalModeDecomposition, HilbertDominantCycle, AdaptiveCycle, SineWave, MAMA, FAMA, Ichimoku, HeikinAshi, 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
+259
View File
@@ -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<Self> {
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<f64> {
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<f64>, b: Vec<f64>) -> napi::Result<Vec<f64>> {
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<Self> {
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<LeadLagValue> {
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<f64>, b: Vec<f64>) -> napi::Result<Vec<f64>> {
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 {
/// EngleGranger hedge ratio (OLS slope of `a` on `b`).
pub hedge_ratio: f64,
/// Current spread (regression residual) `a - (alpha + beta*b)`.
pub spread: f64,
/// Augmented DickeyFuller 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<Self> {
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<CointegrationValue> {
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<f64>, b: Vec<f64>) -> napi::Result<Vec<f64>> {
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<Self> {
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<RelativeStrengthValue> {
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<f64>, b: Vec<f64>) -> napi::Result<Vec<f64>> {
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.
+10
View File
@@ -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",
+382
View File
@@ -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<Self> {
Ok(Self {
inner: wc::PairwiseBeta::new(period).map_err(map_err)?,
})
}
fn update(&mut self, a: f64, b: f64) -> Option<f64> {
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<Bound<'py, PyArray1<f64>>> {
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<Self> {
Ok(Self {
inner: wc::PairSpreadZScore::new(beta_period, z_period).map_err(map_err)?,
})
}
fn update(&mut self, a: f64, b: f64) -> Option<f64> {
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<Bound<'py, PyArray1<f64>>> {
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<Self> {
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<Bound<'py, PyArray2<f64>>> {
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<Self> {
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<Bound<'py, PyArray2<f64>>> {
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<Self> {
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<Bound<'py, PyArray2<f64>>> {
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::<PyHurstExponent>()?;
m.add_class::<PyPearsonCorrelation>()?;
m.add_class::<PyBeta>()?;
m.add_class::<PyPairwiseBeta>()?;
m.add_class::<PyPairSpreadZScore>()?;
m.add_class::<PyLeadLagCrossCorrelation>()?;
m.add_class::<PyCointegration>()?;
m.add_class::<PyRelativeStrengthAB>()?;
m.add_class::<PySpearmanCorrelation>()?;
m.add_class::<PyValueArea>()?;
m.add_class::<PyInitialBalance>()?;
@@ -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
@@ -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)])
@@ -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
+217
View File
@@ -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<WasmPairSpreadZScore, JsError> {
Ok(Self {
inner: wc::PairSpreadZScore::new(beta_period, z_period).map_err(map_err)?,
})
}
pub fn update(&mut self, a: f64, b: f64) -> Option<f64> {
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<Float64Array, JsError> {
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<WasmLeadLagCrossCorrelation, JsError> {
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<Float64Array, JsError> {
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<WasmCointegration, JsError> {
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<Float64Array, JsError> {
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<WasmRelativeStrengthAb, JsError> {
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<Float64Array, JsError> {
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)]