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
+10
View File
@@ -161,6 +161,11 @@ from ._wickra import (
HurstExponent,
PearsonCorrelation,
Beta,
PairwiseBeta,
PairSpreadZScore,
LeadLagCrossCorrelation,
Cointegration,
RelativeStrengthAB,
SpearmanCorrelation,
# Ehlers / Cycle
SuperSmoother,
@@ -393,6 +398,11 @@ __all__ = [
"HurstExponent",
"PearsonCorrelation",
"Beta",
"PairwiseBeta",
"PairSpreadZScore",
"LeadLagCrossCorrelation",
"Cointegration",
"RelativeStrengthAB",
"SpearmanCorrelation",
# Ehlers / Cycle
"SuperSmoother",
+382
View File
@@ -10751,6 +10751,383 @@ impl PyBeta {
}
}
// ============================== PairwiseBeta ==============================
#[pyclass(name = "PairwiseBeta", module = "wickra._wickra", skip_from_py_object)]
#[derive(Clone)]
struct PyPairwiseBeta {
inner: wc::PairwiseBeta,
}
#[pymethods]
impl PyPairwiseBeta {
#[new]
#[pyo3(signature = (period=20))]
fn new(period: usize) -> PyResult<Self> {
Ok(Self {
inner: wc::PairwiseBeta::new(period).map_err(map_err)?,
})
}
fn update(&mut self, a: f64, b: f64) -> Option<f64> {
self.inner.update((a, b))
}
/// Batch over two equally-sized numpy arrays of prices: `a` and `b`.
fn batch<'py>(
&mut self,
py: Python<'py>,
a: PyReadonlyArray1<'py, f64>,
b: PyReadonlyArray1<'py, f64>,
) -> PyResult<Bound<'py, PyArray1<f64>>> {
let xs = a
.as_slice()
.map_err(|_| PyValueError::new_err(NON_CONTIGUOUS))?;
let ys = b
.as_slice()
.map_err(|_| PyValueError::new_err(NON_CONTIGUOUS))?;
if xs.len() != ys.len() {
return Err(PyValueError::new_err("a and b must be equal length"));
}
let mut out = Vec::with_capacity(xs.len());
for i in 0..xs.len() {
out.push(self.inner.update((xs[i], ys[i])).unwrap_or(f64::NAN));
}
Ok(out.into_pyarray(py))
}
#[getter]
fn period(&self) -> usize {
self.inner.period()
}
fn reset(&mut self) {
self.inner.reset();
}
fn is_ready(&self) -> bool {
self.inner.is_ready()
}
fn warmup_period(&self) -> usize {
self.inner.warmup_period()
}
fn __repr__(&self) -> String {
format!("PairwiseBeta(period={})", self.inner.period())
}
}
// ============================== PairSpreadZScore ==============================
#[pyclass(
name = "PairSpreadZScore",
module = "wickra._wickra",
skip_from_py_object
)]
#[derive(Clone)]
struct PyPairSpreadZScore {
inner: wc::PairSpreadZScore,
}
#[pymethods]
impl PyPairSpreadZScore {
#[new]
#[pyo3(signature = (beta_period=20, z_period=20))]
fn new(beta_period: usize, z_period: usize) -> PyResult<Self> {
Ok(Self {
inner: wc::PairSpreadZScore::new(beta_period, z_period).map_err(map_err)?,
})
}
fn update(&mut self, a: f64, b: f64) -> Option<f64> {
self.inner.update((a, b))
}
/// Batch over two equally-sized numpy arrays of prices: `a` and `b`.
fn batch<'py>(
&mut self,
py: Python<'py>,
a: PyReadonlyArray1<'py, f64>,
b: PyReadonlyArray1<'py, f64>,
) -> PyResult<Bound<'py, PyArray1<f64>>> {
let xs = a
.as_slice()
.map_err(|_| PyValueError::new_err(NON_CONTIGUOUS))?;
let ys = b
.as_slice()
.map_err(|_| PyValueError::new_err(NON_CONTIGUOUS))?;
if xs.len() != ys.len() {
return Err(PyValueError::new_err("a and b must be equal length"));
}
let mut out = Vec::with_capacity(xs.len());
for i in 0..xs.len() {
out.push(self.inner.update((xs[i], ys[i])).unwrap_or(f64::NAN));
}
Ok(out.into_pyarray(py))
}
#[getter]
fn beta_period(&self) -> usize {
self.inner.beta_period()
}
#[getter]
fn z_period(&self) -> usize {
self.inner.z_period()
}
fn reset(&mut self) {
self.inner.reset();
}
fn is_ready(&self) -> bool {
self.inner.is_ready()
}
fn warmup_period(&self) -> usize {
self.inner.warmup_period()
}
fn __repr__(&self) -> String {
format!(
"PairSpreadZScore(beta_period={}, z_period={})",
self.inner.beta_period(),
self.inner.z_period()
)
}
}
// ============================== LeadLagCrossCorrelation ==============================
#[pyclass(
name = "LeadLagCrossCorrelation",
module = "wickra._wickra",
skip_from_py_object
)]
#[derive(Clone)]
struct PyLeadLagCrossCorrelation {
inner: wc::LeadLagCrossCorrelation,
}
#[pymethods]
impl PyLeadLagCrossCorrelation {
#[new]
#[pyo3(signature = (window=20, max_lag=10))]
fn new(window: usize, max_lag: usize) -> PyResult<Self> {
Ok(Self {
inner: wc::LeadLagCrossCorrelation::new(window, max_lag).map_err(map_err)?,
})
}
/// Returns `(lag, correlation)` or `None` during warmup. A positive lag
/// means `a` leads `b`.
fn update(&mut self, a: f64, b: f64) -> Option<(i64, f64)> {
self.inner.update((a, b)).map(|o| (o.lag, o.correlation))
}
/// Batch over two equally-sized numpy arrays. Returns a 2D array of shape
/// `(n, 2)` with columns `[lag, correlation]`. Warmup rows are NaN.
fn batch<'py>(
&mut self,
py: Python<'py>,
a: PyReadonlyArray1<'py, f64>,
b: PyReadonlyArray1<'py, f64>,
) -> PyResult<Bound<'py, PyArray2<f64>>> {
let xs = a
.as_slice()
.map_err(|_| PyValueError::new_err(NON_CONTIGUOUS))?;
let ys = b
.as_slice()
.map_err(|_| PyValueError::new_err(NON_CONTIGUOUS))?;
if xs.len() != ys.len() {
return Err(PyValueError::new_err("a and b must be equal length"));
}
let n = xs.len();
let mut out = vec![f64::NAN; n * 2];
for i in 0..n {
if let Some(o) = self.inner.update((xs[i], ys[i])) {
out[i * 2] = o.lag as f64;
out[i * 2 + 1] = o.correlation;
}
}
Ok(numpy::ndarray::Array2::from_shape_vec((n, 2), out)
.expect("shape consistent")
.into_pyarray(py))
}
#[getter]
fn window(&self) -> usize {
self.inner.window()
}
#[getter]
fn max_lag(&self) -> usize {
self.inner.max_lag()
}
fn reset(&mut self) {
self.inner.reset();
}
fn is_ready(&self) -> bool {
self.inner.is_ready()
}
fn warmup_period(&self) -> usize {
self.inner.warmup_period()
}
fn __repr__(&self) -> String {
format!(
"LeadLagCrossCorrelation(window={}, max_lag={})",
self.inner.window(),
self.inner.max_lag()
)
}
}
// ============================== Cointegration ==============================
#[pyclass(name = "Cointegration", module = "wickra._wickra", skip_from_py_object)]
#[derive(Clone)]
struct PyCointegration {
inner: wc::Cointegration,
}
#[pymethods]
impl PyCointegration {
#[new]
#[pyo3(signature = (period=30, adf_lags=1))]
fn new(period: usize, adf_lags: usize) -> PyResult<Self> {
Ok(Self {
inner: wc::Cointegration::new(period, adf_lags).map_err(map_err)?,
})
}
/// Returns `(hedge_ratio, spread, adf_stat)` or `None` during warmup.
fn update(&mut self, a: f64, b: f64) -> Option<(f64, f64, f64)> {
self.inner
.update((a, b))
.map(|o| (o.hedge_ratio, o.spread, o.adf_stat))
}
/// Batch over two equally-sized numpy arrays. Returns a 2D array of shape
/// `(n, 3)` with columns `[hedge_ratio, spread, adf_stat]`. Warmup rows are
/// NaN.
fn batch<'py>(
&mut self,
py: Python<'py>,
a: PyReadonlyArray1<'py, f64>,
b: PyReadonlyArray1<'py, f64>,
) -> PyResult<Bound<'py, PyArray2<f64>>> {
let xs = a
.as_slice()
.map_err(|_| PyValueError::new_err(NON_CONTIGUOUS))?;
let ys = b
.as_slice()
.map_err(|_| PyValueError::new_err(NON_CONTIGUOUS))?;
if xs.len() != ys.len() {
return Err(PyValueError::new_err("a and b must be equal length"));
}
let n = xs.len();
let mut out = vec![f64::NAN; n * 3];
for i in 0..n {
if let Some(o) = self.inner.update((xs[i], ys[i])) {
out[i * 3] = o.hedge_ratio;
out[i * 3 + 1] = o.spread;
out[i * 3 + 2] = o.adf_stat;
}
}
Ok(numpy::ndarray::Array2::from_shape_vec((n, 3), out)
.expect("shape consistent")
.into_pyarray(py))
}
#[getter]
fn period(&self) -> usize {
self.inner.period()
}
#[getter]
fn adf_lags(&self) -> usize {
self.inner.adf_lags()
}
fn reset(&mut self) {
self.inner.reset();
}
fn is_ready(&self) -> bool {
self.inner.is_ready()
}
fn warmup_period(&self) -> usize {
self.inner.warmup_period()
}
fn __repr__(&self) -> String {
format!(
"Cointegration(period={}, adf_lags={})",
self.inner.period(),
self.inner.adf_lags()
)
}
}
// ============================== RelativeStrengthAB ==============================
#[pyclass(
name = "RelativeStrengthAB",
module = "wickra._wickra",
skip_from_py_object
)]
#[derive(Clone)]
struct PyRelativeStrengthAB {
inner: wc::RelativeStrengthAB,
}
#[pymethods]
impl PyRelativeStrengthAB {
#[new]
#[pyo3(signature = (ma_period=20, rsi_period=14))]
fn new(ma_period: usize, rsi_period: usize) -> PyResult<Self> {
Ok(Self {
inner: wc::RelativeStrengthAB::new(ma_period, rsi_period).map_err(map_err)?,
})
}
/// Returns `(ratio, ratio_ma, ratio_rsi)` or `None` during warmup.
fn update(&mut self, a: f64, b: f64) -> Option<(f64, f64, f64)> {
self.inner
.update((a, b))
.map(|o| (o.ratio, o.ratio_ma, o.ratio_rsi))
}
/// Batch over two equally-sized numpy arrays. Returns a 2D array of shape
/// `(n, 3)` with columns `[ratio, ratio_ma, ratio_rsi]`. Warmup rows are
/// NaN.
fn batch<'py>(
&mut self,
py: Python<'py>,
a: PyReadonlyArray1<'py, f64>,
b: PyReadonlyArray1<'py, f64>,
) -> PyResult<Bound<'py, PyArray2<f64>>> {
let xs = a
.as_slice()
.map_err(|_| PyValueError::new_err(NON_CONTIGUOUS))?;
let ys = b
.as_slice()
.map_err(|_| PyValueError::new_err(NON_CONTIGUOUS))?;
if xs.len() != ys.len() {
return Err(PyValueError::new_err("a and b must be equal length"));
}
let n = xs.len();
let mut out = vec![f64::NAN; n * 3];
for i in 0..n {
if let Some(o) = self.inner.update((xs[i], ys[i])) {
out[i * 3] = o.ratio;
out[i * 3 + 1] = o.ratio_ma;
out[i * 3 + 2] = o.ratio_rsi;
}
}
Ok(numpy::ndarray::Array2::from_shape_vec((n, 3), out)
.expect("shape consistent")
.into_pyarray(py))
}
#[getter]
fn ma_period(&self) -> usize {
self.inner.ma_period()
}
#[getter]
fn rsi_period(&self) -> usize {
self.inner.rsi_period()
}
fn reset(&mut self) {
self.inner.reset();
}
fn is_ready(&self) -> bool {
self.inner.is_ready()
}
fn warmup_period(&self) -> usize {
self.inner.warmup_period()
}
fn __repr__(&self) -> String {
format!(
"RelativeStrengthAB(ma_period={}, rsi_period={})",
self.inner.ma_period(),
self.inner.rsi_period()
)
}
}
// ============================== SpearmanCorrelation ==============================
#[pyclass(
@@ -12236,6 +12613,11 @@ fn _wickra(_py: Python<'_>, m: &Bound<'_, PyModule>) -> PyResult<()> {
m.add_class::<PyHurstExponent>()?;
m.add_class::<PyPearsonCorrelation>()?;
m.add_class::<PyBeta>()?;
m.add_class::<PyPairwiseBeta>()?;
m.add_class::<PyPairSpreadZScore>()?;
m.add_class::<PyLeadLagCrossCorrelation>()?;
m.add_class::<PyCointegration>()?;
m.add_class::<PyRelativeStrengthAB>()?;
m.add_class::<PySpearmanCorrelation>()?;
m.add_class::<PyValueArea>()?;
m.add_class::<PyInitialBalance>()?;
@@ -35,6 +35,72 @@ def test_unequal_length_candle_batch_raises(ohlc_series):
ta.Aroon(14).batch(high, short)
def test_pairwise_beta_rejects_bad_period():
with pytest.raises(ValueError):
ta.PairwiseBeta(0)
with pytest.raises(ValueError):
ta.PairwiseBeta(1)
def test_unequal_length_pair_batch_raises(sine_prices):
a = np.ascontiguousarray((sine_prices + 100.0).astype(np.float64))
b = a[:-1]
with pytest.raises(ValueError):
ta.PairwiseBeta(20).batch(a, b)
with pytest.raises(ValueError):
ta.PairSpreadZScore(20, 20).batch(a, b)
def test_pair_spread_zscore_rejects_bad_periods():
with pytest.raises(ValueError):
ta.PairSpreadZScore(1, 20)
with pytest.raises(ValueError):
ta.PairSpreadZScore(20, 1)
def test_lead_lag_rejects_bad_params():
with pytest.raises(ValueError):
ta.LeadLagCrossCorrelation(1, 5)
with pytest.raises(ValueError):
ta.LeadLagCrossCorrelation(10, 0)
def test_lead_lag_unequal_length_batch_raises(sine_prices):
a = np.ascontiguousarray((sine_prices + 100.0).astype(np.float64))
b = a[:-1]
with pytest.raises(ValueError):
ta.LeadLagCrossCorrelation(12, 5).batch(a, b)
def test_cointegration_rejects_too_small_period():
# period must be >= 2*adf_lags + 4.
with pytest.raises(ValueError):
ta.Cointegration(3, 0)
with pytest.raises(ValueError):
ta.Cointegration(5, 1)
def test_cointegration_unequal_length_batch_raises(sine_prices):
a = np.ascontiguousarray((sine_prices + 100.0).astype(np.float64))
b = a[:-1]
with pytest.raises(ValueError):
ta.Cointegration(20, 1).batch(a, b)
def test_relative_strength_rejects_zero_periods():
with pytest.raises(ValueError):
ta.RelativeStrengthAB(0, 14)
with pytest.raises(ValueError):
ta.RelativeStrengthAB(20, 0)
def test_relative_strength_unequal_length_batch_raises(sine_prices):
a = np.ascontiguousarray((sine_prices + 100.0).astype(np.float64))
b = a[:-1]
with pytest.raises(ValueError):
ta.RelativeStrengthAB(10, 14).batch(a, b)
def test_roc_and_trix_have_default_periods():
# ROC/TRIX gained constructor defaults matching the TA-Lib convention.
assert ta.ROC().period == 10
@@ -429,6 +429,67 @@ def test_information_ratio_known_window():
assert math.isclose(out[-1], expected, rel_tol=1e-9)
def test_pairwise_beta_squared_price_is_two():
# a = b² ⇒ a's log-returns are exactly 2× b's ⇒ pairwise beta = 2.
# b must have *varying* returns (a constant-return path has zero variance
# and an undefined slope, which the indicator reports as 0).
b = np.array([100.0 + 10.0 * math.sin(i * 0.5) for i in range(20)])
a = b**2
out = ta.PairwiseBeta(5).batch(a, b)
assert math.isclose(out[-1], 2.0, rel_tol=1e-9)
def test_pairwise_beta_inverse_price_is_minus_one():
# a = 1/b ⇒ a's log-returns are 1× b's ⇒ pairwise beta = 1.
b = np.array([100.0 + 10.0 * math.sin(i * 0.5) for i in range(20)])
a = 1.0 / b
out = ta.PairwiseBeta(5).batch(a, b)
assert math.isclose(out[-1], -1.0, rel_tol=1e-9)
def test_pair_spread_zscore_flat_benchmark_sign():
# Flat b ⇒ hedge ratio 0 ⇒ spread = ln(a). With z_period = 2 the z-score
# collapses to the sign of the last move: rising a ⇒ +1, falling a ⇒ 1.
a = np.array([100.0, 100.0, 110.0, 105.0, 130.0])
b = np.full_like(a, 100.0)
out = ta.PairSpreadZScore(2, 2).batch(a, b)
assert math.isclose(out[-1], 1.0, abs_tol=1e-9)
assert math.isclose(out[-2], -1.0, abs_tol=1e-9)
def test_lead_lag_cross_correlation_negative_lead():
# a is a delayed copy of b ⇒ b leads a ⇒ lag = 2, correlation ≈ 1.
def sig(t):
return math.sin(t * 0.4) + 0.4 * math.sin(t * 1.1) + 0.2 * math.cos(t * 0.27)
n = 60
a = np.array([sig(t - 2) for t in range(n)])
b = np.array([sig(t) for t in range(n)])
out = ta.LeadLagCrossCorrelation(12, 5).batch(a, b)
assert int(out[-1, 0]) == -2
assert out[-1, 1] > 0.99
def test_cointegration_perfect_pair():
# a = 2*b + 5 exactly ⇒ hedge ratio 2, zero spread, degenerate ADF ⇒ 0.
b = np.array([100.0 + t for t in range(40)])
a = 2.0 * b + 5.0
out = ta.Cointegration(20, 1).batch(a, b)
assert math.isclose(out[-1, 0], 2.0, rel_tol=1e-9)
assert math.isclose(out[-1, 1], 0.0, abs_tol=1e-6)
assert math.isclose(out[-1, 2], 0.0, abs_tol=1e-12)
def test_relative_strength_rising_ratio_is_overbought():
# a rises while b is flat ⇒ ratio strictly increases ⇒ RSI saturates at 100.
n = 20
a = np.array([100.0 + 2.0 * t for t in range(n)])
b = np.full(n, 100.0)
out = ta.RelativeStrengthAB(5, 5).batch(a, b)
assert out[-1, 0] > 1.0
assert math.isclose(out[-1, 2], 100.0, abs_tol=1e-9)
def test_value_at_risk_known_window():
# returns -5..4 *0.01; q=0.05*9=0.45 -> -0.0455; VaR = 0.0455.
returns = np.array([i * 0.01 for i in range(-5, 5)])
@@ -159,6 +159,8 @@ PAIR = [
(ta.TreynorRatio, (20, 0.0)),
(ta.InformationRatio, (20,)),
(ta.Alpha, (20, 0.0)),
(ta.PairwiseBeta, (20,)),
(ta.PairSpreadZScore, (20, 20)),
]
@@ -178,6 +180,95 @@ def test_pair_streaming_matches_batch(cls, args, sine_prices):
assert _eq_nan(batch, np.array(streamed, dtype=np.float64))
def _ll_signal(t):
return math.sin(t * 0.4) + 0.4 * math.sin(t * 1.1) + 0.2 * math.cos(t * 0.27)
def test_lead_lag_detects_lead():
n = 60
a = np.array([_ll_signal(t) for t in range(n)])
# b is a delayed by 3 ⇒ a leads b ⇒ lag = +3, correlation ≈ 1.
b = np.array([_ll_signal(t - 3) for t in range(n)])
out = ta.LeadLagCrossCorrelation(12, 5).batch(a, b)
assert out.shape == (n, 2)
assert int(out[-1, 0]) == 3
assert out[-1, 1] > 0.99
def test_lead_lag_streaming_matches_batch():
n = 60
a = np.array([_ll_signal(t) for t in range(n)])
b = np.array([_ll_signal(t - 2) for t in range(n)])
ind = ta.LeadLagCrossCorrelation(12, 5)
batch = ind.batch(a, b)
streamer = ta.LeadLagCrossCorrelation(12, 5)
for i in range(n):
v = streamer.update(float(a[i]), float(b[i]))
if v is None:
assert math.isnan(batch[i, 0]) and math.isnan(batch[i, 1])
else:
lag, corr = v
assert int(batch[i, 0]) == lag
assert math.isclose(batch[i, 1], corr, rel_tol=1e-12, abs_tol=1e-12)
def test_cointegration_detects_mean_reverting_pair():
n = 80
b = np.array([50.0 + 0.5 * t for t in range(n)])
# a tracks 2*b with a small mean-reverting wobble ⇒ cointegrated.
a = 2.0 * b + 1.0 + 0.5 * np.sin(np.arange(n) * 0.6)
out = ta.Cointegration(40, 1).batch(a, b)
assert out.shape == (n, 3)
assert abs(out[-1, 0] - 2.0) < 0.1 # hedge ratio
assert out[-1, 2] < -2.0 # ADF statistic: strongly mean-reverting
def test_cointegration_streaming_matches_batch():
n = 70
b = np.array([30.0 + 0.7 * t for t in range(n)])
a = 1.8 * b + 2.0 + 0.5 * np.sin(np.arange(n) * 0.4)
batch = ta.Cointegration(25, 2).batch(a, b)
streamer = ta.Cointegration(25, 2)
for i in range(n):
v = streamer.update(float(a[i]), float(b[i]))
if v is None:
assert np.all(np.isnan(batch[i]))
else:
hr, sp, adf = v
assert math.isclose(batch[i, 0], hr, rel_tol=1e-12, abs_tol=1e-12)
assert math.isclose(batch[i, 1], sp, rel_tol=1e-12, abs_tol=1e-12)
assert math.isclose(batch[i, 2], adf, rel_tol=1e-12, abs_tol=1e-12)
def test_relative_strength_constant_ratio():
n = 30
a = np.full(n, 200.0)
b = np.full(n, 100.0) # ratio is a constant 2
out = ta.RelativeStrengthAB(5, 5).batch(a, b)
assert out.shape == (n, 3)
assert math.isclose(out[-1, 0], 2.0, abs_tol=1e-12) # ratio
assert math.isclose(out[-1, 1], 2.0, abs_tol=1e-12) # ratio MA
assert math.isclose(out[-1, 2], 50.0, abs_tol=1e-9) # flat ratio ⇒ RSI 50
def test_relative_strength_streaming_matches_batch():
n = 60
tt = np.arange(n)
a = 100.0 + 5.0 * np.sin(tt * 0.3)
b = 100.0 + 2.0 * np.cos(tt * 0.2)
batch = ta.RelativeStrengthAB(10, 14).batch(a, b)
streamer = ta.RelativeStrengthAB(10, 14)
for i in range(n):
v = streamer.update(float(a[i]), float(b[i]))
if v is None:
assert np.all(np.isnan(batch[i]))
else:
ratio, ma, rsi = v
assert math.isclose(batch[i, 0], ratio, rel_tol=1e-12, abs_tol=1e-12)
assert math.isclose(batch[i, 1], ma, rel_tol=1e-12, abs_tol=1e-12)
assert math.isclose(batch[i, 2], rsi, rel_tol=1e-12, abs_tol=1e-12)
# --- Candle-input, single-output indicators -------------------------------
#
# Each entry is (factory, batch-call). Streaming always feeds the full