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:
@@ -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 {
|
||||
/// 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<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.
|
||||
|
||||
Reference in New Issue
Block a user