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:
@@ -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)]
|
||||
|
||||
Reference in New Issue
Block a user