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
@@ -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 ------------------------------------------------------------