Skip to main content

finance_query/risk/
mod.rs

1//! Standalone risk analytics.
2//!
3//! Requires the **`risk`** feature flag (which implies **`indicators`**).
4//!
5//! Provides Value at Risk, Conditional VaR (Expected Shortfall),
6//! Sharpe/Sortino/Calmar ratios, Omega Ratio, Kelly Criterion, beta, max
7//! drawdown, Ulcer Index, Information Ratio, and tracking error as standalone
8//! metrics — independent of the backtesting engine.
9//!
10//! # Quick Start
11//!
12//! ```no_run
13//! use finance_query::{Ticker, Interval, TimeRange};
14//!
15//! # async fn example() -> Result<(), Box<dyn std::error::Error>> {
16//! let ticker = Ticker::new("AAPL").await?;
17//! let summary = ticker.risk(Interval::OneDay, TimeRange::OneYear, None).await?;
18//!
19//! println!("VaR (95%):      {:.2}%", summary.var_95 * 100.0);
20//! println!("Max drawdown:   {:.2}%", summary.max_drawdown * 100.0);
21//! if let Some(sharpe) = summary.sharpe {
22//!     println!("Sharpe ratio:   {sharpe:.2}");
23//! }
24//! # Ok(())
25//! # }
26//! ```
27
28mod beta;
29mod cvar;
30mod drawdown;
31mod ratios;
32mod var;
33
34pub use self::beta::beta;
35pub use self::cvar::{historical_cvar, parametric_cvar};
36pub use self::drawdown::max_drawdown;
37pub use self::ratios::{
38    calmar_ratio, information_ratio, kelly_criterion, omega_ratio, sharpe_ratio, sortino_ratio,
39    tracking_error, ulcer_index, win_loss_stats,
40};
41pub use self::var::{historical_var, parametric_var};
42
43use crate::models::chart::Candle;
44use serde::{Deserialize, Serialize};
45
46/// Comprehensive risk summary for a symbol.
47///
48/// Obtain via [`Ticker::risk`](crate::Ticker::risk).
49#[derive(Debug, Clone, Serialize, Deserialize)]
50#[non_exhaustive]
51pub struct RiskSummary {
52    /// 1-day historical Value at Risk at 95% confidence (expressed as positive loss fraction)
53    pub var_95: f64,
54    /// 1-day historical Value at Risk at 99% confidence
55    pub var_99: f64,
56    /// 1-day parametric VaR at 95% confidence (assumes normally distributed returns)
57    pub parametric_var_95: f64,
58    /// 1-day historical Conditional VaR (Expected Shortfall) at 95% confidence —
59    /// the average loss in the worst 5% of historical periods.
60    pub cvar_95: f64,
61    /// 1-day historical Conditional VaR at 99% confidence.
62    pub cvar_99: f64,
63    /// 1-day parametric Conditional VaR at 95% confidence (assumes normally
64    /// distributed returns).
65    pub parametric_cvar_95: f64,
66    /// Omega Ratio at a 0.0 threshold: probability-weighted ratio of gains to
67    /// losses over the full return distribution. `f64::MAX` when there are no
68    /// negative-return periods.
69    pub omega: f64,
70    /// Kelly Criterion: optimal fraction of capital to risk, treating each
71    /// positive-return period as a "win" and each negative-return period as a
72    /// "loss". `f64::MAX` when there are no losing periods and wins are
73    /// positive (unbounded edge).
74    pub kelly: f64,
75    /// Annualised Sharpe Ratio (risk-free rate = 0, 252 trading days/year).
76    /// `None` when fewer than 2 periods or zero volatility.
77    pub sharpe: Option<f64>,
78    /// Annualised Sortino Ratio (penalises only downside volatility).
79    /// `None` when fewer than 2 periods or zero downside deviation.
80    pub sortino: Option<f64>,
81    /// Calmar Ratio (annualised return / max drawdown).
82    /// `None` when max drawdown is zero.
83    pub calmar: Option<f64>,
84    /// Beta vs benchmark. `None` when no benchmark is provided or data is insufficient.
85    pub beta: Option<f64>,
86    /// Maximum drawdown as a positive fraction (e.g., 0.30 = 30%)
87    pub max_drawdown: f64,
88    /// Number of trading periods to recover from the maximum drawdown.
89    /// `None` when no recovery occurred within the data window.
90    pub max_drawdown_recovery_periods: Option<u64>,
91    /// Ulcer Index: root-mean-square of drawdown depth across all periods,
92    /// expressed as a percentage (0–100). Penalises both depth and duration of
93    /// drawdowns, unlike `max_drawdown` which only reports the worst single one.
94    pub ulcer_index: f64,
95    /// Information Ratio vs benchmark: annualised mean excess return divided by
96    /// tracking error. `None` when no benchmark is provided or data is insufficient.
97    pub information_ratio: Option<f64>,
98    /// Tracking error vs benchmark: annualised standard deviation of
99    /// (asset − benchmark) periodic returns. `None` when no benchmark is
100    /// provided or data is insufficient.
101    pub tracking_error: Option<f64>,
102}
103
104/// Compute returns from a slice of candles (simple daily returns: close-to-close).
105pub(crate) fn candles_to_returns(candles: &[Candle]) -> Vec<f64> {
106    candles
107        .windows(2)
108        .map(|w| (w[1].close - w[0].close) / w[0].close)
109        .collect()
110}
111
112/// Trading calendar used to annualise risk ratios — different asset classes
113/// have different period counts per year.
114// Which variants are constructed depends on enabled provider features (each
115// domain handle picks one), so some are unused under a given feature set.
116#[allow(dead_code)]
117#[derive(Debug, Clone, Copy, PartialEq, Eq)]
118pub(crate) enum TradingCalendar {
119    /// Exchange-traded (equities, indices, futures, commodities): 252 trading
120    /// days, ~6.5h sessions.
121    Exchange,
122    /// Foreign exchange: 24h trading, 5 days/week (~260 days/year).
123    Forex,
124    /// Crypto: continuous 24/7 trading (365 days/year).
125    Crypto,
126}
127
128// Only constructed/called by the domain-handle `risk()` macro in
129// `src/domains/mod.rs`, gated behind provider features (alphavantage, fmp,
130// polygon, crypto, ...) that may all be disabled under a narrower feature
131// set (e.g. `--features indicators,risk,backtesting` alone), same reasoning
132// as the `#[allow(dead_code)]` on `TradingCalendar` above.
133#[allow(dead_code)]
134impl TradingCalendar {
135    fn trading_days(self) -> f64 {
136        match self {
137            TradingCalendar::Exchange => 252.0,
138            TradingCalendar::Forex => 260.0,
139            TradingCalendar::Crypto => 365.0,
140        }
141    }
142
143    fn session_hours(self) -> f64 {
144        match self {
145            TradingCalendar::Exchange => 6.5,
146            TradingCalendar::Forex | TradingCalendar::Crypto => 24.0,
147        }
148    }
149}
150
151/// Number of `interval` periods in a trading year for the given calendar — the
152/// annualisation factor for Sharpe/Sortino/Calmar. Day/week/month/quarter are
153/// exact; intraday scales the daily count by the session length.
154#[allow(dead_code)]
155pub(crate) fn periods_per_year(interval: crate::Interval, cal: TradingCalendar) -> f64 {
156    use crate::Interval;
157    let days = cal.trading_days();
158    match interval {
159        Interval::OneDay => days,
160        Interval::FiveDays | Interval::OneWeek => 52.0,
161        Interval::OneMonth => 12.0,
162        Interval::ThreeMonths => 4.0,
163        Interval::NinetyMinutes => days * cal.session_hours() / 1.5,
164        Interval::OneHour => days * cal.session_hours(),
165        Interval::ThirtyMinutes => days * cal.session_hours() * 2.0,
166        Interval::FifteenMinutes => days * cal.session_hours() * 4.0,
167        Interval::FiveMinutes => days * cal.session_hours() * 12.0,
168        Interval::TwoMinutes => days * cal.session_hours() * 30.0,
169        Interval::OneMinute => days * cal.session_hours() * 60.0,
170    }
171}
172
173/// Build a [`RiskSummary`] from candle data and an optional benchmark return
174/// series, using the default daily exchange calendar (252 periods/year).
175pub(crate) fn compute_risk_summary(
176    candles: &[Candle],
177    benchmark_returns: Option<&[f64]>,
178) -> RiskSummary {
179    compute_risk_summary_with_periods(candles, benchmark_returns, 252.0)
180}
181
182/// Build a [`RiskSummary`] with an explicit annualisation factor
183/// (`periods_per_year`), so non-daily intervals and non-equity asset classes
184/// annualise correctly. See [`periods_per_year`].
185pub(crate) fn compute_risk_summary_with_periods(
186    candles: &[Candle],
187    benchmark_returns: Option<&[f64]>,
188    periods_per_year: f64,
189) -> RiskSummary {
190    let returns = candles_to_returns(candles);
191
192    let mut sorted = returns.clone();
193    sorted.sort_by(|a, b| a.total_cmp(b));
194    let stats = ratios::mean_and_std(&returns);
195
196    let var_95 = var::historical_var_sorted(&sorted, 0.95).unwrap_or(0.0);
197    let var_99 = var::historical_var_sorted(&sorted, 0.99).unwrap_or(0.0);
198    let parametric_var_95 = stats
199        .map(|(m, s)| var::parametric_var_with_stats(m, s, 0.95))
200        .unwrap_or(0.0);
201
202    let cvar_95 = cvar::historical_cvar_sorted(&sorted, 0.95).unwrap_or(0.0);
203    let cvar_99 = cvar::historical_cvar_sorted(&sorted, 0.99).unwrap_or(0.0);
204    let parametric_cvar_95 = stats
205        .map(|(m, s)| cvar::parametric_cvar_with_stats(m, s, 0.95))
206        .unwrap_or(0.0);
207
208    let omega = omega_ratio(&returns);
209    let (win_rate, avg_win_pct, avg_loss_pct) = win_loss_stats(&returns);
210    let kelly = kelly_criterion(win_rate, avg_win_pct, avg_loss_pct);
211
212    let sharpe = stats.and_then(|(m, s)| ratios::sharpe_with_stats(m, s, 0.0, periods_per_year));
213    let sortino = sortino_ratio(&returns, 0.0, periods_per_year);
214
215    let dd = max_drawdown(&returns);
216    let total_return = returns.iter().fold(1.0_f64, |acc, r| acc * (1.0 + r)) - 1.0;
217    let years = returns.len() as f64 / periods_per_year;
218    let calmar = calmar_ratio(total_return, years, dd.max_drawdown);
219    let ulcer_index_val = ulcer_index(&returns);
220
221    let beta_val = benchmark_returns.and_then(|br| beta(&returns, br));
222    let information_ratio_val =
223        benchmark_returns.and_then(|br| information_ratio(&returns, br, periods_per_year));
224    let tracking_error_val =
225        benchmark_returns.and_then(|br| tracking_error(&returns, br, periods_per_year));
226
227    RiskSummary {
228        var_95,
229        var_99,
230        parametric_var_95,
231        cvar_95,
232        cvar_99,
233        parametric_cvar_95,
234        omega,
235        kelly,
236        sharpe,
237        sortino,
238        calmar,
239        beta: beta_val,
240        max_drawdown: dd.max_drawdown,
241        max_drawdown_recovery_periods: dd.recovery_periods,
242        ulcer_index: ulcer_index_val,
243        information_ratio: information_ratio_val,
244        tracking_error: tracking_error_val,
245    }
246}
247
248#[cfg(test)]
249mod tests {
250    use super::*;
251
252    fn make_candle(close: f64) -> Candle {
253        Candle {
254            timestamp: 0,
255            open: close,
256            high: close,
257            low: close,
258            close,
259            volume: 1_000_000,
260            adj_close: None,
261            provider_id: None,
262        }
263    }
264
265    #[test]
266    fn test_compute_risk_summary_flat() {
267        // Constant prices → zero returns → zero VaR, no ratios
268        let candles: Vec<Candle> = (0..=252).map(|_| make_candle(100.0)).collect();
269        let summary = compute_risk_summary(&candles, None);
270        assert_eq!(summary.var_95, 0.0);
271        assert_eq!(summary.max_drawdown, 0.0);
272        assert!(summary.sharpe.is_none());
273        // New metrics: degenerate for a flat (zero-return) series.
274        assert_eq!(summary.cvar_95, 0.0);
275        assert_eq!(summary.cvar_99, 0.0);
276        assert_eq!(summary.parametric_cvar_95, 0.0);
277        assert_eq!(summary.omega, 0.0);
278        assert_eq!(summary.kelly, 0.0);
279        assert_eq!(summary.ulcer_index, 0.0);
280        assert!(summary.information_ratio.is_none());
281        assert!(summary.tracking_error.is_none());
282    }
283
284    #[test]
285    fn test_cvar_at_least_as_severe_as_var() {
286        // A volatile, mostly-declining series should have CVaR >= VaR (CVaR
287        // averages the tail beyond the VaR threshold, so it's at least as bad).
288        let closes: Vec<f64> = (0..60)
289            .map(|i| 100.0 - i as f64 * 0.5 + if i % 5 == 0 { -8.0 } else { 0.0 })
290            .collect();
291        let candles: Vec<Candle> = closes.into_iter().map(make_candle).collect();
292        let summary = compute_risk_summary(&candles, None);
293        assert!(
294            summary.cvar_95 >= summary.var_95,
295            "cvar_95 ({}) should be >= var_95 ({})",
296            summary.cvar_95,
297            summary.var_95
298        );
299        assert!(summary.parametric_cvar_95 >= summary.parametric_var_95);
300    }
301
302    #[test]
303    fn test_drawdown_produces_positive_ulcer_index() {
304        // A single sharp drawdown followed by recovery should score a
305        // positive (nonzero) Ulcer Index.
306        let closes = [100.0, 110.0, 80.0, 85.0, 105.0, 115.0];
307        let candles: Vec<Candle> = closes.into_iter().map(make_candle).collect();
308        let summary = compute_risk_summary(&candles, None);
309        assert!(summary.ulcer_index > 0.0);
310        assert!(summary.max_drawdown > 0.0);
311    }
312
313    #[test]
314    fn test_information_ratio_and_tracking_error_with_benchmark() {
315        let asset_closes: Vec<f64> = (0..30).map(|i| 100.0 + i as f64 * 1.2).collect();
316        let bench_closes: Vec<f64> = (0..30).map(|i| 100.0 + i as f64 * 0.8).collect();
317        let candles: Vec<Candle> = asset_closes.into_iter().map(make_candle).collect();
318        let bench_candles: Vec<Candle> = bench_closes.into_iter().map(make_candle).collect();
319        let bench_returns = candles_to_returns(&bench_candles);
320
321        let summary = compute_risk_summary(&candles, Some(&bench_returns));
322        assert!(summary.information_ratio.is_some());
323        assert!(summary.tracking_error.is_some());
324        assert!(summary.tracking_error.unwrap() > 0.0);
325        // Asset outperforms the benchmark every period -> positive excess return.
326        assert!(summary.information_ratio.unwrap() > 0.0);
327    }
328
329    #[test]
330    fn test_candles_to_returns_basic() {
331        let candles = vec![make_candle(100.0), make_candle(110.0), make_candle(99.0)];
332        let returns = candles_to_returns(&candles);
333        assert_eq!(returns.len(), 2);
334        assert!((returns[0] - 0.10).abs() < 1e-9);
335        assert!((returns[1] - (-0.1)).abs() < 0.01);
336    }
337
338    #[test]
339    fn test_candles_to_returns_empty_and_single() {
340        assert!(candles_to_returns(&[]).is_empty());
341        assert!(candles_to_returns(&[make_candle(100.0)]).is_empty());
342
343        let empty_summary = compute_risk_summary(&[], None);
344        assert_eq!(empty_summary.var_95, 0.0);
345        assert_eq!(empty_summary.var_99, 0.0);
346        assert_eq!(empty_summary.parametric_var_95, 0.0);
347        assert!(empty_summary.sharpe.is_none());
348
349        let single_summary = compute_risk_summary(&[make_candle(100.0)], None);
350        assert_eq!(single_summary.var_95, 0.0);
351        assert_eq!(single_summary.var_99, 0.0);
352        assert_eq!(single_summary.parametric_var_95, 0.0);
353        assert!(single_summary.sharpe.is_none());
354    }
355
356    #[test]
357    fn test_periods_per_year_by_calendar() {
358        use crate::Interval;
359        // Daily differs by asset class; calendar periodicity is shared.
360        assert_eq!(
361            periods_per_year(Interval::OneDay, TradingCalendar::Exchange),
362            252.0
363        );
364        assert_eq!(
365            periods_per_year(Interval::OneDay, TradingCalendar::Forex),
366            260.0
367        );
368        assert_eq!(
369            periods_per_year(Interval::OneDay, TradingCalendar::Crypto),
370            365.0
371        );
372        assert_eq!(
373            periods_per_year(Interval::OneWeek, TradingCalendar::Crypto),
374            52.0
375        );
376        // Intraday scales daily count by session length (24h crypto > 6.5h exchange).
377        assert!(
378            periods_per_year(Interval::OneHour, TradingCalendar::Crypto)
379                > periods_per_year(Interval::OneHour, TradingCalendar::Exchange)
380        );
381    }
382
383    #[test]
384    fn test_annualization_factor_changes_sharpe() {
385        // Same returns, different periods_per_year → different annualised Sharpe.
386        let candles: Vec<Candle> = (0..50).map(|i| make_candle(100.0 + i as f64)).collect();
387        let daily = compute_risk_summary_with_periods(&candles, None, 252.0);
388        let crypto = compute_risk_summary_with_periods(&candles, None, 365.0);
389        assert!(daily.sharpe.is_some() && crypto.sharpe.is_some());
390        assert!(crypto.sharpe.unwrap() > daily.sharpe.unwrap());
391    }
392
393    #[test]
394    fn shared_stats_match_standalone_functions() {
395        let returns: Vec<f64> = (0..2_000)
396            .map(|i| ((i as f64 * 0.37).sin()) * 0.02 - 0.0001)
397            .collect();
398
399        let mut sorted = returns.clone();
400        sorted.sort_by(|a, b| a.total_cmp(b));
401        let (mean, std_dev) = crate::risk::ratios::mean_and_std(&returns).unwrap();
402
403        assert_eq!(
404            crate::risk::var::historical_var_sorted(&sorted, 0.95),
405            crate::risk::historical_var(&returns, 0.95)
406        );
407        assert_eq!(
408            crate::risk::var::historical_var_sorted(&sorted, 0.99),
409            crate::risk::historical_var(&returns, 0.99)
410        );
411        assert_eq!(
412            Some(crate::risk::var::parametric_var_with_stats(
413                mean, std_dev, 0.95
414            )),
415            crate::risk::parametric_var(&returns, 0.95)
416        );
417        assert_eq!(
418            crate::risk::ratios::sharpe_with_stats(mean, std_dev, 0.0, 252.0),
419            crate::risk::sharpe_ratio(&returns, 0.0, 252.0)
420        );
421    }
422}