Files
wickra/crates/wickra-core/src/indicators/alpha.rs
T
kingchencandGitHub 4e3c41ea80 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
2026-05-26 20:44:21 +02:00

221 lines
6.6 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! 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);
}
}