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:
@@ -0,0 +1,446 @@
|
||||
//! Cointegration — rolling Engle–Granger 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 {
|
||||
/// Engle–Granger 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 Dickey–Fuller `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 (Engle–Granger 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 Dickey–Fuller** 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 Dickey–Fuller regression (`0` is the plain
|
||||
/// Dickey–Fuller 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 Dickey–Fuller `t`-statistic on `series`, with `lags` lagged
|
||||
/// differences and **no** constant or trend term (the Engle–Granger 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 @@
|
||||
//! Lead–Lag 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 lead–lag 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; lead–lag 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 lead–lag 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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user