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
+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.