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
@@ -0,0 +1,446 @@
//! Cointegration — rolling EngleGranger 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 {
/// EngleGranger 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 DickeyFuller `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 (EngleGranger 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 DickeyFuller** 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 DickeyFuller regression (`0` is the plain
/// DickeyFuller 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<Self> {
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<CointegrationOutput> {
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<f64> = 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<Vec<f64>>, mut rhs: Vec<f64>) -> Option<Vec<f64>> {
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 DickeyFuller `t`-statistic on `series`, with `lags` lagged
/// differences and **no** constant or trend term (the EngleGranger 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<f64> {
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<f64> = (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);
}
}
@@ -0,0 +1,324 @@
//! LeadLag 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 leadlag 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; leadlag 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<f64>,
b_buf: VecDeque<f64>,
}
impl LeadLagCrossCorrelation {
/// Construct a new leadlag 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<Self> {
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<LeadLagCrossCorrelationOutput> {
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);
}
}
+10
View File
@@ -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;
@@ -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<f64>,
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<Self> {
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<f64> {
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<f64> {
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<f64> {
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);
}
}
@@ -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<Self> {
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<f64> {
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<f64> {
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);
}
}
@@ -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<Self> {
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<RelativeStrengthOutput> {
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);
}
}