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
@@ -17,6 +17,7 @@ const open = close.map((c) => c - 0.5);
function eq(a, b) {
if (Number.isNaN(a)) return Number.isNaN(b);
if (!Number.isFinite(a) || !Number.isFinite(b)) return a === b;
return Math.abs(a - b) < 1e-9;
}
@@ -102,8 +103,46 @@ const scalarFactories = {
MedianAbsoluteDeviation: () => new wickra.MedianAbsoluteDeviation(20),
Autocorrelation: () => new wickra.Autocorrelation(20, 1),
HurstExponent: () => new wickra.HurstExponent(40, 4),
// Family 15 — Risk / Performance metrics (scalar f64 input).
SharpeRatio: () => new wickra.SharpeRatio(20, 0),
SortinoRatio: () => new wickra.SortinoRatio(20, 0),
CalmarRatio: () => new wickra.CalmarRatio(20),
OmegaRatio: () => new wickra.OmegaRatio(20, 0),
MaxDrawdown: () => new wickra.MaxDrawdown(20),
AverageDrawdown: () => new wickra.AverageDrawdown(20),
DrawdownDuration: () => new wickra.DrawdownDuration(),
PainIndex: () => new wickra.PainIndex(20),
ValueAtRisk: () => new wickra.ValueAtRisk(20, 0.95),
ConditionalValueAtRisk: () => new wickra.ConditionalValueAtRisk(20, 0.95),
ProfitFactor: () => new wickra.ProfitFactor(20),
GainLossRatio: () => new wickra.GainLossRatio(20),
RecoveryFactor: () => new wickra.RecoveryFactor(),
KellyCriterion: () => new wickra.KellyCriterion(20),
};
// --- Two-series (asset, benchmark) ratio indicators ---
const ratioPairFactories = {
TreynorRatio: () => new wickra.TreynorRatio(20, 0),
InformationRatio: () => new wickra.InformationRatio(20),
Alpha: () => new wickra.Alpha(20, 0),
};
const asset = Array.from({ length: N }, (_, i) => 0.001 + Math.sin(i * 0.15) * 0.01);
const bench = Array.from({ length: N }, (_, i) => 0.001 + Math.sin(i * 0.15) * 0.007);
for (const [name, make] of Object.entries(ratioPairFactories)) {
test(`${name}: streaming update matches batch (pair)`, () => {
const batch = make().batch(asset, bench);
const streaming = make();
assert.equal(batch.length, N);
for (let i = 0; i < N; i++) {
const s = num(streaming.update(asset[i], bench[i]));
assert.ok(eq(s, batch[i]), `${name} mismatch at ${i}: ${s} vs ${batch[i]}`);
}
});
}
for (const [name, make] of Object.entries(scalarFactories)) {
test(`${name}: streaming update matches batch`, () => {
const batch = make().batch(close);