Add 10 pairwise stat-arb indicators to Price Statistics (#154)

Adds ten pairwise `(f64, f64)` indicators to the **Price Statistics** family, completing the A1 stat-arb expansion block.

## Indicators

**Scalar output:**
- **RollingCorrelation** — rolling Pearson correlation of period-over-period *returns* (distinct from level-based `PearsonCorrelation`).
- **RollingCovariance** — rolling covariance of returns.
- **OuHalfLife** — Ornstein–Uhlenbeck half-life of mean reversion of the spread `a − b`.
- **SpreadHurst** — Hurst exponent of the spread (variance-of-lagged-differences fit) for regime detection.
- **DistanceSsd** — Gatev sum-of-squared-deviations between two start-normalised series.
- **BetaNeutralSpread** — rolling OLS regression residual `a − (α + β·b)`.
- **VarianceRatio** — Lo–MacKinlay variance-ratio test on the spread (two params: `period`, `q`).
- **GrangerCausality** — F-statistic for whether `b` predicts `a` (two params: `period`, `lag`).

**Struct output (custom bindings):**
- **KalmanHedgeRatio** — dynamic hedge ratio via a Kalman filter → `{ hedgeRatio, intercept, spread }`.
- **SpreadBollingerBands** — Bollinger bands on the spread → `{ middle, upper, lower, percentB }`.

## Notes
- No new traits or input families: all use the native `Indicator<Input = (f64, f64)>` (precedent `Beta`, `Cointegration`).
- Adds `Error::InvalidParameter` for floating-point constructor parameters (Kalman `delta`/`observation_var`, `num_std`).
- Full Python/Node/WASM bindings; the two struct-output indicators are hand-written, the rest use the pair macros.
- Indicator count 315 → 325; README, family rows, `__init__`, fuzz target, and CHANGELOG updated.

## Verification
- `cargo test --workspace --all-features` — green (2676 core lib + 308 doc).
- `cargo clippy --workspace --all-targets --all-features -- -D warnings` — clean.
- Node: `npm run build && npm test` — 410 passing (`index.d.ts`/`index.js` regenerated).
- Python: `pytest` — 684 passing.
This commit is contained in:
kingchenc
2026-06-03 15:39:55 +02:00
committed by GitHub
parent 53941b7b07
commit a3a1ae4dba
25 changed files with 4313 additions and 51 deletions
+20
View File
@@ -159,6 +159,16 @@ from ._wickra import (
MarketFacilitationIndex,
EaseOfMovement,
# Statistics
SpreadBollingerBands,
KalmanHedgeRatio,
GrangerCausality,
VarianceRatio,
BetaNeutralSpread,
DistanceSsd,
SpreadHurst,
OuHalfLife,
RollingCovariance,
RollingCorrelation,
TypicalPrice,
MedianPrice,
WeightedClose,
@@ -499,6 +509,16 @@ __all__ = [
"MarketFacilitationIndex",
"EaseOfMovement",
# Statistics
"SpreadBollingerBands",
"KalmanHedgeRatio",
"GrangerCausality",
"VarianceRatio",
"BetaNeutralSpread",
"DistanceSsd",
"SpreadHurst",
"OuHalfLife",
"RollingCovariance",
"RollingCorrelation",
"TypicalPrice",
"MedianPrice",
"WeightedClose",
+693 -1
View File
@@ -30,7 +30,8 @@ fn map_err(e: wc::Error) -> PyErr {
| wc::Error::InvalidOrderBook { .. }
| wc::Error::InvalidTrade { .. }
| wc::Error::InvalidDerivatives { .. }
| wc::Error::InvalidCrossSection { .. } => PyValueError::new_err(e.to_string()),
| wc::Error::InvalidCrossSection { .. }
| wc::Error::InvalidParameter { .. } => PyValueError::new_err(e.to_string()),
}
}
@@ -12250,6 +12251,687 @@ impl PyRelativeStrengthAB {
}
}
// ============================== RollingCorrelation ==============================
#[pyclass(
name = "RollingCorrelation",
module = "wickra._wickra",
skip_from_py_object
)]
#[derive(Clone)]
struct PyRollingCorrelation {
inner: wc::RollingCorrelation,
}
#[pymethods]
impl PyRollingCorrelation {
#[new]
#[pyo3(signature = (period=20))]
fn new(period: usize) -> PyResult<Self> {
Ok(Self {
inner: wc::RollingCorrelation::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: `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!("RollingCorrelation(period={})", self.inner.period())
}
}
// ============================== RollingCovariance ==============================
#[pyclass(
name = "RollingCovariance",
module = "wickra._wickra",
skip_from_py_object
)]
#[derive(Clone)]
struct PyRollingCovariance {
inner: wc::RollingCovariance,
}
#[pymethods]
impl PyRollingCovariance {
#[new]
#[pyo3(signature = (period=20))]
fn new(period: usize) -> PyResult<Self> {
Ok(Self {
inner: wc::RollingCovariance::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: `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!("RollingCovariance(period={})", self.inner.period())
}
}
// ============================== OuHalfLife ==============================
#[pyclass(name = "OuHalfLife", module = "wickra._wickra", skip_from_py_object)]
#[derive(Clone)]
struct PyOuHalfLife {
inner: wc::OuHalfLife,
}
#[pymethods]
impl PyOuHalfLife {
#[new]
#[pyo3(signature = (period=60))]
fn new(period: usize) -> PyResult<Self> {
Ok(Self {
inner: wc::OuHalfLife::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: `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!("OuHalfLife(period={})", self.inner.period())
}
}
// ============================== SpreadHurst ==============================
#[pyclass(name = "SpreadHurst", module = "wickra._wickra", skip_from_py_object)]
#[derive(Clone)]
struct PySpreadHurst {
inner: wc::SpreadHurst,
}
#[pymethods]
impl PySpreadHurst {
#[new]
#[pyo3(signature = (period=60))]
fn new(period: usize) -> PyResult<Self> {
Ok(Self {
inner: wc::SpreadHurst::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: `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!("SpreadHurst(period={})", self.inner.period())
}
}
// ============================== DistanceSsd ==============================
#[pyclass(name = "DistanceSsd", module = "wickra._wickra", skip_from_py_object)]
#[derive(Clone)]
struct PyDistanceSsd {
inner: wc::DistanceSsd,
}
#[pymethods]
impl PyDistanceSsd {
#[new]
#[pyo3(signature = (period=20))]
fn new(period: usize) -> PyResult<Self> {
Ok(Self {
inner: wc::DistanceSsd::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: `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!("DistanceSsd(period={})", self.inner.period())
}
}
// ============================== BetaNeutralSpread ==============================
#[pyclass(
name = "BetaNeutralSpread",
module = "wickra._wickra",
skip_from_py_object
)]
#[derive(Clone)]
struct PyBetaNeutralSpread {
inner: wc::BetaNeutralSpread,
}
#[pymethods]
impl PyBetaNeutralSpread {
#[new]
#[pyo3(signature = (period=20))]
fn new(period: usize) -> PyResult<Self> {
Ok(Self {
inner: wc::BetaNeutralSpread::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: `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!("BetaNeutralSpread(period={})", self.inner.period())
}
}
// ============================== VarianceRatio ==============================
#[pyclass(name = "VarianceRatio", module = "wickra._wickra", skip_from_py_object)]
#[derive(Clone)]
struct PyVarianceRatio {
inner: wc::VarianceRatio,
}
#[pymethods]
impl PyVarianceRatio {
#[new]
#[pyo3(signature = (period=60, q=2))]
fn new(period: usize, q: usize) -> PyResult<Self> {
Ok(Self {
inner: wc::VarianceRatio::new(period, q).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: `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()
}
#[getter]
fn q(&self) -> usize {
self.inner.q()
}
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!(
"VarianceRatio(period={}, q={})",
self.inner.period(),
self.inner.q()
)
}
}
// ============================== GrangerCausality ==============================
#[pyclass(
name = "GrangerCausality",
module = "wickra._wickra",
skip_from_py_object
)]
#[derive(Clone)]
struct PyGrangerCausality {
inner: wc::GrangerCausality,
}
#[pymethods]
impl PyGrangerCausality {
#[new]
#[pyo3(signature = (period=60, lag=1))]
fn new(period: usize, lag: usize) -> PyResult<Self> {
Ok(Self {
inner: wc::GrangerCausality::new(period, lag).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: `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()
}
#[getter]
fn lag(&self) -> usize {
self.inner.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!(
"GrangerCausality(period={}, lag={})",
self.inner.period(),
self.inner.lag()
)
}
}
// ============================== KalmanHedgeRatio ==============================
#[pyclass(
name = "KalmanHedgeRatio",
module = "wickra._wickra",
skip_from_py_object
)]
#[derive(Clone)]
struct PyKalmanHedgeRatio {
inner: wc::KalmanHedgeRatio,
}
#[pymethods]
impl PyKalmanHedgeRatio {
#[new]
#[pyo3(signature = (delta=1e-4, observation_var=1e-3))]
fn new(delta: f64, observation_var: f64) -> PyResult<Self> {
Ok(Self {
inner: wc::KalmanHedgeRatio::new(delta, observation_var).map_err(map_err)?,
})
}
/// Returns `(hedge_ratio, intercept, spread)` 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.intercept, o.spread))
}
/// Batch over two equally-sized numpy arrays. Returns a 2D array of shape
/// `(n, 3)` with columns `[hedge_ratio, intercept, spread]`. 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.intercept;
out[i * 3 + 2] = o.spread;
}
}
Ok(numpy::ndarray::Array2::from_shape_vec((n, 3), out)
.expect("shape consistent")
.into_pyarray(py))
}
#[getter]
fn delta(&self) -> f64 {
self.inner.delta()
}
#[getter]
fn observation_var(&self) -> f64 {
self.inner.observation_var()
}
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!(
"KalmanHedgeRatio(delta={}, observation_var={})",
self.inner.delta(),
self.inner.observation_var()
)
}
}
// ============================== SpreadBollingerBands ==============================
#[pyclass(
name = "SpreadBollingerBands",
module = "wickra._wickra",
skip_from_py_object
)]
#[derive(Clone)]
struct PySpreadBollingerBands {
inner: wc::SpreadBollingerBands,
}
#[pymethods]
impl PySpreadBollingerBands {
#[new]
#[pyo3(signature = (period=20, num_std=2.0))]
fn new(period: usize, num_std: f64) -> PyResult<Self> {
Ok(Self {
inner: wc::SpreadBollingerBands::new(period, num_std).map_err(map_err)?,
})
}
/// Returns `(middle, upper, lower, percent_b)` or `None` during warmup.
fn update(&mut self, a: f64, b: f64) -> Option<(f64, f64, f64, f64)> {
self.inner
.update((a, b))
.map(|o| (o.middle, o.upper, o.lower, o.percent_b))
}
/// Batch over two equally-sized numpy arrays. Returns a 2D array of shape
/// `(n, 4)` with columns `[middle, upper, lower, percent_b]`. 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 * 4];
for i in 0..n {
if let Some(o) = self.inner.update((xs[i], ys[i])) {
out[i * 4] = o.middle;
out[i * 4 + 1] = o.upper;
out[i * 4 + 2] = o.lower;
out[i * 4 + 3] = o.percent_b;
}
}
Ok(numpy::ndarray::Array2::from_shape_vec((n, 4), out)
.expect("shape consistent")
.into_pyarray(py))
}
#[getter]
fn period(&self) -> usize {
self.inner.period()
}
#[getter]
fn num_std(&self) -> f64 {
self.inner.num_std()
}
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!(
"SpreadBollingerBands(period={}, num_std={})",
self.inner.period(),
self.inner.num_std()
)
}
}
// ============================== SpearmanCorrelation ==============================
#[pyclass(
@@ -15772,6 +16454,16 @@ fn _wickra(_py: Python<'_>, m: &Bound<'_, PyModule>) -> PyResult<()> {
m.add_class::<PyLeadLagCrossCorrelation>()?;
m.add_class::<PyCointegration>()?;
m.add_class::<PyRelativeStrengthAB>()?;
m.add_class::<PyRollingCorrelation>()?;
m.add_class::<PyRollingCovariance>()?;
m.add_class::<PyOuHalfLife>()?;
m.add_class::<PySpreadHurst>()?;
m.add_class::<PyDistanceSsd>()?;
m.add_class::<PyBetaNeutralSpread>()?;
m.add_class::<PyVarianceRatio>()?;
m.add_class::<PyGrangerCausality>()?;
m.add_class::<PyKalmanHedgeRatio>()?;
m.add_class::<PySpreadBollingerBands>()?;
m.add_class::<PySpearmanCorrelation>()?;
m.add_class::<PyValueArea>()?;
m.add_class::<PyVolumeProfile>()?;
@@ -167,6 +167,14 @@ def test_scalar_streaming_matches_batch(cls, args, sine_prices):
# --- Two-series (asset, benchmark) indicators -----------------------------
PAIR = [
(ta.GrangerCausality, (60, 1)),
(ta.VarianceRatio, (60, 2)),
(ta.BetaNeutralSpread, (20,)),
(ta.DistanceSsd, (20,)),
(ta.SpreadHurst, (60,)),
(ta.OuHalfLife, (60,)),
(ta.RollingCovariance, (20,)),
(ta.RollingCorrelation, (20,)),
(ta.TreynorRatio, (20, 0.0)),
(ta.InformationRatio, (20,)),
(ta.Alpha, (20, 0.0)),
@@ -251,6 +259,42 @@ def test_cointegration_streaming_matches_batch():
assert math.isclose(batch[i, 2], adf, rel_tol=1e-12, abs_tol=1e-12)
def test_kalman_hedge_ratio_converges_and_streaming_matches_batch():
n = 500
b = np.array([100.0 + 95.0 * math.sin(t * 0.5) for t in range(n)])
a = 2.0 * b + 5.0 # a = 2*b + 5 with a wide-ranging b ⇒ identifiable
batch = ta.KalmanHedgeRatio(1e-2, 1e-3).batch(a, b)
assert batch.shape == (n, 3)
assert abs(batch[-1, 0] - 2.0) < 0.05 # hedge ratio
assert abs(batch[-1, 2]) < 0.05 # spread (forecast error)
streamer = ta.KalmanHedgeRatio(1e-2, 1e-3)
for i in range(n):
hr, ic, sp = streamer.update(float(a[i]), float(b[i]))
assert math.isclose(batch[i, 0], hr, rel_tol=1e-12, abs_tol=1e-12)
assert math.isclose(batch[i, 1], ic, rel_tol=1e-12, abs_tol=1e-12)
assert math.isclose(batch[i, 2], sp, rel_tol=1e-12, abs_tol=1e-12)
def test_spread_bollinger_bands_streaming_matches_batch():
n = 60
b = np.array([100.0 + t for t in range(n)])
a = b + 3.0 * np.sin(np.arange(n) * 0.4)
batch = ta.SpreadBollingerBands(20, 2.0).batch(a, b)
assert batch.shape == (n, 4)
streamer = ta.SpreadBollingerBands(20, 2.0)
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:
mid, up, lo, pct_b = v
assert math.isclose(batch[i, 0], mid, rel_tol=1e-12, abs_tol=1e-12)
assert math.isclose(batch[i, 1], up, rel_tol=1e-12, abs_tol=1e-12)
assert math.isclose(batch[i, 2], lo, rel_tol=1e-12, abs_tol=1e-12)
assert math.isclose(batch[i, 3], pct_b, rel_tol=1e-12, abs_tol=1e-12)
assert lo <= mid <= up
def test_relative_strength_constant_ratio():
n = 30
a = np.full(n, 200.0)
@@ -2262,6 +2306,54 @@ def test_concealing_baby_swallow_reference():
assert t.update((11.0, 13.0, 9.9, 10.0, 1.0, 2)) == pytest.approx(0.0)
assert t.update((14.0, 14.1, 8.9, 9.0, 1.0, 3)) == pytest.approx(1.0)
def test_rolling_correlation_reference():
t = ta.RollingCorrelation(20)
assert t.update(1.0, 1.0) is None
assert t.update(2.0, 1.5) is None
def test_rolling_covariance_reference():
t = ta.RollingCovariance(20)
assert t.update(1.0, 1.0) is None
assert t.update(2.0, 1.5) is None
def test_ou_half_life_reference():
t = ta.OuHalfLife(60)
assert t.update(1.0, 1.0) is None
assert t.update(2.0, 1.5) is None
def test_spread_hurst_reference():
t = ta.SpreadHurst(60)
assert t.update(1.0, 1.0) is None
assert t.update(2.0, 1.5) is None
def test_distance_ssd_reference():
t = ta.DistanceSsd(20)
assert t.update(1.0, 1.0) is None
assert t.update(2.0, 1.5) is None
def test_beta_neutral_spread_reference():
t = ta.BetaNeutralSpread(20)
assert t.update(1.0, 1.0) is None
assert t.update(2.0, 1.5) is None
def test_variance_ratio_reference():
t = ta.VarianceRatio(60, 2)
assert t.update(1.0, 1.0) is None
assert t.update(2.0, 1.5) is None
def test_granger_causality_reference():
t = ta.GrangerCausality(60, 1)
assert t.update(1.0, 1.0) is None
assert t.update(2.0, 1.5) is None
# --- Lifecycle ------------------------------------------------------------