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, Sharpe/Sortino/Calmar ratios, beta, and max drawdown
6//! as standalone metrics — independent of the backtesting engine.
7//!
8//! # Quick Start
9//!
10//! ```no_run
11//! use finance_query::{Ticker, Interval, TimeRange};
12//!
13//! # async fn example() -> Result<(), Box<dyn std::error::Error>> {
14//! let ticker = Ticker::new("AAPL").await?;
15//! let summary = ticker.risk(Interval::OneDay, TimeRange::OneYear, None).await?;
16//!
17//! println!("VaR (95%):      {:.2}%", summary.var_95 * 100.0);
18//! println!("Max drawdown:   {:.2}%", summary.max_drawdown * 100.0);
19//! if let Some(sharpe) = summary.sharpe {
20//!     println!("Sharpe ratio:   {sharpe:.2}");
21//! }
22//! # Ok(())
23//! # }
24//! ```
25
26mod beta;
27mod drawdown;
28mod ratios;
29mod var;
30
31pub use self::beta::beta;
32pub use self::drawdown::max_drawdown;
33pub use self::ratios::{calmar_ratio, sharpe_ratio, sortino_ratio};
34pub use self::var::{historical_var, parametric_var};
35
36use crate::models::chart::Candle;
37use serde::{Deserialize, Serialize};
38
39/// Comprehensive risk summary for a symbol.
40///
41/// Obtain via [`Ticker::risk`](crate::Ticker::risk).
42#[derive(Debug, Clone, Serialize, Deserialize)]
43#[non_exhaustive]
44pub struct RiskSummary {
45    /// 1-day historical Value at Risk at 95% confidence (expressed as positive loss fraction)
46    pub var_95: f64,
47    /// 1-day historical Value at Risk at 99% confidence
48    pub var_99: f64,
49    /// 1-day parametric VaR at 95% confidence (assumes normally distributed returns)
50    pub parametric_var_95: f64,
51    /// Annualised Sharpe Ratio (risk-free rate = 0, 252 trading days/year).
52    /// `None` when fewer than 2 periods or zero volatility.
53    pub sharpe: Option<f64>,
54    /// Annualised Sortino Ratio (penalises only downside volatility).
55    /// `None` when fewer than 2 periods or zero downside deviation.
56    pub sortino: Option<f64>,
57    /// Calmar Ratio (annualised return / max drawdown).
58    /// `None` when max drawdown is zero.
59    pub calmar: Option<f64>,
60    /// Beta vs benchmark. `None` when no benchmark is provided or data is insufficient.
61    pub beta: Option<f64>,
62    /// Maximum drawdown as a positive fraction (e.g., 0.30 = 30%)
63    pub max_drawdown: f64,
64    /// Number of trading periods to recover from the maximum drawdown.
65    /// `None` when no recovery occurred within the data window.
66    pub max_drawdown_recovery_periods: Option<u64>,
67}
68
69/// Compute returns from a slice of candles (simple daily returns: close-to-close).
70pub(crate) fn candles_to_returns(candles: &[Candle]) -> Vec<f64> {
71    candles
72        .windows(2)
73        .map(|w| (w[1].close - w[0].close) / w[0].close)
74        .collect()
75}
76
77/// Trading calendar used to annualise risk ratios — different asset classes
78/// have different period counts per year.
79// Which variants are constructed depends on enabled provider features (each
80// domain handle picks one), so some are unused under a given feature set.
81#[allow(dead_code)]
82#[derive(Debug, Clone, Copy, PartialEq, Eq)]
83pub(crate) enum TradingCalendar {
84    /// Exchange-traded (equities, indices, futures, commodities): 252 trading
85    /// days, ~6.5h sessions.
86    Exchange,
87    /// Foreign exchange: 24h trading, 5 days/week (~260 days/year).
88    Forex,
89    /// Crypto: continuous 24/7 trading (365 days/year).
90    Crypto,
91}
92
93impl TradingCalendar {
94    fn trading_days(self) -> f64 {
95        match self {
96            TradingCalendar::Exchange => 252.0,
97            TradingCalendar::Forex => 260.0,
98            TradingCalendar::Crypto => 365.0,
99        }
100    }
101
102    fn session_hours(self) -> f64 {
103        match self {
104            TradingCalendar::Exchange => 6.5,
105            TradingCalendar::Forex | TradingCalendar::Crypto => 24.0,
106        }
107    }
108}
109
110/// Number of `interval` periods in a trading year for the given calendar — the
111/// annualisation factor for Sharpe/Sortino/Calmar. Day/week/month/quarter are
112/// exact; intraday scales the daily count by the session length.
113pub(crate) fn periods_per_year(interval: crate::Interval, cal: TradingCalendar) -> f64 {
114    use crate::Interval;
115    let days = cal.trading_days();
116    match interval {
117        Interval::OneDay => days,
118        Interval::OneWeek => 52.0,
119        Interval::OneMonth => 12.0,
120        Interval::ThreeMonths => 4.0,
121        Interval::OneHour => days * cal.session_hours(),
122        Interval::ThirtyMinutes => days * cal.session_hours() * 2.0,
123        Interval::FifteenMinutes => days * cal.session_hours() * 4.0,
124        Interval::FiveMinutes => days * cal.session_hours() * 12.0,
125        Interval::OneMinute => days * cal.session_hours() * 60.0,
126    }
127}
128
129/// Build a [`RiskSummary`] from candle data and an optional benchmark return
130/// series, using the default daily exchange calendar (252 periods/year).
131pub(crate) fn compute_risk_summary(
132    candles: &[Candle],
133    benchmark_returns: Option<&[f64]>,
134) -> RiskSummary {
135    compute_risk_summary_with_periods(candles, benchmark_returns, 252.0)
136}
137
138/// Build a [`RiskSummary`] with an explicit annualisation factor
139/// (`periods_per_year`), so non-daily intervals and non-equity asset classes
140/// annualise correctly. See [`periods_per_year`].
141pub(crate) fn compute_risk_summary_with_periods(
142    candles: &[Candle],
143    benchmark_returns: Option<&[f64]>,
144    periods_per_year: f64,
145) -> RiskSummary {
146    let returns = candles_to_returns(candles);
147
148    let var_95 = historical_var(&returns, 0.95).unwrap_or(0.0);
149    let var_99 = historical_var(&returns, 0.99).unwrap_or(0.0);
150    let parametric_var_95 = parametric_var(&returns, 0.95).unwrap_or(0.0);
151
152    let sharpe = sharpe_ratio(&returns, 0.0, periods_per_year);
153    let sortino = sortino_ratio(&returns, 0.0, periods_per_year);
154
155    let dd = max_drawdown(&returns);
156    let total_return = returns.iter().fold(1.0_f64, |acc, r| acc * (1.0 + r)) - 1.0;
157    let years = returns.len() as f64 / periods_per_year;
158    let calmar = calmar_ratio(total_return, years, dd.max_drawdown);
159
160    let beta_val = benchmark_returns.and_then(|br| beta(&returns, br));
161
162    RiskSummary {
163        var_95,
164        var_99,
165        parametric_var_95,
166        sharpe,
167        sortino,
168        calmar,
169        beta: beta_val,
170        max_drawdown: dd.max_drawdown,
171        max_drawdown_recovery_periods: dd.recovery_periods,
172    }
173}
174
175#[cfg(test)]
176mod tests {
177    use super::*;
178
179    fn make_candle(close: f64) -> Candle {
180        Candle {
181            timestamp: 0,
182            open: close,
183            high: close,
184            low: close,
185            close,
186            volume: 1_000_000,
187            adj_close: None,
188            provider_id: None,
189        }
190    }
191
192    #[test]
193    fn test_compute_risk_summary_flat() {
194        // Constant prices → zero returns → zero VaR, no ratios
195        let candles: Vec<Candle> = (0..=252).map(|_| make_candle(100.0)).collect();
196        let summary = compute_risk_summary(&candles, None);
197        assert_eq!(summary.var_95, 0.0);
198        assert_eq!(summary.max_drawdown, 0.0);
199        assert!(summary.sharpe.is_none());
200    }
201
202    #[test]
203    fn test_candles_to_returns_basic() {
204        let candles = vec![make_candle(100.0), make_candle(110.0), make_candle(99.0)];
205        let returns = candles_to_returns(&candles);
206        assert_eq!(returns.len(), 2);
207        assert!((returns[0] - 0.10).abs() < 1e-9);
208        assert!((returns[1] - (-0.1)).abs() < 0.01);
209    }
210
211    #[test]
212    fn test_periods_per_year_by_calendar() {
213        use crate::Interval;
214        // Daily differs by asset class; calendar periodicity is shared.
215        assert_eq!(
216            periods_per_year(Interval::OneDay, TradingCalendar::Exchange),
217            252.0
218        );
219        assert_eq!(
220            periods_per_year(Interval::OneDay, TradingCalendar::Forex),
221            260.0
222        );
223        assert_eq!(
224            periods_per_year(Interval::OneDay, TradingCalendar::Crypto),
225            365.0
226        );
227        assert_eq!(
228            periods_per_year(Interval::OneWeek, TradingCalendar::Crypto),
229            52.0
230        );
231        // Intraday scales daily count by session length (24h crypto > 6.5h exchange).
232        assert!(
233            periods_per_year(Interval::OneHour, TradingCalendar::Crypto)
234                > periods_per_year(Interval::OneHour, TradingCalendar::Exchange)
235        );
236    }
237
238    #[test]
239    fn test_annualization_factor_changes_sharpe() {
240        // Same returns, different periods_per_year → different annualised Sharpe.
241        let candles: Vec<Candle> = (0..50).map(|i| make_candle(100.0 + i as f64)).collect();
242        let daily = compute_risk_summary_with_periods(&candles, None, 252.0);
243        let crypto = compute_risk_summary_with_periods(&candles, None, 365.0);
244        assert!(daily.sharpe.is_some() && crypto.sharpe.is_some());
245        assert!(crypto.sharpe.unwrap() > daily.sharpe.unwrap());
246    }
247}