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
+220
View File
@@ -0,0 +1,220 @@
//! 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);
}
}
@@ -0,0 +1,172 @@
//! Rolling Average Drawdown.
use std::collections::VecDeque;
use crate::error::{Error, Result};
use crate::traits::Indicator;
/// Rolling Average Drawdown.
///
/// Input is treated as an equity-curve sample. The indicator scans the
/// trailing window of `period` values, tracks the running peak inside the
/// window, and reports the **mean** of all bar-by-bar drawdowns (the average
/// "pain" of being under water):
///
/// ```text
/// drawdown_t = (peak_t equity_t) / peak_t (running peak inside window)
/// AvgDD = mean(drawdown_t over window)
/// ```
///
/// Output is non-negative (a fraction; `0.05` ≈ 5 % average drawdown). This
/// is the **Pain Index** under a different name — see [`crate::PainIndex`]
/// for the same metric exposed under its conventional label.
///
/// Each `update` is O(period).
#[derive(Debug, Clone)]
pub struct AverageDrawdown {
period: usize,
window: VecDeque<f64>,
}
impl AverageDrawdown {
/// Construct a new rolling Average Drawdown.
///
/// # Errors
/// Returns [`Error::PeriodZero`] if `period == 0`.
pub fn new(period: usize) -> Result<Self> {
if period == 0 {
return Err(Error::PeriodZero);
}
Ok(Self {
period,
window: VecDeque::with_capacity(period),
})
}
/// Configured window length.
pub const fn period(&self) -> usize {
self.period
}
}
impl Indicator for AverageDrawdown {
type Input = f64;
type Output = f64;
fn update(&mut self, input: f64) -> Option<f64> {
if !input.is_finite() {
return None;
}
if self.window.len() == self.period {
self.window.pop_front();
}
self.window.push_back(input);
if self.window.len() < self.period {
return None;
}
let mut peak = f64::NEG_INFINITY;
let mut sum_dd = 0.0_f64;
for &v in &self.window {
if v > peak {
peak = v;
}
if peak > 0.0 {
sum_dd += (peak - v) / peak;
}
}
Some(sum_dd / self.period as f64)
}
fn reset(&mut self) {
self.window.clear();
}
fn warmup_period(&self) -> usize {
self.period
}
fn is_ready(&self) -> bool {
self.window.len() == self.period
}
fn name(&self) -> &'static str {
"AverageDrawdown"
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::traits::BatchExt;
use approx::assert_relative_eq;
#[test]
fn rejects_zero_period() {
assert!(matches!(AverageDrawdown::new(0), Err(Error::PeriodZero)));
}
#[test]
fn accessors_and_metadata() {
let a = AverageDrawdown::new(10).unwrap();
assert_eq!(a.period(), 10);
assert_eq!(a.name(), "AverageDrawdown");
assert_eq!(a.warmup_period(), 10);
}
#[test]
fn pure_uptrend_yields_zero() {
let mut a = AverageDrawdown::new(5).unwrap();
let out = a.batch(&(1..=20).map(f64::from).collect::<Vec<_>>());
for v in out.into_iter().flatten() {
assert_relative_eq!(v, 0.0, epsilon = 1e-12);
}
}
#[test]
fn reference_value() {
// window [100, 120, 90, 110]:
// peaks: 100, 120, 120, 120; dd: 0, 0, (30/120)=.25, (10/120)=.0833...
// avg = (.25 + .0833...) / 4 = .0833...
let mut a = AverageDrawdown::new(4).unwrap();
let out = a.batch(&[100.0, 120.0, 90.0, 110.0]);
let expected = (0.25 + (10.0 / 120.0)) / 4.0;
assert_relative_eq!(out[3].unwrap(), expected, epsilon = 1e-12);
}
#[test]
fn ignores_non_finite_input() {
let mut a = AverageDrawdown::new(3).unwrap();
assert_eq!(a.update(f64::NAN), None);
assert_eq!(a.update(f64::INFINITY), None);
}
#[test]
fn reset_clears_state() {
let mut a = AverageDrawdown::new(3).unwrap();
a.batch(&[100.0, 90.0, 110.0]);
assert!(a.is_ready());
a.reset();
assert!(!a.is_ready());
assert_eq!(a.update(100.0), None);
}
#[test]
fn batch_equals_streaming() {
let prices: Vec<f64> = (0..40)
.map(|i| 100.0 + (f64::from(i) * 0.3).sin() * 8.0)
.collect();
let batch = AverageDrawdown::new(10).unwrap().batch(&prices);
let mut s = AverageDrawdown::new(10).unwrap();
let streamed: Vec<_> = prices.iter().map(|p| s.update(*p)).collect();
assert_eq!(batch, streamed);
}
#[test]
fn non_positive_peak_yields_zero() {
let mut a = AverageDrawdown::new(3).unwrap();
let out = a.batch(&[0.0_f64; 6]);
for v in out.into_iter().flatten() {
assert_eq!(v, 0.0);
}
}
}
@@ -0,0 +1,202 @@
//! Rolling Calmar Ratio — return over max drawdown.
use std::collections::VecDeque;
use crate::error::{Error, Result};
use crate::traits::Indicator;
/// Rolling Calmar Ratio.
///
/// Input is treated as a single period return. Over the trailing window of
/// `period` returns the indicator reconstructs the implied equity curve
/// (cumulative-compounded), measures the worst peak-to-trough drawdown, and
/// divides the mean return by that drawdown:
///
/// ```text
/// equity_t = ∏(1 + r_i) for i in window up to t
/// mdd = max peak-to-trough decline of equity over window
/// Calmar = mean(returns) / mdd
/// ```
///
/// If the drawdown is zero (monotonically non-decreasing equity in the
/// window) the indicator returns `0.0` rather than `NaN` / `Inf`.
///
/// The equity curve is recomputed inside the window each `update`, which
/// keeps each call O(period) — acceptable for typical backtest windows
/// (`period ≤ 252`).
///
/// # Example
///
/// ```
/// use wickra_core::{CalmarRatio, Indicator};
///
/// let mut cr = CalmarRatio::new(20).unwrap();
/// let mut last = None;
/// for i in 0..40 {
/// last = cr.update(0.001 + (f64::from(i) * 0.1).sin() * 0.005);
/// }
/// assert!(last.is_some());
/// ```
#[derive(Debug, Clone)]
pub struct CalmarRatio {
period: usize,
window: VecDeque<f64>,
sum: f64,
}
impl CalmarRatio {
/// Construct a new rolling Calmar Ratio.
///
/// # Errors
/// Returns [`Error::InvalidPeriod`] if `period < 2`.
pub fn new(period: usize) -> Result<Self> {
if period < 2 {
return Err(Error::InvalidPeriod {
message: "calmar ratio needs period >= 2",
});
}
Ok(Self {
period,
window: VecDeque::with_capacity(period),
sum: 0.0,
})
}
/// Configured window length.
pub const fn period(&self) -> usize {
self.period
}
}
impl Indicator for CalmarRatio {
type Input = f64;
type Output = f64;
fn update(&mut self, input: f64) -> Option<f64> {
if !input.is_finite() {
return None;
}
if self.window.len() == self.period {
let old = self.window.pop_front().expect("non-empty");
self.sum -= old;
}
self.window.push_back(input);
self.sum += input;
if self.window.len() < self.period {
return None;
}
let n = self.period as f64;
let mean = self.sum / n;
// Build equity curve and track the worst peak-to-trough drawdown.
let mut equity = 1.0_f64;
let mut peak = 1.0_f64;
let mut mdd = 0.0_f64;
for &r in &self.window {
equity *= 1.0 + r;
if equity > peak {
peak = equity;
}
// peak starts at 1.0 and never decreases, so peak > 0 by construction.
let dd = (peak - equity) / peak;
if dd > mdd {
mdd = dd;
}
}
if mdd == 0.0 {
return Some(0.0);
}
Some(mean / mdd)
}
fn reset(&mut self) {
self.window.clear();
self.sum = 0.0;
}
fn warmup_period(&self) -> usize {
self.period
}
fn is_ready(&self) -> bool {
self.window.len() == self.period
}
fn name(&self) -> &'static str {
"CalmarRatio"
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::traits::BatchExt;
use approx::assert_relative_eq;
#[test]
fn rejects_period_less_than_two() {
assert!(matches!(
CalmarRatio::new(1),
Err(Error::InvalidPeriod { .. })
));
}
#[test]
fn accessors_and_metadata() {
let c = CalmarRatio::new(10).unwrap();
assert_eq!(c.period(), 10);
assert_eq!(c.name(), "CalmarRatio");
assert_eq!(c.warmup_period(), 10);
}
#[test]
fn pure_uptrend_yields_zero() {
// All positive returns -> no drawdown -> Calmar = 0 by convention.
let mut c = CalmarRatio::new(5).unwrap();
let out = c.batch(&[0.01; 10]);
for v in out.into_iter().flatten() {
assert_eq!(v, 0.0);
}
}
#[test]
fn reference_value() {
// returns = [0.10, -0.20, 0.05]
// equity: 1.0 -> 1.10 -> 0.88 -> 0.924
// peak 1.10, trough 0.88 -> mdd = 0.20.
// mean = (0.10 - 0.20 + 0.05) / 3 ≈ -0.01666...
// Calmar = -0.01666... / 0.20 ≈ -0.08333...
let mut c = CalmarRatio::new(3).unwrap();
let out = c.batch(&[0.10, -0.20, 0.05]);
let mean = (0.10 - 0.20 + 0.05) / 3.0;
let expected = mean / 0.20;
assert_relative_eq!(out[2].unwrap(), expected, epsilon = 1e-9);
}
#[test]
fn ignores_non_finite_input() {
let mut c = CalmarRatio::new(3).unwrap();
assert_eq!(c.update(f64::NAN), None);
assert_eq!(c.update(f64::INFINITY), None);
}
#[test]
fn reset_clears_state() {
let mut c = CalmarRatio::new(3).unwrap();
c.batch(&[0.10, -0.20, 0.05]);
assert!(c.is_ready());
c.reset();
assert!(!c.is_ready());
assert_eq!(c.update(0.01), None);
}
#[test]
fn batch_equals_streaming() {
let returns: Vec<f64> = (0..50)
.map(|i| 0.001 + (f64::from(i) * 0.25).sin() * 0.02)
.collect();
let batch = CalmarRatio::new(10).unwrap().batch(&returns);
let mut s = CalmarRatio::new(10).unwrap();
let streamed: Vec<_> = returns.iter().map(|r| s.update(*r)).collect();
assert_eq!(batch, streamed);
}
}
@@ -0,0 +1,221 @@
//! Rolling Conditional Value-at-Risk (`CVaR` / Expected Shortfall).
use std::collections::VecDeque;
use crate::error::{Error, Result};
use crate::traits::Indicator;
/// Rolling Conditional Value-at-Risk (Expected Shortfall).
///
/// Where [`crate::ValueAtRisk`] reports the loss at the lower-tail quantile,
/// `CVaR` averages **all** returns below that quantile — the expected loss
/// conditional on being in the bad tail:
///
/// ```text
/// q = 1 confidence
/// tail = returns over window with rank fraction ≤ q
/// CVaR = mean(tail) if mean is negative
/// CVaR = 0 otherwise
/// ```
///
/// The tail comprises the `floor(q · n)` smallest returns; if `floor` rounds
/// down to zero the smallest single return is used so the metric stays
/// defined for any `period ≥ 2`. Output is the magnitude of the expected
/// shortfall (sign-flipped to be non-negative). `CVaR` is by construction
/// `≥ VaR` because it averages losses *beyond* the `VaR` threshold.
///
/// Each `update` is O(period · log period).
///
/// # Example
///
/// ```
/// use wickra_core::{ConditionalValueAtRisk, Indicator};
///
/// let mut c = ConditionalValueAtRisk::new(100, 0.95).unwrap();
/// let mut last = None;
/// for i in 0..120 {
/// last = c.update((f64::from(i) * 0.1).sin() * 0.02);
/// }
/// assert!(last.is_some());
/// ```
#[derive(Debug, Clone)]
pub struct ConditionalValueAtRisk {
period: usize,
confidence: f64,
window: VecDeque<f64>,
}
impl ConditionalValueAtRisk {
/// Construct a new rolling `CVaR`.
///
/// # Errors
/// Returns [`Error::InvalidPeriod`] if `period < 2`, or if
/// `confidence` is outside `(0, 1)`.
pub fn new(period: usize, confidence: f64) -> Result<Self> {
if period < 2 {
return Err(Error::InvalidPeriod {
message: "conditional value-at-risk needs period >= 2",
});
}
if !confidence.is_finite() || confidence <= 0.0 || confidence >= 1.0 {
return Err(Error::InvalidPeriod {
message: "confidence must lie strictly between 0 and 1",
});
}
Ok(Self {
period,
confidence,
window: VecDeque::with_capacity(period),
})
}
/// Configured window length.
pub const fn period(&self) -> usize {
self.period
}
/// Configured confidence level.
pub const fn confidence(&self) -> f64 {
self.confidence
}
}
impl Indicator for ConditionalValueAtRisk {
type Input = f64;
type Output = f64;
fn update(&mut self, input: f64) -> Option<f64> {
if !input.is_finite() {
return None;
}
if self.window.len() == self.period {
self.window.pop_front();
}
self.window.push_back(input);
if self.window.len() < self.period {
return None;
}
let mut sorted: Vec<f64> = self.window.iter().copied().collect();
sorted.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal));
let q = 1.0 - self.confidence;
let n = sorted.len();
// Number of samples in the tail. Floor, with a min of 1 so the
// expectation is always defined.
let k = ((q * n as f64).floor() as usize).max(1);
let tail = &sorted[..k];
let mean = tail.iter().sum::<f64>() / k as f64;
Some((-mean).max(0.0))
}
fn reset(&mut self) {
self.window.clear();
}
fn warmup_period(&self) -> usize {
self.period
}
fn is_ready(&self) -> bool {
self.window.len() == self.period
}
fn name(&self) -> &'static str {
"ConditionalValueAtRisk"
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::traits::BatchExt;
use approx::assert_relative_eq;
#[test]
fn rejects_invalid_params() {
assert!(matches!(
ConditionalValueAtRisk::new(1, 0.95),
Err(Error::InvalidPeriod { .. })
));
assert!(matches!(
ConditionalValueAtRisk::new(20, 0.0),
Err(Error::InvalidPeriod { .. })
));
assert!(matches!(
ConditionalValueAtRisk::new(20, 1.0),
Err(Error::InvalidPeriod { .. })
));
}
#[test]
fn accessors_and_metadata() {
let c = ConditionalValueAtRisk::new(100, 0.95).unwrap();
assert_eq!(c.period(), 100);
assert_relative_eq!(c.confidence(), 0.95, epsilon = 1e-12);
assert_eq!(c.name(), "ConditionalValueAtRisk");
assert_eq!(c.warmup_period(), 100);
}
#[test]
fn reference_value() {
// 20 returns -10..9 (each *0.01); confidence 0.95.
// q = 0.05, n = 20, k = floor(0.05*20) = 1.
// Tail = {-0.10}, CVaR = 0.10.
let mut c = ConditionalValueAtRisk::new(20, 0.95).unwrap();
let returns: Vec<f64> = (-10..10).map(|i| f64::from(i) * 0.01).collect();
let out = c.batch(&returns);
assert_relative_eq!(out[19].unwrap(), 0.10, epsilon = 1e-9);
}
#[test]
fn cvar_geq_var_on_same_window() {
// Sanity: with confidence 0.9, the tail of 10 returns has 1 sample;
// VaR uses interpolation between 0 and 1, so CVaR (mean of just the
// worst) >= VaR.
use crate::ValueAtRisk;
let returns: Vec<f64> = vec![
-0.05, -0.02, -0.01, 0.0, 0.005, 0.01, 0.02, 0.03, 0.04, 0.05,
];
let mut v = ValueAtRisk::new(10, 0.9).unwrap();
let mut c = ConditionalValueAtRisk::new(10, 0.9).unwrap();
let v_out = v.batch(&returns);
let c_out = c.batch(&returns);
let var = v_out[9].unwrap();
let cvar = c_out[9].unwrap();
assert!(cvar >= var - 1e-12, "CVaR {cvar} should be >= VaR {var}");
}
#[test]
fn all_positive_returns_yield_zero() {
let mut c = ConditionalValueAtRisk::new(5, 0.95).unwrap();
let out = c.batch(&[0.01, 0.02, 0.03, 0.04, 0.05]);
assert_eq!(out[4], Some(0.0));
}
#[test]
fn ignores_non_finite_input() {
let mut c = ConditionalValueAtRisk::new(3, 0.95).unwrap();
assert_eq!(c.update(f64::NAN), None);
assert_eq!(c.update(f64::INFINITY), None);
}
#[test]
fn reset_clears_state() {
let mut c = ConditionalValueAtRisk::new(3, 0.95).unwrap();
c.batch(&[-0.01, -0.02, -0.03]);
assert!(c.is_ready());
c.reset();
assert!(!c.is_ready());
assert_eq!(c.update(0.01), None);
}
#[test]
fn batch_equals_streaming() {
let returns: Vec<f64> = (0..50).map(|i| (f64::from(i) * 0.2).sin() * 0.02).collect();
let batch = ConditionalValueAtRisk::new(10, 0.95)
.unwrap()
.batch(&returns);
let mut s = ConditionalValueAtRisk::new(10, 0.95).unwrap();
let streamed: Vec<_> = returns.iter().map(|r| s.update(*r)).collect();
assert_eq!(batch, streamed);
}
}
@@ -0,0 +1,174 @@
//! Drawdown Duration — bars since the last all-time peak ("time under water").
use crate::traits::Indicator;
/// Cumulative drawdown duration in bars.
///
/// Each `update` receives one equity-curve sample. The indicator tracks the
/// **running all-time peak** seen since construction (or last `reset`) and
/// reports how many bars have elapsed since that peak was set:
///
/// ```text
/// peak_t = max(input over [0..=t])
/// duration_t = bars elapsed since peak_t was first set
/// ```
///
/// A new peak resets the duration to `0`. As long as the series stays under
/// water the duration grows linearly with each bar.
///
/// The indicator emits a value on every bar (no warmup beyond the first
/// input) and runs in O(1) per `update`.
///
/// # Example
///
/// ```
/// use wickra_core::{DrawdownDuration, Indicator};
///
/// let mut dd = DrawdownDuration::new();
/// assert_eq!(dd.update(100.0), Some(0)); // first bar -> new peak
/// assert_eq!(dd.update(95.0), Some(1)); // 1 bar under water
/// assert_eq!(dd.update(90.0), Some(2)); // 2 bars under water
/// assert_eq!(dd.update(110.0), Some(0)); // new peak -> reset
/// ```
#[derive(Debug, Clone, Default)]
pub struct DrawdownDuration {
peak: f64,
bars_under_water: u32,
seen: bool,
}
impl DrawdownDuration {
/// Construct a new Drawdown Duration tracker.
pub const fn new() -> Self {
Self {
peak: f64::NEG_INFINITY,
bars_under_water: 0,
seen: false,
}
}
/// Bars elapsed since the running all-time peak was set.
pub const fn value(&self) -> Option<u32> {
if self.seen {
Some(self.bars_under_water)
} else {
None
}
}
}
impl Indicator for DrawdownDuration {
type Input = f64;
type Output = u32;
fn update(&mut self, input: f64) -> Option<u32> {
if !input.is_finite() {
return self.value();
}
if !self.seen || input >= self.peak {
self.peak = input;
self.bars_under_water = 0;
} else {
self.bars_under_water = self.bars_under_water.saturating_add(1);
}
self.seen = true;
Some(self.bars_under_water)
}
fn reset(&mut self) {
self.peak = f64::NEG_INFINITY;
self.bars_under_water = 0;
self.seen = false;
}
fn warmup_period(&self) -> usize {
1
}
fn is_ready(&self) -> bool {
self.seen
}
fn name(&self) -> &'static str {
"DrawdownDuration"
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::traits::BatchExt;
#[test]
fn accessors_and_metadata() {
let mut d = DrawdownDuration::new();
assert_eq!(d.name(), "DrawdownDuration");
assert_eq!(d.warmup_period(), 1);
assert_eq!(d.value(), None);
d.update(100.0);
assert_eq!(d.value(), Some(0));
}
#[test]
fn first_bar_is_peak() {
let mut d = DrawdownDuration::new();
assert_eq!(d.update(100.0), Some(0));
}
#[test]
fn under_water_counter_increments() {
let mut d = DrawdownDuration::new();
d.update(100.0);
assert_eq!(d.update(90.0), Some(1));
assert_eq!(d.update(80.0), Some(2));
assert_eq!(d.update(85.0), Some(3));
}
#[test]
fn new_peak_resets_counter() {
let mut d = DrawdownDuration::new();
d.update(100.0);
d.update(90.0);
d.update(80.0);
assert_eq!(d.update(105.0), Some(0));
assert_eq!(d.update(95.0), Some(1));
}
#[test]
fn equal_value_is_treated_as_peak() {
let mut d = DrawdownDuration::new();
d.update(100.0);
assert_eq!(d.update(100.0), Some(0));
}
#[test]
fn ignores_non_finite_input() {
let mut d = DrawdownDuration::new();
d.update(100.0);
d.update(90.0);
let v = d.value();
assert_eq!(d.update(f64::NAN), v);
assert_eq!(d.update(f64::INFINITY), v);
}
#[test]
fn reset_clears_state() {
let mut d = DrawdownDuration::new();
d.batch(&[100.0, 90.0, 80.0]);
assert!(d.is_ready());
d.reset();
assert!(!d.is_ready());
assert_eq!(d.update(100.0), Some(0));
}
#[test]
fn batch_equals_streaming() {
let prices: Vec<f64> = (0..30)
.map(|i| 100.0 + (f64::from(i) * 0.4).sin() * 5.0)
.collect();
let batch = DrawdownDuration::new().batch(&prices);
let mut s = DrawdownDuration::new();
let streamed: Vec<_> = prices.iter().map(|p| s.update(*p)).collect();
assert_eq!(batch, streamed);
}
}
@@ -0,0 +1,184 @@
//! Rolling Gain/Loss Ratio.
use std::collections::VecDeque;
use crate::error::{Error, Result};
use crate::traits::Indicator;
/// Rolling Gain/Loss Ratio.
///
/// Over the trailing window:
///
/// ```text
/// avg_win = mean(r for r in window if r > 0)
/// avg_loss = mean(r for r in window if r < 0)
/// GLR = avg_win / avg_loss
/// ```
///
/// Where Profit Factor sums gains and losses, the Gain/Loss Ratio averages
/// them: it answers "for the typical winning bar, how big is the win
/// compared to the typical losing bar?". If there are no losers the
/// indicator returns `f64::INFINITY`; if there are no winners and no losers
/// it returns `0.0`.
///
/// Each `update` is O(period).
#[derive(Debug, Clone)]
pub struct GainLossRatio {
period: usize,
window: VecDeque<f64>,
}
impl GainLossRatio {
/// Construct a new rolling Gain/Loss Ratio.
///
/// # Errors
/// Returns [`Error::PeriodZero`] if `period == 0`.
pub fn new(period: usize) -> Result<Self> {
if period == 0 {
return Err(Error::PeriodZero);
}
Ok(Self {
period,
window: VecDeque::with_capacity(period),
})
}
/// Configured window length.
pub const fn period(&self) -> usize {
self.period
}
}
impl Indicator for GainLossRatio {
type Input = f64;
type Output = f64;
fn update(&mut self, input: f64) -> Option<f64> {
if !input.is_finite() {
return None;
}
if self.window.len() == self.period {
self.window.pop_front();
}
self.window.push_back(input);
if self.window.len() < self.period {
return None;
}
let mut sum_win = 0.0_f64;
let mut n_win = 0_u32;
let mut sum_loss = 0.0_f64;
let mut n_loss = 0_u32;
for &r in &self.window {
if r > 0.0 {
sum_win += r;
n_win += 1;
} else if r < 0.0 {
sum_loss += -r;
n_loss += 1;
}
}
if n_loss == 0 {
return Some(if n_win == 0 { 0.0 } else { f64::INFINITY });
}
let avg_win = if n_win == 0 {
0.0
} else {
sum_win / f64::from(n_win)
};
let avg_loss = sum_loss / f64::from(n_loss);
Some(avg_win / avg_loss)
}
fn reset(&mut self) {
self.window.clear();
}
fn warmup_period(&self) -> usize {
self.period
}
fn is_ready(&self) -> bool {
self.window.len() == self.period
}
fn name(&self) -> &'static str {
"GainLossRatio"
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::traits::BatchExt;
use approx::assert_relative_eq;
#[test]
fn rejects_zero_period() {
assert!(matches!(GainLossRatio::new(0), Err(Error::PeriodZero)));
}
#[test]
fn accessors_and_metadata() {
let g = GainLossRatio::new(10).unwrap();
assert_eq!(g.period(), 10);
assert_eq!(g.name(), "GainLossRatio");
assert_eq!(g.warmup_period(), 10);
}
#[test]
fn reference_value() {
// returns = [0.02, -0.01, 0.04, -0.03]
// avg_win = 0.03, avg_loss = 0.02, GLR = 1.5.
let mut g = GainLossRatio::new(4).unwrap();
let out = g.batch(&[0.02, -0.01, 0.04, -0.03]);
assert_relative_eq!(out[3].unwrap(), 1.5, epsilon = 1e-9);
}
#[test]
fn no_losses_yields_infinity() {
let mut g = GainLossRatio::new(3).unwrap();
let out = g.batch(&[0.01, 0.02, 0.03]);
assert!(out[2].unwrap().is_infinite());
}
#[test]
fn flat_window_yields_zero() {
let mut g = GainLossRatio::new(3).unwrap();
let out = g.batch(&[0.0_f64; 3]);
assert_eq!(out[2], Some(0.0));
}
#[test]
fn ignores_non_finite_input() {
let mut g = GainLossRatio::new(3).unwrap();
assert_eq!(g.update(f64::NAN), None);
assert_eq!(g.update(f64::INFINITY), None);
}
#[test]
fn no_wins_but_losses_yields_zero() {
// Window with only losses: avg_win is 0, GLR = 0.
let mut g = GainLossRatio::new(3).unwrap();
let out = g.batch(&[-0.01, -0.02, -0.03]);
assert_eq!(out[2], Some(0.0));
}
#[test]
fn reset_clears_state() {
let mut g = GainLossRatio::new(3).unwrap();
g.batch(&[0.01, -0.02, 0.03]);
assert!(g.is_ready());
g.reset();
assert!(!g.is_ready());
assert_eq!(g.update(0.01), None);
}
#[test]
fn batch_equals_streaming() {
let returns: Vec<f64> = (0..40).map(|i| (f64::from(i) * 0.3).sin() * 0.01).collect();
let batch = GainLossRatio::new(10).unwrap().batch(&returns);
let mut s = GainLossRatio::new(10).unwrap();
let streamed: Vec<_> = returns.iter().map(|r| s.update(*r)).collect();
assert_eq!(batch, streamed);
}
}
@@ -0,0 +1,187 @@
//! Rolling Information Ratio.
use std::collections::VecDeque;
use crate::error::{Error, Result};
use crate::traits::Indicator;
/// Rolling Information Ratio.
///
/// Each `update` receives one `(asset_return, benchmark_return)` pair. Over
/// the trailing window of `period` pairs:
///
/// ```text
/// active_t = asset_t benchmark_t
/// tracking_error = stddev(active over window) (sample)
/// IR = mean(active) / tracking_error
/// ```
///
/// The Information Ratio quantifies skill in beating a benchmark per unit
/// of active-return volatility. A high IR means consistent (low-noise)
/// outperformance; a near-zero IR means the asset moves with the benchmark
/// regardless of any small alpha.
///
/// If the tracking error is zero (asset perfectly tracks the benchmark over
/// the window) the indicator returns `0.0` rather than `NaN`.
///
/// Each `update` is O(1).
#[derive(Debug, Clone)]
pub struct InformationRatio {
period: usize,
window: VecDeque<f64>,
sum: f64,
sum_sq: f64,
}
impl InformationRatio {
/// Construct a new rolling Information Ratio.
///
/// # Errors
/// Returns [`Error::InvalidPeriod`] if `period < 2`.
pub fn new(period: usize) -> Result<Self> {
if period < 2 {
return Err(Error::InvalidPeriod {
message: "information ratio needs period >= 2",
});
}
Ok(Self {
period,
window: VecDeque::with_capacity(period),
sum: 0.0,
sum_sq: 0.0,
})
}
/// Configured window length.
pub const fn period(&self) -> usize {
self.period
}
}
impl Indicator for InformationRatio {
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;
}
let active = a - b;
if self.window.len() == self.period {
let old = self.window.pop_front().expect("non-empty");
self.sum -= old;
self.sum_sq -= old * old;
}
self.window.push_back(active);
self.sum += active;
self.sum_sq += active * active;
if self.window.len() < self.period {
return None;
}
let n = self.period as f64;
let mean = self.sum / n;
let var = ((self.sum_sq - n * mean * mean) / (n - 1.0)).max(0.0);
let te = var.sqrt();
if te == 0.0 {
return Some(0.0);
}
Some(mean / te)
}
fn reset(&mut self) {
self.window.clear();
self.sum = 0.0;
self.sum_sq = 0.0;
}
fn warmup_period(&self) -> usize {
self.period
}
fn is_ready(&self) -> bool {
self.window.len() == self.period
}
fn name(&self) -> &'static str {
"InformationRatio"
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::traits::BatchExt;
use approx::assert_relative_eq;
#[test]
fn rejects_period_less_than_two() {
assert!(matches!(
InformationRatio::new(1),
Err(Error::InvalidPeriod { .. })
));
}
#[test]
fn accessors_and_metadata() {
let i = InformationRatio::new(10).unwrap();
assert_eq!(i.period(), 10);
assert_eq!(i.name(), "InformationRatio");
assert_eq!(i.warmup_period(), 10);
}
#[test]
fn perfect_tracking_yields_zero() {
// asset == benchmark every bar -> active = 0 -> te = 0 -> 0.
let mut i = InformationRatio::new(5).unwrap();
let inputs: Vec<(f64, f64)> = (0..5)
.map(|j| (f64::from(j) * 0.01, f64::from(j) * 0.01))
.collect();
let out = i.batch(&inputs);
assert_eq!(out[4], Some(0.0));
}
#[test]
fn reference_value() {
// asset=[0.02,0.04,0.06,0.08], bench=[0.01,0.02,0.03,0.04].
// active=[0.01,0.02,0.03,0.04]; mean=0.025;
// var = ((0.01-.025)^2 + ... ) / 3 = 0.0001666...;
// te = sqrt(0.0001666...); IR = 0.025/te.
let mut i = InformationRatio::new(4).unwrap();
let inputs = vec![(0.02, 0.01), (0.04, 0.02), (0.06, 0.03), (0.08, 0.04)];
let out = i.batch(&inputs);
let expected = 0.025 / (0.000_166_666_666_666_666_67_f64).sqrt();
assert_relative_eq!(out[3].unwrap(), expected, epsilon = 1e-9);
}
#[test]
fn ignores_non_finite_input() {
let mut i = InformationRatio::new(3).unwrap();
assert_eq!(i.update((f64::NAN, 0.01)), None);
assert_eq!(i.update((0.01, f64::INFINITY)), None);
}
#[test]
fn reset_clears_state() {
let mut i = InformationRatio::new(3).unwrap();
i.batch(&[(0.01, 0.005), (0.02, 0.01), (-0.01, -0.005)]);
assert!(i.is_ready());
i.reset();
assert!(!i.is_ready());
assert_eq!(i.update((0.01, 0.005)), None);
}
#[test]
fn batch_equals_streaming() {
let inputs: Vec<(f64, f64)> = (0..50)
.map(|j| {
let b = (f64::from(j) * 0.2).sin() * 0.01;
(b + 0.001, b)
})
.collect();
let batch = InformationRatio::new(10).unwrap().batch(&inputs);
let mut s = InformationRatio::new(10).unwrap();
let streamed: Vec<_> = inputs.iter().map(|x| s.update(*x)).collect();
assert_eq!(batch, streamed);
}
}
@@ -0,0 +1,202 @@
//! Rolling Kelly Criterion.
use std::collections::VecDeque;
use crate::error::{Error, Result};
use crate::traits::Indicator;
/// Rolling Kelly Criterion fraction.
///
/// Input is treated as a per-period (or per-trade) return. Over the trailing
/// window the indicator estimates the optimal capital fraction to allocate
/// using the **even-money** Kelly formula generalised by the payoff ratio:
///
/// ```text
/// win_rate = P(r > 0) over window
/// avg_win = mean(r for r > 0)
/// avg_loss = mean(r for r < 0)
/// payoff_ratio = avg_win / avg_loss
/// Kelly = win_rate (1 win_rate) / payoff_ratio
/// ```
///
/// The output is the recommended **fraction** of capital to bet (typically
/// `(0, 1)`; can go negative if the estimated edge is negative, in which
/// case the position should be reversed or sized to zero). Most
/// practitioners use a "half-Kelly" or "quarter-Kelly" multiplier in
/// practice to reduce variance — Wickra reports raw Kelly and leaves the
/// scaling to the caller.
///
/// Edge cases:
/// * No winners and no losers ⇒ `0.0` (no information).
/// * No losers (`payoff_ratio = ∞`) ⇒ Kelly collapses to the win rate.
/// * No winners but losers present ⇒ Kelly = `(1 0) / payoff = …`,
/// which is negative — bet nothing (or short).
///
/// Each `update` is O(period).
#[derive(Debug, Clone)]
pub struct KellyCriterion {
period: usize,
window: VecDeque<f64>,
}
impl KellyCriterion {
/// Construct a new rolling Kelly Criterion.
///
/// # Errors
/// Returns [`Error::PeriodZero`] if `period == 0`.
pub fn new(period: usize) -> Result<Self> {
if period == 0 {
return Err(Error::PeriodZero);
}
Ok(Self {
period,
window: VecDeque::with_capacity(period),
})
}
/// Configured window length.
pub const fn period(&self) -> usize {
self.period
}
}
impl Indicator for KellyCriterion {
type Input = f64;
type Output = f64;
fn update(&mut self, input: f64) -> Option<f64> {
if !input.is_finite() {
return None;
}
if self.window.len() == self.period {
self.window.pop_front();
}
self.window.push_back(input);
if self.window.len() < self.period {
return None;
}
let mut sum_win = 0.0_f64;
let mut n_win = 0_u32;
let mut sum_loss = 0.0_f64;
let mut n_loss = 0_u32;
for &r in &self.window {
if r > 0.0 {
sum_win += r;
n_win += 1;
} else if r < 0.0 {
sum_loss += -r;
n_loss += 1;
}
}
let n = self.period as f64;
let win_rate = f64::from(n_win) / n;
if n_loss == 0 {
// No losses in window: payoff ratio is infinite; Kelly collapses
// to the win rate (limit of w - (1-w)/r as r -> ∞).
return Some(win_rate);
}
let avg_loss = sum_loss / f64::from(n_loss);
if n_win == 0 {
// All losses: avg_win = 0 -> payoff = 0 -> -(1)/0 -> -inf.
// Bet nothing (or reverse); clamp to -1 for sanity.
return Some(-1.0);
}
let avg_win = sum_win / f64::from(n_win);
let payoff = avg_win / avg_loss;
Some(win_rate - (1.0 - win_rate) / payoff)
}
fn reset(&mut self) {
self.window.clear();
}
fn warmup_period(&self) -> usize {
self.period
}
fn is_ready(&self) -> bool {
self.window.len() == self.period
}
fn name(&self) -> &'static str {
"KellyCriterion"
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::traits::BatchExt;
use approx::assert_relative_eq;
#[test]
fn rejects_zero_period() {
assert!(matches!(KellyCriterion::new(0), Err(Error::PeriodZero)));
}
#[test]
fn accessors_and_metadata() {
let k = KellyCriterion::new(10).unwrap();
assert_eq!(k.period(), 10);
assert_eq!(k.name(), "KellyCriterion");
assert_eq!(k.warmup_period(), 10);
}
#[test]
fn reference_value() {
// returns = [0.02, 0.04, -0.01, -0.02] (n=4).
// n_win=2, n_loss=2; win_rate = 0.5.
// avg_win=0.03, avg_loss=0.015, payoff=2.
// Kelly = 0.5 - (0.5/2) = 0.25.
let mut k = KellyCriterion::new(4).unwrap();
let out = k.batch(&[0.02, 0.04, -0.01, -0.02]);
assert_relative_eq!(out[3].unwrap(), 0.25, epsilon = 1e-9);
}
#[test]
fn all_winners_returns_win_rate() {
let mut k = KellyCriterion::new(3).unwrap();
let out = k.batch(&[0.01, 0.02, 0.03]);
assert_relative_eq!(out[2].unwrap(), 1.0, epsilon = 1e-12);
}
#[test]
fn all_losers_returns_negative_one() {
let mut k = KellyCriterion::new(3).unwrap();
let out = k.batch(&[-0.01, -0.02, -0.03]);
assert_relative_eq!(out[2].unwrap(), -1.0, epsilon = 1e-12);
}
#[test]
fn flat_window_yields_zero() {
let mut k = KellyCriterion::new(3).unwrap();
let out = k.batch(&[0.0_f64; 3]);
assert_eq!(out[2], Some(0.0));
}
#[test]
fn ignores_non_finite_input() {
let mut k = KellyCriterion::new(3).unwrap();
assert_eq!(k.update(f64::NAN), None);
assert_eq!(k.update(f64::INFINITY), None);
}
#[test]
fn reset_clears_state() {
let mut k = KellyCriterion::new(3).unwrap();
k.batch(&[0.01, -0.02, 0.03]);
assert!(k.is_ready());
k.reset();
assert!(!k.is_ready());
assert_eq!(k.update(0.01), None);
}
#[test]
fn batch_equals_streaming() {
let returns: Vec<f64> = (0..40).map(|i| (f64::from(i) * 0.3).sin() * 0.01).collect();
let batch = KellyCriterion::new(10).unwrap().batch(&returns);
let mut s = KellyCriterion::new(10).unwrap();
let streamed: Vec<_> = returns.iter().map(|r| s.update(*r)).collect();
assert_eq!(batch, streamed);
}
}
@@ -0,0 +1,245 @@
//! Maximum Drawdown over a rolling window.
use std::collections::VecDeque;
use crate::error::{Error, Result};
use crate::traits::Indicator;
/// Rolling Maximum Drawdown — the deepest peak-to-trough decline within the
/// trailing window.
///
/// The input is treated as an equity-curve sample (or any non-negative value
/// series). For each bar the indicator computes the largest fractional decline
/// from any prior peak inside the trailing `period`-bar window:
///
/// ```text
/// drawdown_t = (equity_t peak_t) / peak_t (a negative number)
/// MaxDrawdown = min(drawdown_t over window) (most-negative value)
/// ```
///
/// Output is the magnitude of the worst drawdown as a non-negative fraction
/// (`0.20` = 20 % drop from peak). A monotonically rising equity curve has a
/// max drawdown of `0`. Setting `period` greater than or equal to the number of
/// bars you will ever feed makes the metric effectively *cumulative* — the
/// indicator never forgets the global peak.
///
/// Each `update` is amortised O(1): the running peak is tracked with a
/// monotonically-decreasing deque.
///
/// # Example
///
/// ```
/// use wickra_core::{Indicator, MaxDrawdown};
///
/// let mut mdd = MaxDrawdown::new(10).unwrap();
/// // Equity peaks at 110 then drops to 88 — a 20% drawdown.
/// for v in [100.0, 110.0, 100.0, 95.0, 88.0, 90.0, 92.0, 95.0, 100.0, 105.0] {
/// mdd.update(v);
/// }
/// assert!((mdd.update(106.0).unwrap() - 0.20).abs() < 1e-9);
/// ```
#[derive(Debug, Clone)]
pub struct MaxDrawdown {
period: usize,
count: u64,
/// Monotonically-decreasing deque of `(index, value)` over the trailing
/// window. Front is the trailing peak in O(1).
peak_dq: VecDeque<(u64, f64)>,
window: VecDeque<f64>,
last: Option<f64>,
}
impl MaxDrawdown {
/// Construct a new rolling Max Drawdown.
///
/// # Errors
/// Returns [`Error::PeriodZero`] if `period == 0`.
pub fn new(period: usize) -> Result<Self> {
if period == 0 {
return Err(Error::PeriodZero);
}
Ok(Self {
period,
count: 0,
peak_dq: VecDeque::with_capacity(period),
window: VecDeque::with_capacity(period),
last: None,
})
}
/// Configured rolling-window length.
pub const fn period(&self) -> usize {
self.period
}
/// Current value if available.
pub const fn value(&self) -> Option<f64> {
self.last
}
}
impl Indicator for MaxDrawdown {
type Input = f64;
type Output = f64;
fn update(&mut self, input: f64) -> Option<f64> {
if !input.is_finite() {
return self.last;
}
self.count += 1;
// Drop tail entries dominated by the new value (running peak from the
// back side of the window).
while let Some(&(_, back)) = self.peak_dq.back() {
if back <= input {
self.peak_dq.pop_back();
} else {
break;
}
}
self.peak_dq.push_back((self.count, input));
// Window slide.
if self.window.len() == self.period {
self.window.pop_front();
}
self.window.push_back(input);
let window_lo = self.count.saturating_sub(self.period as u64 - 1);
while let Some(&(idx, _)) = self.peak_dq.front() {
if idx < window_lo {
self.peak_dq.pop_front();
} else {
break;
}
}
if self.window.len() < self.period {
return None;
}
// Scan the window for the deepest drawdown vs running peak so far.
let mut peak = f64::NEG_INFINITY;
let mut worst = 0.0_f64;
for &v in &self.window {
if v > peak {
peak = v;
}
if peak > 0.0 {
let dd = (peak - v) / peak;
if dd > worst {
worst = dd;
}
}
}
self.last = Some(worst);
Some(worst)
}
fn reset(&mut self) {
self.count = 0;
self.peak_dq.clear();
self.window.clear();
self.last = None;
}
fn warmup_period(&self) -> usize {
self.period
}
fn is_ready(&self) -> bool {
self.last.is_some()
}
fn name(&self) -> &'static str {
"MaxDrawdown"
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::traits::BatchExt;
use approx::assert_relative_eq;
#[test]
fn new_rejects_zero_period() {
assert!(matches!(MaxDrawdown::new(0), Err(Error::PeriodZero)));
}
#[test]
fn accessors_and_metadata() {
let mut mdd = MaxDrawdown::new(10).unwrap();
assert_eq!(mdd.period(), 10);
assert_eq!(mdd.name(), "MaxDrawdown");
assert_eq!(mdd.value(), None);
assert_eq!(mdd.warmup_period(), 10);
for v in 1..=10 {
mdd.update(f64::from(v));
}
assert!(mdd.value().is_some());
}
#[test]
fn pure_uptrend_yields_zero() {
let mut mdd = MaxDrawdown::new(5).unwrap();
let out = mdd.batch(&(1..=20).map(f64::from).collect::<Vec<_>>());
for v in out.into_iter().flatten() {
assert_relative_eq!(v, 0.0, epsilon = 1e-12);
}
}
#[test]
fn reference_drawdown() {
// Window [100, 120, 90]: peak 120, trough 90 -> 25% drawdown.
let mut mdd = MaxDrawdown::new(3).unwrap();
let out = mdd.batch(&[100.0, 120.0, 90.0]);
assert_eq!(out[0], None);
assert_eq!(out[1], None);
assert_relative_eq!(out[2].unwrap(), 0.25, epsilon = 1e-12);
}
#[test]
fn constant_series_yields_zero() {
let mut mdd = MaxDrawdown::new(4).unwrap();
let out = mdd.batch(&[50.0; 12]);
for v in out.into_iter().flatten() {
assert_relative_eq!(v, 0.0, epsilon = 1e-12);
}
}
#[test]
fn ignores_non_finite_input() {
let mut mdd = MaxDrawdown::new(3).unwrap();
mdd.batch(&[100.0, 90.0, 80.0]);
let last = mdd.value();
assert_eq!(mdd.update(f64::NAN), last);
assert_eq!(mdd.update(f64::INFINITY), last);
}
#[test]
fn reset_clears_state() {
let mut mdd = MaxDrawdown::new(3).unwrap();
mdd.batch(&[100.0, 90.0, 80.0]);
assert!(mdd.is_ready());
mdd.reset();
assert!(!mdd.is_ready());
assert_eq!(mdd.update(100.0), None);
}
#[test]
fn batch_equals_streaming() {
let prices: Vec<f64> = (0..60)
.map(|i| 100.0 + (f64::from(i) * 0.3).sin() * 10.0)
.collect();
let batch = MaxDrawdown::new(10).unwrap().batch(&prices);
let mut s = MaxDrawdown::new(10).unwrap();
let streamed: Vec<_> = prices.iter().map(|p| s.update(*p)).collect();
assert_eq!(batch, streamed);
}
#[test]
fn non_positive_peak_yields_zero() {
// All-zero stream: peak is 0, division skipped, result stays 0.
let mut mdd = MaxDrawdown::new(3).unwrap();
let out = mdd.batch(&[0.0_f64; 6]);
for v in out.into_iter().flatten() {
assert_eq!(v, 0.0);
}
}
}
+34
View File
@@ -13,6 +13,7 @@ mod adx;
mod adxr;
mod alligator;
mod alma;
mod alpha;
mod anchored_vwap;
mod apo;
mod aroon;
@@ -21,12 +22,14 @@ mod atr;
mod atr_bands;
mod atr_trailing_stop;
mod autocorrelation;
mod average_drawdown;
mod awesome_oscillator;
mod awesome_oscillator_histogram;
mod balance_of_power;
mod beta;
mod bollinger;
mod bollinger_bandwidth;
mod calmar_ratio;
mod camarilla_pivots;
mod cci;
mod center_of_gravity;
@@ -40,6 +43,7 @@ mod classic_pivots;
mod cmf;
mod cmo;
mod coefficient_of_variation;
mod conditional_value_at_risk;
mod connors_rsi;
mod coppock;
mod cybernetic_cycle;
@@ -54,6 +58,7 @@ mod donchian;
mod donchian_stop;
mod double_bollinger;
mod dpo;
mod drawdown_duration;
mod ease_of_movement;
mod ehlers_stochastic;
mod elder_impulse;
@@ -67,6 +72,7 @@ mod fisher_transform;
mod force_index;
mod fractal_chaos_bands;
mod frama;
mod gain_loss_ratio;
mod garman_klass;
mod hammer;
mod hanging_man;
@@ -80,12 +86,14 @@ mod hurst_channel;
mod hurst_exponent;
mod ichimoku;
mod inertia;
mod information_ratio;
mod initial_balance;
mod instantaneous_trendline;
mod inverse_fisher_transform;
mod inverted_hammer;
mod jma;
mod kama;
mod kelly_criterion;
mod keltner;
mod kst;
mod kurtosis;
@@ -101,6 +109,7 @@ mod mama;
mod market_facilitation_index;
mod marubozu;
mod mass_index;
mod max_drawdown;
mod mcginley_dynamic;
mod median_absolute_deviation;
mod median_price;
@@ -110,7 +119,9 @@ mod morning_evening_star;
mod natr;
mod nvi;
mod obv;
mod omega_ratio;
mod opening_range;
mod pain_index;
mod parkinson;
mod pearson_correlation;
mod percent_b;
@@ -119,9 +130,11 @@ mod pgo;
mod piercing_dark_cloud;
mod pmo;
mod ppo;
mod profit_factor;
mod psar;
mod pvi;
mod r_squared;
mod recovery_factor;
mod renko_trailing_stop;
mod roc;
mod rogers_satchell;
@@ -130,12 +143,14 @@ mod rsi;
mod rvi;
mod rvi_volatility;
mod rwi;
mod sharpe_ratio;
mod shooting_star;
mod sine_wave;
mod skewness;
mod sma;
mod smi;
mod smma;
mod sortino_ratio;
mod spearman_correlation;
mod spinning_top;
mod standard_error;
@@ -166,6 +181,7 @@ mod three_inside;
mod three_outside;
mod three_soldiers_or_crows;
mod tii;
mod treynor_ratio;
mod trima;
mod trix;
mod true_range;
@@ -177,6 +193,7 @@ mod typical_price;
mod ulcer_index;
mod ultimate_oscillator;
mod value_area;
mod value_at_risk;
mod variance;
mod vertical_horizontal_filter;
mod vidya;
@@ -210,6 +227,7 @@ pub use adx::{Adx, AdxOutput};
pub use adxr::Adxr;
pub use alligator::{Alligator, AlligatorOutput};
pub use alma::Alma;
pub use alpha::Alpha;
pub use anchored_vwap::AnchoredVwap;
pub use apo::Apo;
pub use aroon::{Aroon, AroonOutput};
@@ -218,12 +236,14 @@ pub use atr::Atr;
pub use atr_bands::{AtrBands, AtrBandsOutput};
pub use atr_trailing_stop::AtrTrailingStop;
pub use autocorrelation::Autocorrelation;
pub use average_drawdown::AverageDrawdown;
pub use awesome_oscillator::AwesomeOscillator;
pub use awesome_oscillator_histogram::AwesomeOscillatorHistogram;
pub use balance_of_power::BalanceOfPower;
pub use beta::Beta;
pub use bollinger::{BollingerBands, BollingerOutput};
pub use bollinger_bandwidth::BollingerBandwidth;
pub use calmar_ratio::CalmarRatio;
pub use camarilla_pivots::{Camarilla, CamarillaPivotsOutput};
pub use cci::Cci;
pub use center_of_gravity::CenterOfGravity;
@@ -237,6 +257,7 @@ pub use classic_pivots::{ClassicPivots, ClassicPivotsOutput};
pub use cmf::ChaikinMoneyFlow;
pub use cmo::Cmo;
pub use coefficient_of_variation::CoefficientOfVariation;
pub use conditional_value_at_risk::ConditionalValueAtRisk;
pub use connors_rsi::ConnorsRsi;
pub use coppock::Coppock;
pub use cybernetic_cycle::CyberneticCycle;
@@ -251,6 +272,7 @@ pub use donchian::{Donchian, DonchianOutput};
pub use donchian_stop::{DonchianStop, DonchianStopOutput};
pub use double_bollinger::{DoubleBollinger, DoubleBollingerOutput};
pub use dpo::Dpo;
pub use drawdown_duration::DrawdownDuration;
pub use ease_of_movement::EaseOfMovement;
pub use ehlers_stochastic::EhlersStochastic;
pub use elder_impulse::ElderImpulse;
@@ -264,6 +286,7 @@ pub use fisher_transform::FisherTransform;
pub use force_index::ForceIndex;
pub use fractal_chaos_bands::{FractalChaosBands, FractalChaosBandsOutput};
pub use frama::Frama;
pub use gain_loss_ratio::GainLossRatio;
pub use garman_klass::GarmanKlassVolatility;
pub use hammer::Hammer;
pub use hanging_man::HangingMan;
@@ -277,12 +300,14 @@ pub use hurst_channel::{HurstChannel, HurstChannelOutput};
pub use hurst_exponent::HurstExponent;
pub use ichimoku::{Ichimoku, IchimokuOutput};
pub use inertia::Inertia;
pub use information_ratio::InformationRatio;
pub use initial_balance::{InitialBalance, InitialBalanceOutput};
pub use instantaneous_trendline::InstantaneousTrendline;
pub use inverse_fisher_transform::InverseFisherTransform;
pub use inverted_hammer::InvertedHammer;
pub use jma::Jma;
pub use kama::Kama;
pub use kelly_criterion::KellyCriterion;
pub use keltner::{Keltner, KeltnerOutput};
pub use kst::{Kst, KstOutput};
pub use kurtosis::Kurtosis;
@@ -298,6 +323,7 @@ pub use mama::{Mama, MamaOutput};
pub use market_facilitation_index::MarketFacilitationIndex;
pub use marubozu::Marubozu;
pub use mass_index::MassIndex;
pub use max_drawdown::MaxDrawdown;
pub use mcginley_dynamic::McGinleyDynamic;
pub use median_absolute_deviation::MedianAbsoluteDeviation;
pub use median_price::MedianPrice;
@@ -307,7 +333,9 @@ pub use morning_evening_star::MorningEveningStar;
pub use natr::Natr;
pub use nvi::Nvi;
pub use obv::Obv;
pub use omega_ratio::OmegaRatio;
pub use opening_range::{OpeningRange, OpeningRangeOutput};
pub use pain_index::PainIndex;
pub use parkinson::ParkinsonVolatility;
pub use pearson_correlation::PearsonCorrelation;
pub use percent_b::PercentB;
@@ -316,9 +344,11 @@ pub use pgo::Pgo;
pub use piercing_dark_cloud::PiercingDarkCloud;
pub use pmo::Pmo;
pub use ppo::Ppo;
pub use profit_factor::ProfitFactor;
pub use psar::Psar;
pub use pvi::Pvi;
pub use r_squared::RSquared;
pub use recovery_factor::RecoveryFactor;
pub use renko_trailing_stop::RenkoTrailingStop;
pub use roc::Roc;
pub use rogers_satchell::RogersSatchellVolatility;
@@ -327,12 +357,14 @@ pub use rsi::Rsi;
pub use rvi::Rvi;
pub use rvi_volatility::RviVolatility;
pub use rwi::{Rwi, RwiOutput};
pub use sharpe_ratio::SharpeRatio;
pub use shooting_star::ShootingStar;
pub use sine_wave::SineWave;
pub use skewness::Skewness;
pub use sma::Sma;
pub use smi::Smi;
pub use smma::Smma;
pub use sortino_ratio::SortinoRatio;
pub use spearman_correlation::SpearmanCorrelation;
pub use spinning_top::SpinningTop;
pub use standard_error::StandardError;
@@ -363,6 +395,7 @@ pub use three_inside::ThreeInside;
pub use three_outside::ThreeOutside;
pub use three_soldiers_or_crows::ThreeSoldiersOrCrows;
pub use tii::Tii;
pub use treynor_ratio::TreynorRatio;
pub use trima::Trima;
pub use trix::Trix;
pub use true_range::TrueRange;
@@ -374,6 +407,7 @@ pub use typical_price::TypicalPrice;
pub use ulcer_index::UlcerIndex;
pub use ultimate_oscillator::UltimateOscillator;
pub use value_area::{ValueArea, ValueAreaOutput};
pub use value_at_risk::ValueAtRisk;
pub use variance::Variance;
pub use vertical_horizontal_filter::VerticalHorizontalFilter;
pub use vidya::Vidya;
@@ -0,0 +1,194 @@
//! Rolling Omega Ratio — gain-to-loss ratio above a threshold.
use std::collections::VecDeque;
use crate::error::{Error, Result};
use crate::traits::Indicator;
/// Rolling Omega Ratio.
///
/// Over the trailing window of `period` returns and a target `threshold`:
///
/// ```text
/// gains = Σ max(0, r threshold)
/// losses = Σ max(0, threshold r)
/// Omega = gains / losses
/// ```
///
/// Omega expresses how many units of "above-threshold" return the strategy
/// produces per unit of "below-threshold" shortfall. By construction `Omega
/// ≥ 0`; a window where every return clears the threshold has zero losses and
/// the indicator returns `f64::INFINITY` (in keeping with the standard
/// definition). The Sharpe Ratio collapses risk into a single second-moment
/// number; Omega keeps the full shape of the loss tail.
///
/// Each `update` is O(period) because the partial sums are recomputed across
/// the window — adequate for typical backtest windows (`period ≤ 252`).
///
/// # Example
///
/// ```
/// use wickra_core::{Indicator, OmegaRatio};
///
/// let mut o = OmegaRatio::new(20, 0.0).unwrap();
/// let mut last = None;
/// for i in 0..40 {
/// last = o.update((f64::from(i) * 0.2).sin() * 0.01);
/// }
/// assert!(last.is_some());
/// ```
#[derive(Debug, Clone)]
pub struct OmegaRatio {
period: usize,
threshold: f64,
window: VecDeque<f64>,
}
impl OmegaRatio {
/// Construct a new rolling Omega Ratio.
///
/// # Errors
/// Returns [`Error::PeriodZero`] if `period == 0`.
pub fn new(period: usize, threshold: f64) -> Result<Self> {
if period == 0 {
return Err(Error::PeriodZero);
}
Ok(Self {
period,
threshold,
window: VecDeque::with_capacity(period),
})
}
/// Configured window length.
pub const fn period(&self) -> usize {
self.period
}
/// Configured threshold (per-period).
pub const fn threshold(&self) -> f64 {
self.threshold
}
}
impl Indicator for OmegaRatio {
type Input = f64;
type Output = f64;
fn update(&mut self, input: f64) -> Option<f64> {
if !input.is_finite() {
return None;
}
if self.window.len() == self.period {
self.window.pop_front();
}
self.window.push_back(input);
if self.window.len() < self.period {
return None;
}
let mut gains = 0.0_f64;
let mut losses = 0.0_f64;
for &r in &self.window {
let d = r - self.threshold;
if d >= 0.0 {
gains += d;
} else {
losses += -d;
}
}
if losses == 0.0 {
return Some(if gains == 0.0 { 0.0 } else { f64::INFINITY });
}
Some(gains / losses)
}
fn reset(&mut self) {
self.window.clear();
}
fn warmup_period(&self) -> usize {
self.period
}
fn is_ready(&self) -> bool {
self.window.len() == self.period
}
fn name(&self) -> &'static str {
"OmegaRatio"
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::traits::BatchExt;
use approx::assert_relative_eq;
#[test]
fn rejects_zero_period() {
assert!(matches!(OmegaRatio::new(0, 0.0), Err(Error::PeriodZero)));
}
#[test]
fn accessors_and_metadata() {
let o = OmegaRatio::new(10, 0.001).unwrap();
assert_eq!(o.period(), 10);
assert_relative_eq!(o.threshold(), 0.001, epsilon = 1e-12);
assert_eq!(o.name(), "OmegaRatio");
assert_eq!(o.warmup_period(), 10);
}
#[test]
fn all_above_threshold_yields_infinity() {
let mut o = OmegaRatio::new(4, 0.0).unwrap();
let out = o.batch(&[0.01, 0.02, 0.03, 0.04]);
assert!(out[3].unwrap().is_infinite());
}
#[test]
fn flat_at_threshold_yields_zero() {
// Every return equals threshold -> gains = losses = 0 -> 0 by
// convention.
let mut o = OmegaRatio::new(4, 0.01).unwrap();
let out = o.batch(&[0.01; 4]);
assert_eq!(out[3], Some(0.0));
}
#[test]
fn reference_value() {
// returns = [-0.02, 0.01, -0.01, 0.03], threshold = 0.
// gains = 0.01 + 0.03 = 0.04
// losses = 0.02 + 0.01 = 0.03
// Omega = 0.04 / 0.03 ≈ 1.3333...
let mut o = OmegaRatio::new(4, 0.0).unwrap();
let out = o.batch(&[-0.02, 0.01, -0.01, 0.03]);
assert_relative_eq!(out[3].unwrap(), 0.04 / 0.03, epsilon = 1e-9);
}
#[test]
fn ignores_non_finite_input() {
let mut o = OmegaRatio::new(3, 0.0).unwrap();
assert_eq!(o.update(f64::NAN), None);
assert_eq!(o.update(f64::INFINITY), None);
}
#[test]
fn reset_clears_state() {
let mut o = OmegaRatio::new(3, 0.0).unwrap();
o.batch(&[0.01, -0.02, 0.005]);
assert!(o.is_ready());
o.reset();
assert!(!o.is_ready());
assert_eq!(o.update(0.01), None);
}
#[test]
fn batch_equals_streaming() {
let returns: Vec<f64> = (0..50).map(|i| (f64::from(i) * 0.4).sin() * 0.01).collect();
let batch = OmegaRatio::new(10, 0.0).unwrap().batch(&returns);
let mut s = OmegaRatio::new(10, 0.0).unwrap();
let streamed: Vec<_> = returns.iter().map(|r| s.update(*r)).collect();
assert_eq!(batch, streamed);
}
}
@@ -0,0 +1,171 @@
//! Rolling Pain Index — mean depth of drawdowns.
use std::collections::VecDeque;
use crate::error::{Error, Result};
use crate::traits::Indicator;
/// Rolling Pain Index — Thomas Becker's continuous-pain risk measure.
///
/// Input is treated as an equity-curve sample. The Pain Index is the **mean**
/// drawdown depth over the trailing window of `period` bars, expressed as a
/// non-negative fraction:
///
/// ```text
/// peak_t = running max over window up to t
/// dd_t = (peak_t equity_t) / peak_t (0 if no drawdown)
/// PainIdx = mean(dd_t over window)
/// ```
///
/// Where Ulcer Index uses an RMS aggregation that punishes deep drawdowns
/// disproportionately, the Pain Index uses a plain arithmetic mean. The two
/// are normally similar; the Pain Index reads slightly lower on stresses with
/// a few large drawdowns and similar elsewhere.
///
/// Each `update` is O(period).
#[derive(Debug, Clone)]
pub struct PainIndex {
period: usize,
window: VecDeque<f64>,
}
impl PainIndex {
/// Construct a new rolling Pain Index.
///
/// # Errors
/// Returns [`Error::PeriodZero`] if `period == 0`.
pub fn new(period: usize) -> Result<Self> {
if period == 0 {
return Err(Error::PeriodZero);
}
Ok(Self {
period,
window: VecDeque::with_capacity(period),
})
}
/// Configured window length.
pub const fn period(&self) -> usize {
self.period
}
}
impl Indicator for PainIndex {
type Input = f64;
type Output = f64;
fn update(&mut self, input: f64) -> Option<f64> {
if !input.is_finite() {
return None;
}
if self.window.len() == self.period {
self.window.pop_front();
}
self.window.push_back(input);
if self.window.len() < self.period {
return None;
}
let mut peak = f64::NEG_INFINITY;
let mut sum_dd = 0.0_f64;
for &v in &self.window {
if v > peak {
peak = v;
}
if peak > 0.0 {
sum_dd += (peak - v) / peak;
}
}
Some(sum_dd / self.period as f64)
}
fn reset(&mut self) {
self.window.clear();
}
fn warmup_period(&self) -> usize {
self.period
}
fn is_ready(&self) -> bool {
self.window.len() == self.period
}
fn name(&self) -> &'static str {
"PainIndex"
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::traits::BatchExt;
use approx::assert_relative_eq;
#[test]
fn rejects_zero_period() {
assert!(matches!(PainIndex::new(0), Err(Error::PeriodZero)));
}
#[test]
fn accessors_and_metadata() {
let p = PainIndex::new(10).unwrap();
assert_eq!(p.period(), 10);
assert_eq!(p.name(), "PainIndex");
assert_eq!(p.warmup_period(), 10);
}
#[test]
fn pure_uptrend_yields_zero() {
let mut p = PainIndex::new(5).unwrap();
let out = p.batch(&(1..=20).map(f64::from).collect::<Vec<_>>());
for v in out.into_iter().flatten() {
assert_relative_eq!(v, 0.0, epsilon = 1e-12);
}
}
#[test]
fn reference_value() {
// window [100, 120, 90]: peaks 100,120,120; dd: 0, 0, 0.25.
// Pain = 0.25 / 3 ≈ 0.08333...
let mut p = PainIndex::new(3).unwrap();
let out = p.batch(&[100.0, 120.0, 90.0]);
assert_relative_eq!(out[2].unwrap(), 0.25 / 3.0, epsilon = 1e-12);
}
#[test]
fn ignores_non_finite_input() {
let mut p = PainIndex::new(3).unwrap();
assert_eq!(p.update(f64::NAN), None);
assert_eq!(p.update(f64::INFINITY), None);
}
#[test]
fn reset_clears_state() {
let mut p = PainIndex::new(3).unwrap();
p.batch(&[100.0, 90.0, 110.0]);
assert!(p.is_ready());
p.reset();
assert!(!p.is_ready());
assert_eq!(p.update(100.0), None);
}
#[test]
fn batch_equals_streaming() {
let prices: Vec<f64> = (0..40)
.map(|i| 100.0 + (f64::from(i) * 0.3).sin() * 8.0)
.collect();
let batch = PainIndex::new(10).unwrap().batch(&prices);
let mut s = PainIndex::new(10).unwrap();
let streamed: Vec<_> = prices.iter().map(|p| s.update(*p)).collect();
assert_eq!(batch, streamed);
}
#[test]
fn non_positive_peak_yields_zero() {
let mut p = PainIndex::new(3).unwrap();
let out = p.batch(&[0.0_f64; 6]);
for v in out.into_iter().flatten() {
assert_eq!(v, 0.0);
}
}
}
@@ -0,0 +1,179 @@
//! Rolling Profit Factor.
use std::collections::VecDeque;
use crate::error::{Error, Result};
use crate::traits::Indicator;
/// Rolling Profit Factor.
///
/// Input is treated as a per-period return (or a per-trade P&L). Over the
/// trailing window:
///
/// ```text
/// gross_profit = Σ max(0, r) over window
/// gross_loss = Σ max(0, r) over window
/// PF = gross_profit / gross_loss
/// ```
///
/// `PF > 1` means the strategy made more than it lost in the window. If
/// there were no losing returns the gross loss is zero and the indicator
/// returns `f64::INFINITY` (or `0.0` when there were also no gains —
/// a flat window).
///
/// Each `update` is O(period).
///
/// # Example
///
/// ```
/// use wickra_core::{Indicator, ProfitFactor};
///
/// let mut pf = ProfitFactor::new(20).unwrap();
/// let mut last = None;
/// for i in 0..40 {
/// last = pf.update((f64::from(i) * 0.2).sin() * 0.01);
/// }
/// assert!(last.is_some());
/// ```
#[derive(Debug, Clone)]
pub struct ProfitFactor {
period: usize,
window: VecDeque<f64>,
}
impl ProfitFactor {
/// Construct a new rolling Profit Factor.
///
/// # Errors
/// Returns [`Error::PeriodZero`] if `period == 0`.
pub fn new(period: usize) -> Result<Self> {
if period == 0 {
return Err(Error::PeriodZero);
}
Ok(Self {
period,
window: VecDeque::with_capacity(period),
})
}
/// Configured window length.
pub const fn period(&self) -> usize {
self.period
}
}
impl Indicator for ProfitFactor {
type Input = f64;
type Output = f64;
fn update(&mut self, input: f64) -> Option<f64> {
if !input.is_finite() {
return None;
}
if self.window.len() == self.period {
self.window.pop_front();
}
self.window.push_back(input);
if self.window.len() < self.period {
return None;
}
let mut gains = 0.0_f64;
let mut losses = 0.0_f64;
for &r in &self.window {
if r > 0.0 {
gains += r;
} else if r < 0.0 {
losses += -r;
}
}
if losses == 0.0 {
return Some(if gains == 0.0 { 0.0 } else { f64::INFINITY });
}
Some(gains / losses)
}
fn reset(&mut self) {
self.window.clear();
}
fn warmup_period(&self) -> usize {
self.period
}
fn is_ready(&self) -> bool {
self.window.len() == self.period
}
fn name(&self) -> &'static str {
"ProfitFactor"
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::traits::BatchExt;
use approx::assert_relative_eq;
#[test]
fn rejects_zero_period() {
assert!(matches!(ProfitFactor::new(0), Err(Error::PeriodZero)));
}
#[test]
fn accessors_and_metadata() {
let p = ProfitFactor::new(10).unwrap();
assert_eq!(p.period(), 10);
assert_eq!(p.name(), "ProfitFactor");
assert_eq!(p.warmup_period(), 10);
}
#[test]
fn reference_value() {
// returns = [0.02, -0.01, 0.03, -0.02]
// gains = 0.05, losses = 0.03, PF = 5/3.
let mut p = ProfitFactor::new(4).unwrap();
let out = p.batch(&[0.02, -0.01, 0.03, -0.02]);
assert_relative_eq!(out[3].unwrap(), 5.0 / 3.0, epsilon = 1e-9);
}
#[test]
fn no_losses_yields_infinity() {
let mut p = ProfitFactor::new(3).unwrap();
let out = p.batch(&[0.01, 0.02, 0.03]);
assert!(out[2].unwrap().is_infinite());
}
#[test]
fn flat_window_yields_zero() {
let mut p = ProfitFactor::new(3).unwrap();
let out = p.batch(&[0.0_f64; 3]);
assert_eq!(out[2], Some(0.0));
}
#[test]
fn ignores_non_finite_input() {
let mut p = ProfitFactor::new(3).unwrap();
assert_eq!(p.update(f64::NAN), None);
assert_eq!(p.update(f64::INFINITY), None);
}
#[test]
fn reset_clears_state() {
let mut p = ProfitFactor::new(3).unwrap();
p.batch(&[0.01, -0.02, 0.03]);
assert!(p.is_ready());
p.reset();
assert!(!p.is_ready());
assert_eq!(p.update(0.01), None);
}
#[test]
fn batch_equals_streaming() {
let returns: Vec<f64> = (0..40).map(|i| (f64::from(i) * 0.3).sin() * 0.01).collect();
let batch = ProfitFactor::new(10).unwrap().batch(&returns);
let mut s = ProfitFactor::new(10).unwrap();
let streamed: Vec<_> = returns.iter().map(|r| s.update(*r)).collect();
assert_eq!(batch, streamed);
}
}
@@ -0,0 +1,212 @@
//! Recovery Factor — cumulative net return over max drawdown.
use crate::traits::Indicator;
/// Recovery Factor.
///
/// Input is treated as an equity-curve sample (e.g. total account equity).
/// The indicator tracks the running all-time peak and the deepest drawdown
/// seen so far, plus the cumulative net return relative to the *first*
/// observation:
///
/// ```text
/// peak = max(equity since start)
/// trough_dd = max((peak equity) / peak)
/// net_return = (equity_last / equity_first) 1
/// Recovery = net_return / trough_dd
/// ```
///
/// `Recovery > 1` means the strategy has earned more than it ever lost on
/// the way. A pure up-trend has no drawdown and the indicator reports `0.0`
/// (the ratio is undefined; zero by convention).
///
/// Cumulative-from-start rather than rolling-windowed: the user resets to
/// re-start the count. Each `update` is O(1).
///
/// # Example
///
/// ```
/// use wickra_core::{Indicator, RecoveryFactor};
///
/// let mut r = RecoveryFactor::new();
/// // Equity climbs, drops 20%, recovers and exceeds original peak.
/// for v in [100.0, 110.0, 105.0, 95.0, 88.0, 100.0, 120.0, 130.0] {
/// r.update(v);
/// }
/// assert!(r.value().unwrap() > 0.0);
/// ```
#[derive(Debug, Clone, Default)]
pub struct RecoveryFactor {
first: f64,
last: f64,
peak: f64,
max_dd: f64,
seen: bool,
}
impl RecoveryFactor {
/// Construct a new Recovery Factor tracker.
pub const fn new() -> Self {
Self {
first: 0.0,
last: 0.0,
peak: f64::NEG_INFINITY,
max_dd: 0.0,
seen: false,
}
}
/// Current value if available.
pub fn value(&self) -> Option<f64> {
if !self.seen || self.first == 0.0 {
return None;
}
if self.max_dd == 0.0 {
return Some(0.0);
}
let net_return = (self.last / self.first) - 1.0;
Some(net_return / self.max_dd)
}
}
impl Indicator for RecoveryFactor {
type Input = f64;
type Output = f64;
fn update(&mut self, input: f64) -> Option<f64> {
if !input.is_finite() {
return self.value();
}
if self.seen {
if input > self.peak {
self.peak = input;
}
if self.peak > 0.0 {
let dd = (self.peak - input) / self.peak;
if dd > self.max_dd {
self.max_dd = dd;
}
}
} else {
self.first = input;
self.peak = input;
self.seen = true;
}
self.last = input;
self.value()
}
fn reset(&mut self) {
self.first = 0.0;
self.last = 0.0;
self.peak = f64::NEG_INFINITY;
self.max_dd = 0.0;
self.seen = false;
}
fn warmup_period(&self) -> usize {
1
}
fn is_ready(&self) -> bool {
self.seen && self.first != 0.0
}
fn name(&self) -> &'static str {
"RecoveryFactor"
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::traits::BatchExt;
use approx::assert_relative_eq;
#[test]
fn accessors_and_metadata() {
let r = RecoveryFactor::new();
assert_eq!(r.name(), "RecoveryFactor");
assert_eq!(r.warmup_period(), 1);
assert_eq!(r.value(), None);
}
#[test]
fn pure_uptrend_yields_zero() {
let mut r = RecoveryFactor::new();
for v in 1..=10 {
r.update(f64::from(v));
}
// max_dd == 0 -> 0 by convention.
assert_eq!(r.value(), Some(0.0));
}
#[test]
fn reference_value() {
// Start 100, peak 110, trough 88 -> max_dd = 0.2.
// End 130 -> net_return = 0.3 -> Recovery = 1.5.
let mut r = RecoveryFactor::new();
let out = r.batch(&[100.0, 110.0, 105.0, 95.0, 88.0, 100.0, 120.0, 130.0]);
let last = out.last().copied().unwrap().unwrap();
assert_relative_eq!(last, 0.30 / 0.20, epsilon = 1e-9);
}
#[test]
fn ignores_non_finite_input() {
let mut r = RecoveryFactor::new();
r.update(100.0);
r.update(90.0);
let v = r.value();
assert_eq!(r.update(f64::NAN), v);
assert_eq!(r.update(f64::INFINITY), v);
}
#[test]
fn first_value_alone_yields_zero() {
// First update: max_dd is still 0 -> 0 by convention; value defined.
let mut r = RecoveryFactor::new();
assert_eq!(r.update(100.0), Some(0.0));
}
#[test]
fn first_zero_equity_keeps_value_none() {
// first == 0 means net-return division would be 0/0; indicator stays
// not-ready until a non-zero baseline is reset in.
let mut r = RecoveryFactor::new();
assert_eq!(r.update(0.0), None);
assert!(!r.is_ready());
}
#[test]
fn reset_clears_state() {
let mut r = RecoveryFactor::new();
r.batch(&[100.0, 90.0, 80.0]);
assert!(r.is_ready());
r.reset();
assert!(!r.is_ready());
assert_eq!(r.update(100.0), Some(0.0));
}
#[test]
fn batch_equals_streaming() {
let prices: Vec<f64> = (0..40)
.map(|i| 100.0 + (f64::from(i) * 0.3).sin() * 8.0)
.collect();
let batch = RecoveryFactor::new().batch(&prices);
let mut s = RecoveryFactor::new();
let streamed: Vec<_> = prices.iter().map(|p| s.update(*p)).collect();
assert_eq!(batch, streamed);
}
#[test]
fn non_positive_peak_skips_drawdown_calc() {
// All inputs <= 0 keep `peak` non-positive, so the guarded drawdown
// computation is skipped on every step. Exercises the `else` branch
// of `if self.peak > 0.0`.
let mut r = RecoveryFactor::new();
assert_eq!(r.update(-1.0), Some(0.0));
assert_eq!(r.update(-2.0), Some(0.0));
assert_eq!(r.update(-0.5), Some(0.0));
assert!(r.is_ready());
}
}
@@ -0,0 +1,220 @@
//! Rolling Sharpe Ratio.
use std::collections::VecDeque;
use crate::error::{Error, Result};
use crate::traits::Indicator;
/// Rolling Sharpe Ratio over `period` period-returns.
///
/// The input is treated as a single period-return (e.g. one day's percentage
/// return). Over the trailing window of `period` returns the indicator
/// computes:
///
/// ```text
/// Sharpe = (mean(returns) risk_free_per_period) / stddev(returns)
/// ```
///
/// `stddev` is the sample standard deviation with `n 1` in the denominator.
/// `risk_free_per_period` is the per-period risk-free rate the caller supplies
/// (e.g. `0.0` for excess-of-zero or a daily-equivalent rate to match the
/// return frequency). Wickra does not annualise: feed already-annualised
/// returns and supply an annual risk-free rate if you want an annualised
/// Sharpe.
///
/// A flat window has zero standard deviation and Sharpe is undefined; the
/// indicator returns `0.0` in that case rather than producing `NaN`.
///
/// Each `update` is O(1) — Welford-style running sums maintain `Σr`, `Σr²`
/// as the window slides.
///
/// # Example
///
/// ```
/// use wickra_core::{Indicator, SharpeRatio};
///
/// let mut sr = SharpeRatio::new(20, 0.0).unwrap();
/// let mut last = None;
/// for i in 0..40 {
/// last = sr.update(0.001 + (f64::from(i) * 0.1).sin() * 0.01);
/// }
/// assert!(last.is_some());
/// ```
#[derive(Debug, Clone)]
pub struct SharpeRatio {
period: usize,
risk_free: f64,
window: VecDeque<f64>,
sum: f64,
sum_sq: f64,
}
impl SharpeRatio {
/// Construct a new rolling Sharpe Ratio with the given window and
/// per-period risk-free rate.
///
/// # Errors
/// Returns [`Error::InvalidPeriod`] if `period < 2` (sample standard
/// deviation needs at least two observations).
pub fn new(period: usize, risk_free: f64) -> Result<Self> {
if period < 2 {
return Err(Error::InvalidPeriod {
message: "sharpe ratio needs period >= 2",
});
}
Ok(Self {
period,
risk_free,
window: VecDeque::with_capacity(period),
sum: 0.0,
sum_sq: 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 SharpeRatio {
type Input = f64;
type Output = f64;
fn update(&mut self, input: f64) -> Option<f64> {
if !input.is_finite() {
return None;
}
if self.window.len() == self.period {
let old = self.window.pop_front().expect("non-empty");
self.sum -= old;
self.sum_sq -= old * old;
}
self.window.push_back(input);
self.sum += input;
self.sum_sq += input * input;
if self.window.len() < self.period {
return None;
}
let n = self.period as f64;
let mean = self.sum / n;
// Sample variance with Bessel's correction.
let var = (self.sum_sq - n * mean * mean).max(0.0) / (n - 1.0);
let sd = var.sqrt();
if sd == 0.0 {
return Some(0.0);
}
Some((mean - self.risk_free) / sd)
}
fn reset(&mut self) {
self.window.clear();
self.sum = 0.0;
self.sum_sq = 0.0;
}
fn warmup_period(&self) -> usize {
self.period
}
fn is_ready(&self) -> bool {
self.window.len() == self.period
}
fn name(&self) -> &'static str {
"SharpeRatio"
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::traits::BatchExt;
use approx::assert_relative_eq;
#[test]
fn rejects_period_less_than_two() {
assert!(matches!(
SharpeRatio::new(1, 0.0),
Err(Error::InvalidPeriod { .. })
));
assert!(matches!(
SharpeRatio::new(0, 0.0),
Err(Error::InvalidPeriod { .. })
));
}
#[test]
fn accessors_and_metadata() {
let sr = SharpeRatio::new(20, 0.001).unwrap();
assert_eq!(sr.period(), 20);
assert_relative_eq!(sr.risk_free(), 0.001, epsilon = 1e-12);
assert_eq!(sr.name(), "SharpeRatio");
assert_eq!(sr.warmup_period(), 20);
}
#[test]
fn constant_returns_yield_zero() {
let mut sr = SharpeRatio::new(5, 0.0).unwrap();
let out = sr.batch(&[0.01; 10]);
for v in out.into_iter().flatten() {
assert_relative_eq!(v, 0.0, epsilon = 1e-12);
}
}
#[test]
fn reference_value() {
// returns = [0.01, 0.02, 0.03, 0.04], rf = 0.
// mean = 0.025, var = ((0.01-.025)^2 + (.02-.025)^2 + (.03-.025)^2
// + (.04-.025)^2) / 3 = 0.00016666..., sd = sqrt(0.000166..) =
// 0.01290994..., Sharpe = 0.025 / 0.01290994 ≈ 1.936491673.
let mut sr = SharpeRatio::new(4, 0.0).unwrap();
let out = sr.batch(&[0.01, 0.02, 0.03, 0.04]);
let expected = 0.025_f64 / (0.000_166_666_666_666_666_67_f64).sqrt();
assert_relative_eq!(out[3].unwrap(), expected, epsilon = 1e-9);
}
#[test]
fn ignores_non_finite_input() {
let mut sr = SharpeRatio::new(3, 0.0).unwrap();
assert_eq!(sr.update(0.01), None);
assert_eq!(sr.update(f64::NAN), None);
assert_eq!(sr.update(0.02), None);
assert!(sr.update(0.03).is_some());
}
#[test]
fn warmup_returns_none() {
let mut sr = SharpeRatio::new(5, 0.0).unwrap();
for i in 0..4 {
assert_eq!(sr.update(f64::from(i) * 0.01), None);
}
assert!(sr.update(0.05).is_some());
}
#[test]
fn reset_clears_state() {
let mut sr = SharpeRatio::new(3, 0.0).unwrap();
sr.batch(&[0.01, 0.02, 0.03]);
assert!(sr.is_ready());
sr.reset();
assert!(!sr.is_ready());
assert_eq!(sr.update(0.01), None);
}
#[test]
fn batch_equals_streaming() {
let returns: Vec<f64> = (0..50)
.map(|i| 0.001 + (f64::from(i) * 0.2).sin() * 0.01)
.collect();
let batch = SharpeRatio::new(10, 0.0).unwrap().batch(&returns);
let mut s = SharpeRatio::new(10, 0.0).unwrap();
let streamed: Vec<_> = returns.iter().map(|p| s.update(*p)).collect();
assert_eq!(batch, streamed);
}
}
@@ -0,0 +1,197 @@
//! Rolling Sortino Ratio — Sharpe with downside-only volatility.
use std::collections::VecDeque;
use crate::error::{Error, Result};
use crate::traits::Indicator;
/// Rolling Sortino Ratio.
///
/// Like the Sharpe Ratio but only penalises **downside** volatility — returns
/// below the minimum acceptable return (`mar`). The numerator is excess return
/// over `mar`; the denominator is the downside deviation:
///
/// ```text
/// downside_dev = sqrt( mean( min(0, r mar)² over period ) )
/// Sortino = (mean(r) mar) / downside_dev
/// ```
///
/// Downside variance uses the population formula (`n` in the denominator)
/// since the negative-shortfall samples are treated as the full population.
/// If every return in the window is ≥ `mar` the downside deviation is `0`
/// and the indicator returns `0.0` rather than `NaN`.
///
/// Each `update` is O(1).
///
/// # Example
///
/// ```
/// use wickra_core::{Indicator, SortinoRatio};
///
/// let mut sr = SortinoRatio::new(20, 0.0).unwrap();
/// let mut last = None;
/// for i in 0..40 {
/// last = sr.update((f64::from(i) * 0.1).sin() * 0.01);
/// }
/// assert!(last.is_some());
/// ```
#[derive(Debug, Clone)]
pub struct SortinoRatio {
period: usize,
mar: f64,
window: VecDeque<f64>,
sum: f64,
}
impl SortinoRatio {
/// Construct a new rolling Sortino Ratio.
///
/// # Errors
/// Returns [`Error::InvalidPeriod`] if `period < 2`.
pub fn new(period: usize, mar: f64) -> Result<Self> {
if period < 2 {
return Err(Error::InvalidPeriod {
message: "sortino ratio needs period >= 2",
});
}
Ok(Self {
period,
mar,
window: VecDeque::with_capacity(period),
sum: 0.0,
})
}
/// Configured window length.
pub const fn period(&self) -> usize {
self.period
}
/// Configured minimum-acceptable return.
pub const fn mar(&self) -> f64 {
self.mar
}
}
impl Indicator for SortinoRatio {
type Input = f64;
type Output = f64;
fn update(&mut self, input: f64) -> Option<f64> {
if !input.is_finite() {
return None;
}
if self.window.len() == self.period {
let old = self.window.pop_front().expect("non-empty");
self.sum -= old;
}
self.window.push_back(input);
self.sum += input;
if self.window.len() < self.period {
return None;
}
let n = self.period as f64;
let mean = self.sum / n;
let mut downside_sq = 0.0;
for &r in &self.window {
let d = r - self.mar;
if d < 0.0 {
downside_sq += d * d;
}
}
let dd = (downside_sq / n).sqrt();
if dd == 0.0 {
return Some(0.0);
}
Some((mean - self.mar) / dd)
}
fn reset(&mut self) {
self.window.clear();
self.sum = 0.0;
}
fn warmup_period(&self) -> usize {
self.period
}
fn is_ready(&self) -> bool {
self.window.len() == self.period
}
fn name(&self) -> &'static str {
"SortinoRatio"
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::traits::BatchExt;
use approx::assert_relative_eq;
#[test]
fn rejects_period_less_than_two() {
assert!(matches!(
SortinoRatio::new(1, 0.0),
Err(Error::InvalidPeriod { .. })
));
}
#[test]
fn accessors_and_metadata() {
let s = SortinoRatio::new(10, 0.001).unwrap();
assert_eq!(s.period(), 10);
assert_relative_eq!(s.mar(), 0.001, epsilon = 1e-12);
assert_eq!(s.name(), "SortinoRatio");
assert_eq!(s.warmup_period(), 10);
}
#[test]
fn all_returns_above_mar_yields_zero_downside() {
let mut s = SortinoRatio::new(5, 0.0).unwrap();
let out = s.batch(&[0.01, 0.02, 0.03, 0.04, 0.05]);
// Downside deviation is 0 -> indicator returns 0.0.
assert_eq!(out[4], Some(0.0));
}
#[test]
fn reference_value() {
// returns = [-0.02, 0.01, -0.01, 0.03], mar = 0.
// mean = 0.0025, downside_sq = (0.02)^2 + (0.01)^2 = 0.0005;
// downside_dev = sqrt(0.0005 / 4) = sqrt(0.000125) ≈ 0.01118033...
// Sortino = 0.0025 / 0.011180339887 ≈ 0.2236068.
let mut s = SortinoRatio::new(4, 0.0).unwrap();
let out = s.batch(&[-0.02, 0.01, -0.01, 0.03]);
let expected = 0.0025 / (0.000_125_f64).sqrt();
assert_relative_eq!(out[3].unwrap(), expected, epsilon = 1e-9);
}
#[test]
fn ignores_non_finite_input() {
let mut s = SortinoRatio::new(3, 0.0).unwrap();
assert_eq!(s.update(f64::NAN), None);
assert_eq!(s.update(f64::INFINITY), None);
}
#[test]
fn reset_clears_state() {
let mut s = SortinoRatio::new(3, 0.0).unwrap();
s.batch(&[-0.01, -0.02, -0.005]);
assert!(s.is_ready());
s.reset();
assert!(!s.is_ready());
assert_eq!(s.update(0.01), None);
}
#[test]
fn batch_equals_streaming() {
let returns: Vec<f64> = (0..50)
.map(|i| 0.001 + (f64::from(i) * 0.3).sin() * 0.02)
.collect();
let batch = SortinoRatio::new(10, 0.0).unwrap().batch(&returns);
let mut s = SortinoRatio::new(10, 0.0).unwrap();
let streamed: Vec<_> = returns.iter().map(|r| s.update(*r)).collect();
assert_eq!(batch, streamed);
}
}
@@ -0,0 +1,224 @@
//! Rolling Treynor Ratio.
use std::collections::VecDeque;
use crate::error::{Error, Result};
use crate::traits::Indicator;
/// Rolling Treynor Ratio.
///
/// Each `update` receives one `(asset_return, benchmark_return)` pair. Over
/// the trailing window of `period` pairs:
///
/// ```text
/// cov_ab = (1/n) · Σ a·b ā·b̄
/// var_b = (1/n) · Σ b² b̄²
/// Beta = cov_ab / var_b
/// Treynor = (mean(asset) risk_free) / Beta
/// ```
///
/// Treynor is Sharpe's market-risk cousin: it divides excess return by the
/// asset's sensitivity to the benchmark (Beta) rather than by the asset's
/// own volatility. Useful for diversified portfolios where idiosyncratic
/// volatility has been mostly diversified away and the dominant remaining
/// risk is systematic / market exposure.
///
/// A flat benchmark window has zero variance and the indicator returns
/// `0.0` rather than `NaN`. A near-zero `Beta` makes the ratio explode by
/// construction; callers should treat extreme values with the usual care.
///
/// Each `update` is O(1) — running sums maintain `Σa`, `Σb`, `Σb²`, `Σa·b`
/// as the window slides.
#[derive(Debug, Clone)]
pub struct TreynorRatio {
period: usize,
risk_free: f64,
window: VecDeque<(f64, f64)>,
sum_a: f64,
sum_b: f64,
sum_bb: f64,
sum_ab: f64,
}
impl TreynorRatio {
/// Construct a new rolling Treynor Ratio.
///
/// # 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: "treynor ratio 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 TreynorRatio {
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 {
return Some(0.0);
}
let cov_ab = (self.sum_ab / n) - mean_a * mean_b;
let beta = cov_ab / var_b;
if beta == 0.0 {
return Some(0.0);
}
Some((mean_a - self.risk_free) / beta)
}
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 {
"TreynorRatio"
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::traits::BatchExt;
use approx::assert_relative_eq;
#[test]
fn rejects_period_less_than_two() {
assert!(matches!(
TreynorRatio::new(1, 0.0),
Err(Error::InvalidPeriod { .. })
));
}
#[test]
fn accessors_and_metadata() {
let t = TreynorRatio::new(20, 0.001).unwrap();
assert_eq!(t.period(), 20);
assert_relative_eq!(t.risk_free(), 0.001, epsilon = 1e-12);
assert_eq!(t.name(), "TreynorRatio");
assert_eq!(t.warmup_period(), 20);
}
#[test]
fn reference_beta_two_payoff() {
// a_i = 2 * b_i with non-zero mean.
// Beta should be 2; mean_a = 2 * mean_b; Treynor = mean_b.
let mut t = TreynorRatio::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 = t.batch(&inputs);
let last = out[19].unwrap();
let expected = inputs.iter().map(|(_, b)| *b).sum::<f64>() / 20.0;
assert_relative_eq!(last, expected, epsilon = 1e-9);
}
#[test]
fn flat_benchmark_yields_zero() {
// Benchmark all 0 -> var_b = 0 -> indicator returns 0.0.
let mut t = TreynorRatio::new(4, 0.0).unwrap();
let out = t.batch(&[(0.01, 0.0), (0.02, 0.0), (-0.01, 0.0), (0.03, 0.0)]);
assert_eq!(out[3], Some(0.0));
}
#[test]
fn ignores_non_finite_input() {
let mut t = TreynorRatio::new(3, 0.0).unwrap();
assert_eq!(t.update((f64::NAN, 0.0)), None);
assert_eq!(t.update((0.0, f64::INFINITY)), None);
}
#[test]
fn reset_clears_state() {
let mut t = TreynorRatio::new(3, 0.0).unwrap();
t.batch(&[(0.01, 0.005), (0.02, 0.01), (-0.01, -0.005)]);
assert!(t.is_ready());
t.reset();
assert!(!t.is_ready());
assert_eq!(t.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.001, b)
})
.collect();
let batch = TreynorRatio::new(10, 0.0).unwrap().batch(&inputs);
let mut s = TreynorRatio::new(10, 0.0).unwrap();
let streamed: Vec<_> = inputs.iter().map(|x| s.update(*x)).collect();
assert_eq!(batch, streamed);
}
#[test]
fn zero_beta_returns_zero() {
// Constant asset returns vs varying benchmark force cov(a,b) = 0,
// hence beta = 0 — the explicit zero-beta short-circuit.
let mut t = TreynorRatio::new(4, 0.0).unwrap();
let pairs: [(f64, f64); 4] = [(0.01, 0.005), (0.01, -0.002), (0.01, 0.001), (0.01, 0.003)];
let mut last = None;
for p in pairs {
last = t.update(p);
}
assert_eq!(last, Some(0.0));
}
}
@@ -0,0 +1,227 @@
//! Rolling historical Value-at-Risk (`VaR`).
use std::collections::VecDeque;
use crate::error::{Error, Result};
use crate::traits::Indicator;
/// Rolling historical Value-at-Risk.
///
/// Input is treated as a period return. Over the trailing window of `period`
/// returns the indicator reports the empirical lower-tail quantile at the
/// given `confidence` level (e.g. `0.95` = the 95 %-confident worst-case
/// loss). The output is the **magnitude** of that loss, sign-flipped to be a
/// non-negative number (so a 5 % `VaR` is reported as `0.05`, not `-0.05`):
///
/// ```text
/// q = (1 confidence)
/// VaR_t = percentile(returns over window, q · 100) if it is negative
/// VaR_t = 0 otherwise
/// ```
///
/// `percentile` uses linear interpolation between the two closest order
/// statistics ("type 7" in R / `NumPy` default). If the q-quantile of the
/// window is itself non-negative (a window where every return was at or above
/// zero) the indicator returns `0.0` — there is no loss to report.
///
/// Each `update` is O(period · log period) due to the window-sort. Good
/// enough for the typical `period ≤ 252` rolling-VaR workflow.
///
/// # Example
///
/// ```
/// use wickra_core::{Indicator, ValueAtRisk};
///
/// let mut var = ValueAtRisk::new(100, 0.95).unwrap();
/// let mut last = None;
/// for i in 0..120 {
/// last = var.update((f64::from(i) * 0.1).sin() * 0.02);
/// }
/// assert!(last.is_some());
/// ```
#[derive(Debug, Clone)]
pub struct ValueAtRisk {
period: usize,
confidence: f64,
window: VecDeque<f64>,
}
impl ValueAtRisk {
/// Construct a new rolling historical `VaR`.
///
/// # Errors
/// Returns [`Error::InvalidPeriod`] if `period < 2`, or if
/// `confidence` is outside the open interval `(0, 1)`.
pub fn new(period: usize, confidence: f64) -> Result<Self> {
if period < 2 {
return Err(Error::InvalidPeriod {
message: "value-at-risk needs period >= 2",
});
}
if !confidence.is_finite() || confidence <= 0.0 || confidence >= 1.0 {
return Err(Error::InvalidPeriod {
message: "confidence must lie strictly between 0 and 1",
});
}
Ok(Self {
period,
confidence,
window: VecDeque::with_capacity(period),
})
}
/// Configured window length.
pub const fn period(&self) -> usize {
self.period
}
/// Configured confidence level.
pub const fn confidence(&self) -> f64 {
self.confidence
}
}
/// Linear-interpolated percentile (type 7 / `NumPy` default) on a sorted slice.
fn percentile_sorted(sorted: &[f64], q: f64) -> f64 {
let n = sorted.len();
let pos = q * (n - 1) as f64;
let lo = pos.floor() as usize;
let hi = pos.ceil() as usize;
if lo == hi {
sorted[lo]
} else {
let frac = pos - lo as f64;
sorted[lo] + (sorted[hi] - sorted[lo]) * frac
}
}
impl Indicator for ValueAtRisk {
type Input = f64;
type Output = f64;
fn update(&mut self, input: f64) -> Option<f64> {
if !input.is_finite() {
return None;
}
if self.window.len() == self.period {
self.window.pop_front();
}
self.window.push_back(input);
if self.window.len() < self.period {
return None;
}
let mut sorted: Vec<f64> = self.window.iter().copied().collect();
sorted.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal));
let q = 1.0 - self.confidence;
let cut = percentile_sorted(&sorted, q);
// Loss magnitude (sign-flipped); 0 if quantile is non-negative.
Some((-cut).max(0.0))
}
fn reset(&mut self) {
self.window.clear();
}
fn warmup_period(&self) -> usize {
self.period
}
fn is_ready(&self) -> bool {
self.window.len() == self.period
}
fn name(&self) -> &'static str {
"ValueAtRisk"
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::traits::BatchExt;
use approx::assert_relative_eq;
#[test]
fn rejects_invalid_params() {
assert!(matches!(
ValueAtRisk::new(1, 0.95),
Err(Error::InvalidPeriod { .. })
));
assert!(matches!(
ValueAtRisk::new(20, 0.0),
Err(Error::InvalidPeriod { .. })
));
assert!(matches!(
ValueAtRisk::new(20, 1.0),
Err(Error::InvalidPeriod { .. })
));
assert!(matches!(
ValueAtRisk::new(20, f64::NAN),
Err(Error::InvalidPeriod { .. })
));
}
#[test]
fn accessors_and_metadata() {
let v = ValueAtRisk::new(100, 0.95).unwrap();
assert_eq!(v.period(), 100);
assert_relative_eq!(v.confidence(), 0.95, epsilon = 1e-12);
assert_eq!(v.name(), "ValueAtRisk");
assert_eq!(v.warmup_period(), 100);
}
#[test]
fn reference_value() {
// returns = -5,-4,-3,-2,-1,0,1,2,3,4 (each *0.01), confidence 0.95.
// q = 0.05, sorted positions 0..9, pos = 0.05*9 = 0.45,
// -> -0.05 + (-0.04 - (-0.05))*0.45 = -0.05 + 0.0045 = -0.0455.
// VaR = 0.0455.
let mut v = ValueAtRisk::new(10, 0.95).unwrap();
let returns: Vec<f64> = (-5..5).map(|i| f64::from(i) * 0.01).collect();
let out = v.batch(&returns);
assert_relative_eq!(out[9].unwrap(), 0.0455, epsilon = 1e-9);
}
#[test]
fn all_positive_returns_yield_zero() {
let mut v = ValueAtRisk::new(5, 0.95).unwrap();
let out = v.batch(&[0.01, 0.02, 0.03, 0.04, 0.05]);
assert_eq!(out[4], Some(0.0));
}
#[test]
fn ignores_non_finite_input() {
let mut v = ValueAtRisk::new(3, 0.95).unwrap();
assert_eq!(v.update(f64::NAN), None);
assert_eq!(v.update(f64::INFINITY), None);
}
#[test]
fn reset_clears_state() {
let mut v = ValueAtRisk::new(3, 0.95).unwrap();
v.batch(&[-0.01, -0.02, -0.03]);
assert!(v.is_ready());
v.reset();
assert!(!v.is_ready());
assert_eq!(v.update(0.01), None);
}
#[test]
fn batch_equals_streaming() {
let returns: Vec<f64> = (0..50).map(|i| (f64::from(i) * 0.2).sin() * 0.02).collect();
let batch = ValueAtRisk::new(10, 0.95).unwrap().batch(&returns);
let mut s = ValueAtRisk::new(10, 0.95).unwrap();
let streamed: Vec<_> = returns.iter().map(|r| s.update(*r)).collect();
assert_eq!(batch, streamed);
}
#[test]
fn integer_position_quantile_branch() {
// period=5, confidence=0.75 -> q=0.25, n-1=4 -> pos=1.0 (integer),
// so the percentile helper takes the `lo == hi` branch.
let mut v = ValueAtRisk::new(5, 0.75).unwrap();
let out = v.batch(&[-0.05, -0.04, -0.03, -0.02, -0.01]);
// sorted = same order; sorted[1] = -0.04, so VaR = 0.04 exactly.
assert_relative_eq!(out[4].unwrap(), 0.04, epsilon = 1e-12);
}
}