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