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