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:
@@ -6440,3 +6440,241 @@ mod tests {
|
||||
);
|
||||
}
|
||||
}
|
||||
// ============================== Family 15: Risk / Performance ==============================
|
||||
|
||||
// Most metrics need fallible `new` (period >= 2), so they're written by hand
|
||||
// rather than going through `wasm_scalar_indicator!`. Single-parameter helpers
|
||||
// reuse the same patterns as the rest of the file.
|
||||
|
||||
wasm_scalar_indicator!(WasmCalmarRatio, "CalmarRatio", wc::CalmarRatio, period: usize);
|
||||
wasm_scalar_indicator!(WasmMaxDrawdown, "MaxDrawdown", wc::MaxDrawdown, period: usize);
|
||||
wasm_scalar_indicator!(WasmAverageDrawdown, "AverageDrawdown", wc::AverageDrawdown, period: usize);
|
||||
wasm_scalar_indicator!(WasmPainIndex, "PainIndex", wc::PainIndex, period: usize);
|
||||
wasm_scalar_indicator!(WasmProfitFactor, "ProfitFactor", wc::ProfitFactor, period: usize);
|
||||
wasm_scalar_indicator!(WasmGainLossRatio, "GainLossRatio", wc::GainLossRatio, period: usize);
|
||||
wasm_scalar_indicator!(WasmKellyCriterion, "KellyCriterion", wc::KellyCriterion, period: usize);
|
||||
wasm_scalar_indicator!(WasmSharpeRatio, "SharpeRatio", wc::SharpeRatio, period: usize, risk_free: f64);
|
||||
wasm_scalar_indicator!(WasmSortinoRatio, "SortinoRatio", wc::SortinoRatio, period: usize, mar: f64);
|
||||
wasm_scalar_indicator!(WasmOmegaRatio, "OmegaRatio", wc::OmegaRatio, period: usize, threshold: f64);
|
||||
wasm_scalar_indicator!(WasmValueAtRisk, "ValueAtRisk", wc::ValueAtRisk, period: usize, confidence: f64);
|
||||
wasm_scalar_indicator!(WasmConditionalValueAtRisk, "ConditionalValueAtRisk", wc::ConditionalValueAtRisk, period: usize, confidence: f64);
|
||||
|
||||
// --- DrawdownDuration: u32 output, no constructor args ---
|
||||
|
||||
#[wasm_bindgen(js_name = DrawdownDuration)]
|
||||
pub struct WasmDrawdownDuration {
|
||||
inner: wc::DrawdownDuration,
|
||||
}
|
||||
|
||||
impl Default for WasmDrawdownDuration {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
#[wasm_bindgen(js_class = DrawdownDuration)]
|
||||
impl WasmDrawdownDuration {
|
||||
#[wasm_bindgen(constructor)]
|
||||
pub fn new() -> WasmDrawdownDuration {
|
||||
Self {
|
||||
inner: wc::DrawdownDuration::new(),
|
||||
}
|
||||
}
|
||||
pub fn update(&mut self, value: f64) -> Option<u32> {
|
||||
self.inner.update(value)
|
||||
}
|
||||
pub fn batch(&mut self, prices: &[f64]) -> Float64Array {
|
||||
let out: Vec<f64> = prices
|
||||
.iter()
|
||||
.map(|p| self.inner.update(*p).map_or(f64::NAN, f64::from))
|
||||
.collect();
|
||||
Float64Array::from(out.as_slice())
|
||||
}
|
||||
pub fn reset(&mut self) {
|
||||
self.inner.reset();
|
||||
}
|
||||
#[wasm_bindgen(js_name = isReady)]
|
||||
pub fn is_ready(&self) -> bool {
|
||||
self.inner.is_ready()
|
||||
}
|
||||
#[wasm_bindgen(js_name = warmupPeriod)]
|
||||
pub fn warmup_period(&self) -> usize {
|
||||
self.inner.warmup_period()
|
||||
}
|
||||
}
|
||||
|
||||
// --- RecoveryFactor: no constructor args ---
|
||||
|
||||
#[wasm_bindgen(js_name = RecoveryFactor)]
|
||||
pub struct WasmRecoveryFactor {
|
||||
inner: wc::RecoveryFactor,
|
||||
}
|
||||
|
||||
impl Default for WasmRecoveryFactor {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
#[wasm_bindgen(js_class = RecoveryFactor)]
|
||||
impl WasmRecoveryFactor {
|
||||
#[wasm_bindgen(constructor)]
|
||||
pub fn new() -> WasmRecoveryFactor {
|
||||
Self {
|
||||
inner: wc::RecoveryFactor::new(),
|
||||
}
|
||||
}
|
||||
pub fn update(&mut self, value: f64) -> Option<f64> {
|
||||
self.inner.update(value)
|
||||
}
|
||||
pub fn batch(&mut self, prices: &[f64]) -> Float64Array {
|
||||
let out = flatten(self.inner.batch(prices));
|
||||
Float64Array::from(out.as_slice())
|
||||
}
|
||||
pub fn reset(&mut self) {
|
||||
self.inner.reset();
|
||||
}
|
||||
#[wasm_bindgen(js_name = isReady)]
|
||||
pub fn is_ready(&self) -> bool {
|
||||
self.inner.is_ready()
|
||||
}
|
||||
#[wasm_bindgen(js_name = warmupPeriod)]
|
||||
pub fn warmup_period(&self) -> usize {
|
||||
self.inner.warmup_period()
|
||||
}
|
||||
}
|
||||
|
||||
// --- Two-series (asset, benchmark) indicators ---
|
||||
//
|
||||
// Family 12 (PR #51) introduces `wasm_pair_indicator!` for Pearson / Beta /
|
||||
// Spearman. Family 12 is not in main, so Family 15 writes its three pair
|
||||
// wrappers by hand here; merge with PR #51 keeps the macro and re-uses it.
|
||||
|
||||
#[wasm_bindgen(js_name = TreynorRatio)]
|
||||
pub struct WasmTreynorRatio {
|
||||
inner: wc::TreynorRatio,
|
||||
}
|
||||
|
||||
#[wasm_bindgen(js_class = TreynorRatio)]
|
||||
impl WasmTreynorRatio {
|
||||
#[wasm_bindgen(constructor)]
|
||||
pub fn new(period: usize, risk_free: f64) -> Result<WasmTreynorRatio, JsError> {
|
||||
Ok(Self {
|
||||
inner: wc::TreynorRatio::new(period, risk_free).map_err(map_err)?,
|
||||
})
|
||||
}
|
||||
pub fn update(&mut self, asset: f64, benchmark: f64) -> Option<f64> {
|
||||
self.inner.update((asset, benchmark))
|
||||
}
|
||||
pub fn batch(&mut self, asset: &[f64], benchmark: &[f64]) -> Result<Float64Array, JsError> {
|
||||
if asset.len() != benchmark.len() {
|
||||
return Err(JsError::new("asset and benchmark must be equal length"));
|
||||
}
|
||||
let mut out = Vec::with_capacity(asset.len());
|
||||
for i in 0..asset.len() {
|
||||
out.push(
|
||||
self.inner
|
||||
.update((asset[i], benchmark[i]))
|
||||
.unwrap_or(f64::NAN),
|
||||
);
|
||||
}
|
||||
Ok(Float64Array::from(out.as_slice()))
|
||||
}
|
||||
pub fn reset(&mut self) {
|
||||
self.inner.reset();
|
||||
}
|
||||
#[wasm_bindgen(js_name = isReady)]
|
||||
pub fn is_ready(&self) -> bool {
|
||||
self.inner.is_ready()
|
||||
}
|
||||
#[wasm_bindgen(js_name = warmupPeriod)]
|
||||
pub fn warmup_period(&self) -> usize {
|
||||
self.inner.warmup_period()
|
||||
}
|
||||
}
|
||||
|
||||
#[wasm_bindgen(js_name = InformationRatio)]
|
||||
pub struct WasmInformationRatio {
|
||||
inner: wc::InformationRatio,
|
||||
}
|
||||
|
||||
#[wasm_bindgen(js_class = InformationRatio)]
|
||||
impl WasmInformationRatio {
|
||||
#[wasm_bindgen(constructor)]
|
||||
pub fn new(period: usize) -> Result<WasmInformationRatio, JsError> {
|
||||
Ok(Self {
|
||||
inner: wc::InformationRatio::new(period).map_err(map_err)?,
|
||||
})
|
||||
}
|
||||
pub fn update(&mut self, asset: f64, benchmark: f64) -> Option<f64> {
|
||||
self.inner.update((asset, benchmark))
|
||||
}
|
||||
pub fn batch(&mut self, asset: &[f64], benchmark: &[f64]) -> Result<Float64Array, JsError> {
|
||||
if asset.len() != benchmark.len() {
|
||||
return Err(JsError::new("asset and benchmark must be equal length"));
|
||||
}
|
||||
let mut out = Vec::with_capacity(asset.len());
|
||||
for i in 0..asset.len() {
|
||||
out.push(
|
||||
self.inner
|
||||
.update((asset[i], benchmark[i]))
|
||||
.unwrap_or(f64::NAN),
|
||||
);
|
||||
}
|
||||
Ok(Float64Array::from(out.as_slice()))
|
||||
}
|
||||
pub fn reset(&mut self) {
|
||||
self.inner.reset();
|
||||
}
|
||||
#[wasm_bindgen(js_name = isReady)]
|
||||
pub fn is_ready(&self) -> bool {
|
||||
self.inner.is_ready()
|
||||
}
|
||||
#[wasm_bindgen(js_name = warmupPeriod)]
|
||||
pub fn warmup_period(&self) -> usize {
|
||||
self.inner.warmup_period()
|
||||
}
|
||||
}
|
||||
|
||||
#[wasm_bindgen(js_name = Alpha)]
|
||||
pub struct WasmAlpha {
|
||||
inner: wc::Alpha,
|
||||
}
|
||||
|
||||
#[wasm_bindgen(js_class = Alpha)]
|
||||
impl WasmAlpha {
|
||||
#[wasm_bindgen(constructor)]
|
||||
pub fn new(period: usize, risk_free: f64) -> Result<WasmAlpha, JsError> {
|
||||
Ok(Self {
|
||||
inner: wc::Alpha::new(period, risk_free).map_err(map_err)?,
|
||||
})
|
||||
}
|
||||
pub fn update(&mut self, asset: f64, benchmark: f64) -> Option<f64> {
|
||||
self.inner.update((asset, benchmark))
|
||||
}
|
||||
pub fn batch(&mut self, asset: &[f64], benchmark: &[f64]) -> Result<Float64Array, JsError> {
|
||||
if asset.len() != benchmark.len() {
|
||||
return Err(JsError::new("asset and benchmark must be equal length"));
|
||||
}
|
||||
let mut out = Vec::with_capacity(asset.len());
|
||||
for i in 0..asset.len() {
|
||||
out.push(
|
||||
self.inner
|
||||
.update((asset[i], benchmark[i]))
|
||||
.unwrap_or(f64::NAN),
|
||||
);
|
||||
}
|
||||
Ok(Float64Array::from(out.as_slice()))
|
||||
}
|
||||
pub fn reset(&mut self) {
|
||||
self.inner.reset();
|
||||
}
|
||||
#[wasm_bindgen(js_name = isReady)]
|
||||
pub fn is_ready(&self) -> bool {
|
||||
self.inner.is_ready()
|
||||
}
|
||||
#[wasm_bindgen(js_name = warmupPeriod)]
|
||||
pub fn warmup_period(&self) -> usize {
|
||||
self.inner.warmup_period()
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user