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:
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user