feat(family-15): add 17 risk/performance metrics (#54)

* feat(family-15): add 17 risk/performance metrics

Implements Family 15 pragmatically as standard `Indicator`s instead of a
separate `wickra-metrics` crate. Input is scalar `f64` per bar — period
return, equity sample, or per-trade P&L depending on the metric.

Scalar `Indicator<f64>` (14):
- SharpeRatio(period, risk_free)
- SortinoRatio(period, mar)
- CalmarRatio(period)
- OmegaRatio(period, threshold)
- MaxDrawdown(period)          — rolling, peak-to-trough
- AverageDrawdown(period)
- DrawdownDuration             — cumulative, bars under water (u32 output)
- PainIndex(period)
- ValueAtRisk(period, confidence)
- ConditionalValueAtRisk(period, confidence)
- ProfitFactor(period)
- GainLossRatio(period)
- RecoveryFactor               — cumulative, net return / max drawdown
- KellyCriterion(period)

Two-series `Indicator<(f64, f64)>` for (asset, benchmark) returns (3):
- TreynorRatio(period, risk_free)
- InformationRatio(period)
- Alpha(period, risk_free)     — Jensen / CAPM

Touchpoints:
- 17 new files under `crates/wickra-core/src/indicators/`.
- `mod.rs` + `lib.rs` re-exports.
- Python bindings (`bindings/python/src/lib.rs`, `__init__.py`).
- Node bindings (`bindings/node/src/lib.rs`, `index.js`).
- WASM bindings (`bindings/wasm/src/lib.rs`).
- Fuzz: scalar metrics appended to `indicator_update.rs`; new
  `indicator_update_pair.rs` fuzz target for `(f64, f64)` indicators.
- Python tests: SCALAR + new PAIR parameter lists in `test_new_indicators.py`,
  reference-value cases in `test_known_values.py`.
- Node tests: scalar factories + new pair-factory block in
  `bindings/node/__tests__/indicators.test.js`.
- Benches: 5 Family-15 benches added in `crates/wickra/benches/indicators.rs`.
- Docs: README family-table row + counter (71 -> 88), CHANGELOG entry under
  [Unreleased].

Note: Family 12 (statistik-regression, PR #51) introduces
`node_pair_indicator!` and `wasm_pair_indicator!` macros for Pearson /
Beta / Spearman. Family 15 needs the same pair-input pattern but Family 12
is not yet in main, so the three pair wrappers below are written by hand
in this PR. When PR #51 lands, the trivial merge-conflict is resolved by
keeping the macros from Family 12 and re-using them for Treynor / IR /
Alpha (drop the three handwritten wrappers).

cargo check --workspace --all-features: green.

* fix(family-15): satisfy clippy doc_markdown / if_not_else / digit_grouping

* fix(family-15): unused TreynorRatio import, duplicate pairFactories, _eq_nan inf handling

* fix(family-15): node eq() handles matching infinities for ratio indicators

* test(family-15): cover cold paths flagged by codecov patch
This commit is contained in:
kingchenc
2026-05-26 20:44:21 +02:00
committed by GitHub
parent 55284a3042
commit 4e3c41ea80
34 changed files with 5727 additions and 73 deletions
+47 -2
View File
@@ -17,13 +17,17 @@ import wickra as ta
def _eq_nan(a: np.ndarray, b: np.ndarray, tol: float = 1e-9) -> bool:
"""Compare two float arrays treating NaN positions as equal."""
"""Compare two float arrays treating NaN and matching-sign inf positions as equal."""
a = np.asarray(a, dtype=np.float64)
b = np.asarray(b, dtype=np.float64)
if a.shape != b.shape:
return False
both_nan = np.isnan(a) & np.isnan(b)
return bool(np.all(np.where(both_nan, 0.0, np.abs(a - b)) <= tol))
both_inf_same = np.isinf(a) & np.isinf(b) & (np.sign(a) == np.sign(b))
skip = both_nan | both_inf_same
with np.errstate(invalid="ignore"):
diff = np.abs(a - b)
return bool(np.all(np.where(skip, 0.0, diff) <= tol))
@pytest.fixture
@@ -106,6 +110,22 @@ SCALAR = [
(ta.MedianAbsoluteDeviation, (20,)),
(ta.Autocorrelation, (20, 1)),
(ta.HurstExponent, (40, 4)),
# Family 15 — Risk / Performance (scalar f64 input = period return or
# equity sample).
(ta.SharpeRatio, (20, 0.0)),
(ta.SortinoRatio, (20, 0.0)),
(ta.CalmarRatio, (20,)),
(ta.OmegaRatio, (20, 0.0)),
(ta.MaxDrawdown, (20,)),
(ta.AverageDrawdown, (20,)),
(ta.DrawdownDuration, ()),
(ta.PainIndex, (20,)),
(ta.ValueAtRisk, (20, 0.95)),
(ta.ConditionalValueAtRisk, (20, 0.95)),
(ta.ProfitFactor, (20,)),
(ta.GainLossRatio, (20,)),
(ta.RecoveryFactor, ()),
(ta.KellyCriterion, (20,)),
]
@@ -133,6 +153,31 @@ def test_scalar_streaming_matches_batch(cls, args, sine_prices):
assert _eq_nan(batch, np.array(streamed, dtype=np.float64))
# --- Two-series (asset, benchmark) indicators -----------------------------
PAIR = [
(ta.TreynorRatio, (20, 0.0)),
(ta.InformationRatio, (20,)),
(ta.Alpha, (20, 0.0)),
]
@pytest.mark.parametrize("cls, args", PAIR, ids=[c.__name__ for c, _ in PAIR])
def test_pair_streaming_matches_batch(cls, args, sine_prices):
asset = np.ascontiguousarray(sine_prices.astype(np.float64))
bench = np.ascontiguousarray((sine_prices * 0.7 + 0.001).astype(np.float64))
batch = cls(*args).batch(asset, bench)
assert batch.shape == asset.shape
assert batch.dtype == np.float64
streamer = cls(*args)
streamed = []
for a, b in zip(asset, bench):
v = streamer.update(float(a), float(b))
streamed.append(math.nan if v is None else float(v))
assert _eq_nan(batch, np.array(streamed, dtype=np.float64))
# --- Candle-input, single-output indicators -------------------------------
#
# Each entry is (factory, batch-call). Streaming always feeds the full