* 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
221 lines
6.6 KiB
Rust
221 lines
6.6 KiB
Rust
//! Rolling Jensen's Alpha (CAPM).
|
||
|
||
use std::collections::VecDeque;
|
||
|
||
use crate::error::{Error, Result};
|
||
use crate::traits::Indicator;
|
||
|
||
/// Rolling Jensen's Alpha.
|
||
///
|
||
/// Each `update` receives one `(asset_return, benchmark_return)` pair. Over
|
||
/// the trailing window of `period` pairs:
|
||
///
|
||
/// ```text
|
||
/// Beta = cov(asset, bench) / var(bench)
|
||
/// Alpha = mean(asset) − ( risk_free + Beta · (mean(bench) − risk_free) )
|
||
/// ```
|
||
///
|
||
/// Alpha is the *risk-adjusted excess return* — the slice of the asset's
|
||
/// performance that cannot be explained by simple exposure to the
|
||
/// benchmark. A positive alpha indicates outperformance net of the market
|
||
/// premium implied by the asset's beta; negative alpha is the opposite.
|
||
///
|
||
/// Population covariance and variance are used (matching common
|
||
/// implementations in pandas-ta / quantstats); the rolling estimator stays
|
||
/// unbiased in the steady state for fixed `period`.
|
||
///
|
||
/// If the benchmark is flat (`var(bench) = 0`) the indicator falls back to
|
||
/// `alpha = mean(asset) − risk_free` — the asset's mean excess return, with
|
||
/// no market-risk adjustment, since the regression slope is undefined.
|
||
///
|
||
/// Each `update` is O(1).
|
||
#[derive(Debug, Clone)]
|
||
pub struct Alpha {
|
||
period: usize,
|
||
risk_free: f64,
|
||
window: VecDeque<(f64, f64)>,
|
||
sum_a: f64,
|
||
sum_b: f64,
|
||
sum_bb: f64,
|
||
sum_ab: f64,
|
||
}
|
||
|
||
impl Alpha {
|
||
/// Construct a new rolling Alpha.
|
||
///
|
||
/// # Errors
|
||
/// Returns [`Error::InvalidPeriod`] if `period < 2`.
|
||
pub fn new(period: usize, risk_free: f64) -> Result<Self> {
|
||
if period < 2 {
|
||
return Err(Error::InvalidPeriod {
|
||
message: "alpha needs period >= 2",
|
||
});
|
||
}
|
||
Ok(Self {
|
||
period,
|
||
risk_free,
|
||
window: VecDeque::with_capacity(period),
|
||
sum_a: 0.0,
|
||
sum_b: 0.0,
|
||
sum_bb: 0.0,
|
||
sum_ab: 0.0,
|
||
})
|
||
}
|
||
|
||
/// Configured window length.
|
||
pub const fn period(&self) -> usize {
|
||
self.period
|
||
}
|
||
|
||
/// Configured per-period risk-free rate.
|
||
pub const fn risk_free(&self) -> f64 {
|
||
self.risk_free
|
||
}
|
||
}
|
||
|
||
impl Indicator for Alpha {
|
||
type Input = (f64, f64);
|
||
type Output = f64;
|
||
|
||
fn update(&mut self, input: (f64, f64)) -> Option<f64> {
|
||
let (a, b) = input;
|
||
if !a.is_finite() || !b.is_finite() {
|
||
return None;
|
||
}
|
||
if self.window.len() == self.period {
|
||
let (oa, ob) = self.window.pop_front().expect("non-empty");
|
||
self.sum_a -= oa;
|
||
self.sum_b -= ob;
|
||
self.sum_bb -= ob * ob;
|
||
self.sum_ab -= oa * ob;
|
||
}
|
||
self.window.push_back((a, b));
|
||
self.sum_a += a;
|
||
self.sum_b += b;
|
||
self.sum_bb += b * b;
|
||
self.sum_ab += a * b;
|
||
if self.window.len() < self.period {
|
||
return None;
|
||
}
|
||
let n = self.period as f64;
|
||
let mean_a = self.sum_a / n;
|
||
let mean_b = self.sum_b / n;
|
||
let var_b = (self.sum_bb / n) - mean_b * mean_b;
|
||
if var_b <= 0.0 {
|
||
// Undefined beta: report unadjusted excess.
|
||
return Some(mean_a - self.risk_free);
|
||
}
|
||
let cov_ab = (self.sum_ab / n) - mean_a * mean_b;
|
||
let beta = cov_ab / var_b;
|
||
Some(mean_a - (self.risk_free + beta * (mean_b - self.risk_free)))
|
||
}
|
||
|
||
fn reset(&mut self) {
|
||
self.window.clear();
|
||
self.sum_a = 0.0;
|
||
self.sum_b = 0.0;
|
||
self.sum_bb = 0.0;
|
||
self.sum_ab = 0.0;
|
||
}
|
||
|
||
fn warmup_period(&self) -> usize {
|
||
self.period
|
||
}
|
||
|
||
fn is_ready(&self) -> bool {
|
||
self.window.len() == self.period
|
||
}
|
||
|
||
fn name(&self) -> &'static str {
|
||
"Alpha"
|
||
}
|
||
}
|
||
|
||
#[cfg(test)]
|
||
mod tests {
|
||
use super::*;
|
||
use crate::traits::BatchExt;
|
||
use approx::assert_relative_eq;
|
||
|
||
#[test]
|
||
fn rejects_period_less_than_two() {
|
||
assert!(matches!(
|
||
Alpha::new(1, 0.0),
|
||
Err(Error::InvalidPeriod { .. })
|
||
));
|
||
}
|
||
|
||
#[test]
|
||
fn accessors_and_metadata() {
|
||
let a = Alpha::new(20, 0.001).unwrap();
|
||
assert_eq!(a.period(), 20);
|
||
assert_relative_eq!(a.risk_free(), 0.001, epsilon = 1e-12);
|
||
assert_eq!(a.name(), "Alpha");
|
||
assert_eq!(a.warmup_period(), 20);
|
||
}
|
||
|
||
#[test]
|
||
fn capm_perfect_fit_yields_zero_alpha() {
|
||
// asset = 2 * bench - constant beta of 2, no alpha; with rf = 0 the
|
||
// CAPM-implied return matches the asset's mean perfectly.
|
||
let mut a = Alpha::new(20, 0.0).unwrap();
|
||
let inputs: Vec<(f64, f64)> = (1..=20)
|
||
.map(|i| (2.0 * f64::from(i) * 0.01, f64::from(i) * 0.01))
|
||
.collect();
|
||
let out = a.batch(&inputs);
|
||
assert_relative_eq!(out[19].unwrap(), 0.0, epsilon = 1e-12);
|
||
}
|
||
|
||
#[test]
|
||
fn constant_alpha_offset_recovered() {
|
||
// asset = bench + 0.005 (additive alpha of 0.5%), beta == 1.
|
||
// Expected alpha = 0.005.
|
||
let mut a = Alpha::new(20, 0.0).unwrap();
|
||
let inputs: Vec<(f64, f64)> = (1..=20)
|
||
.map(|i| (f64::from(i) * 0.01 + 0.005, f64::from(i) * 0.01))
|
||
.collect();
|
||
let out = a.batch(&inputs);
|
||
assert_relative_eq!(out[19].unwrap(), 0.005, epsilon = 1e-9);
|
||
}
|
||
|
||
#[test]
|
||
fn flat_benchmark_falls_back_to_excess_return() {
|
||
// Benchmark all 0 -> beta undefined -> alpha = mean_a - rf.
|
||
let mut a = Alpha::new(4, 0.001).unwrap();
|
||
let out = a.batch(&[(0.01, 0.0), (0.02, 0.0), (-0.01, 0.0), (0.04, 0.0)]);
|
||
let mean = (0.01 + 0.02 - 0.01 + 0.04) / 4.0;
|
||
assert_relative_eq!(out[3].unwrap(), mean - 0.001, epsilon = 1e-12);
|
||
}
|
||
|
||
#[test]
|
||
fn ignores_non_finite_input() {
|
||
let mut a = Alpha::new(3, 0.0).unwrap();
|
||
assert_eq!(a.update((f64::NAN, 0.0)), None);
|
||
assert_eq!(a.update((0.0, f64::INFINITY)), None);
|
||
}
|
||
|
||
#[test]
|
||
fn reset_clears_state() {
|
||
let mut a = Alpha::new(3, 0.0).unwrap();
|
||
a.batch(&[(0.01, 0.005), (0.02, 0.01), (-0.01, -0.005)]);
|
||
assert!(a.is_ready());
|
||
a.reset();
|
||
assert!(!a.is_ready());
|
||
assert_eq!(a.update((0.01, 0.005)), None);
|
||
}
|
||
|
||
#[test]
|
||
fn batch_equals_streaming() {
|
||
let inputs: Vec<(f64, f64)> = (0..50)
|
||
.map(|i| {
|
||
let b = (f64::from(i) * 0.2).sin() * 0.01;
|
||
(1.5 * b + 0.002, b)
|
||
})
|
||
.collect();
|
||
let batch = Alpha::new(10, 0.0).unwrap().batch(&inputs);
|
||
let mut s = Alpha::new(10, 0.0).unwrap();
|
||
let streamed: Vec<_> = inputs.iter().map(|x| s.update(*x)).collect();
|
||
assert_eq!(batch, streamed);
|
||
}
|
||
}
|