Skip to main content

finance_solution/stocks/
risk.rs

1//! Risk metrics: volatility, Sharpe, Sortino, max drawdown, beta, rolling drawdown.
2//!
3//! # Error handling (v0.1+)
4//!
5//! All public functions return [`FinanceResult`]. Short series, zero sample vol, empty
6//! prices, and length mismatches yield structured [`FinanceError`] values.
7use crate::stocks::returns::{mean_return, simple_returns};
8use crate::util::error::{require_finite, FinanceError, FinanceResult};
9
10/// Sample standard deviation of a return series (population divisor `n - 1`).
11///
12/// # Examples
13/// ```
14/// use finance_solution::{volatility, FinanceError};
15///
16/// assert!(volatility(&[0.01, 0.02, -0.01]).is_ok());
17/// match volatility(&[0.01]) {
18///     Err(FinanceError::Unsolvable { message }) => assert!(message.contains("two")),
19///     other => panic!("expected Unsolvable, got {other:?}"),
20/// }
21/// ```
22pub fn volatility(returns: &[f64]) -> FinanceResult<f64> {
23    if returns.len() < 2 {
24        return Err(FinanceError::Unsolvable {
25            message: "volatility requires at least two returns",
26        });
27    }
28    let mean = mean_return(returns)?;
29    let mut sum_sq = 0.0;
30    for r in returns {
31        require_finite("returns", *r)?;
32        let d = r - mean;
33        sum_sq += d * d;
34    }
35    Ok((sum_sq / (returns.len() - 1) as f64).sqrt())
36}
37
38/// Annualized volatility: `volatility(returns) * sqrt(periods_per_year)`.
39///
40/// # Examples
41/// ```
42/// use finance_solution::{volatility_annualized, FinanceError};
43///
44/// assert!(volatility_annualized(&[0.01, 0.02], 12.0).is_ok());
45/// match volatility_annualized(&[0.01, 0.02], 0.0) {
46///     Err(FinanceError::Unsolvable { message }) => assert!(message.contains("periods_per_year")),
47///     other => panic!("expected Unsolvable, got {other:?}"),
48/// }
49/// ```
50pub fn volatility_annualized(returns: &[f64], periods_per_year: f64) -> FinanceResult<f64> {
51    require_finite("periods_per_year", periods_per_year)?;
52    if periods_per_year <= 0.0 {
53        return Err(FinanceError::Unsolvable {
54            message: "periods_per_year must be positive",
55        });
56    }
57    Ok(volatility(returns)? * periods_per_year.sqrt())
58}
59
60/// Sharpe ratio: `(mean - risk_free) / volatility` over the return series.
61///
62/// # Examples
63/// ```
64/// use finance_solution::{sharpe_ratio, FinanceError};
65///
66/// assert!(sharpe_ratio(&[0.02, 0.01, 0.03], 0.0).is_ok());
67/// // Constant returns → zero volatility → undefined Sharpe.
68/// match sharpe_ratio(&[0.01, 0.01, 0.01], 0.0) {
69///     Err(FinanceError::Unsolvable { message }) => assert!(message.contains("volatility")),
70///     other => panic!("expected Unsolvable, got {other:?}"),
71/// }
72/// ```
73pub fn sharpe_ratio(returns: &[f64], risk_free_rate: f64) -> FinanceResult<f64> {
74    require_finite("risk_free_rate", risk_free_rate)?;
75    let vol = volatility(returns)?;
76    if vol == 0.0 {
77        return Err(FinanceError::Unsolvable {
78            message: "sharpe_ratio undefined when volatility is zero",
79        });
80    }
81    let mean = mean_return(returns)?;
82    Ok((mean - risk_free_rate) / vol)
83}
84
85/// Sortino ratio: `(mean - target) / downside_deviation`, using returns below `target` only.
86///
87/// # Examples
88/// ```
89/// use finance_solution::{sortino_ratio, FinanceError};
90///
91/// assert!(sortino_ratio(&[0.02, -0.03, 0.01], 0.0).is_ok());
92/// // All returns above target → no downside.
93/// match sortino_ratio(&[0.01, 0.02, 0.03], 0.0) {
94///     Err(FinanceError::Unsolvable { message }) => assert!(message.contains("below target")),
95///     other => panic!("expected Unsolvable, got {other:?}"),
96/// }
97/// ```
98pub fn sortino_ratio(returns: &[f64], target: f64) -> FinanceResult<f64> {
99    require_finite("target", target)?;
100    if returns.len() < 2 {
101        return Err(FinanceError::Unsolvable {
102            message: "sortino_ratio requires at least two returns",
103        });
104    }
105    for r in returns {
106        require_finite("returns", *r)?;
107    }
108    let mut sum_sq = 0.0;
109    let mut downside_count = 0usize;
110    for &r in returns {
111        let shortfall = r - target;
112        if shortfall < 0.0 {
113            sum_sq += shortfall * shortfall;
114            downside_count += 1;
115        }
116    }
117    if downside_count == 0 {
118        return Err(FinanceError::Unsolvable {
119            message: "sortino_ratio undefined when no returns fall below target",
120        });
121    }
122    // Sample-style denominator over the full series length (n - 1).
123    let dd = (sum_sq / (returns.len() - 1) as f64).sqrt();
124    if dd == 0.0 {
125        return Err(FinanceError::Unsolvable {
126            message: "sortino_ratio undefined when downside deviation is zero",
127        });
128    }
129    let mean = mean_return(returns)?;
130    Ok((mean - target) / dd)
131}
132
133/// Maximum peak-to-trough drawdown over a positive price series (most negative fraction).
134///
135/// # Examples
136/// ```
137/// use finance_solution::{max_drawdown, FinanceError};
138///
139/// let dd = max_drawdown(&[100.0, 120.0, 90.0]).unwrap();
140/// assert!((dd - 0.25).abs() < 1e-12);
141/// match max_drawdown(&[100.0]) {
142///     Err(FinanceError::Unsolvable { message }) => assert!(message.contains("two")),
143///     other => panic!("expected Unsolvable, got {other:?}"),
144/// }
145/// ```
146pub fn max_drawdown(prices: &[f64]) -> FinanceResult<f64> {
147    if prices.len() < 2 {
148        return Err(FinanceError::Unsolvable {
149            message: "max_drawdown requires at least two prices",
150        });
151    }
152    let series = drawdown_series(prices)?;
153    Ok(series.into_iter().fold(0.0_f64, f64::max))
154}
155
156/// Running drawdown series (one value per price, starting at 0).
157///
158/// # Examples
159/// ```
160/// use finance_solution::{drawdown_series, FinanceError};
161///
162/// assert!(drawdown_series(&[100.0, 110.0]).is_ok());
163/// match drawdown_series(&[]) {
164///     Err(FinanceError::Unsolvable { message }) => assert!(message.contains("one")),
165///     other => panic!("expected Unsolvable, got {other:?}"),
166/// }
167/// ```
168pub fn drawdown_series(prices: &[f64]) -> FinanceResult<Vec<f64>> {
169    if prices.is_empty() {
170        return Err(FinanceError::Unsolvable {
171            message: "drawdown_series requires at least one price",
172        });
173    }
174    let mut peak = prices[0];
175    require_finite("prices", peak)?;
176    if peak <= 0.0 {
177        return Err(FinanceError::InvalidCashflow {
178            message: "drawdown_series requires positive prices",
179        });
180    }
181    let mut out = Vec::with_capacity(prices.len());
182    for &p in prices {
183        require_finite("prices", p)?;
184        if p <= 0.0 {
185            return Err(FinanceError::InvalidCashflow {
186                message: "drawdown_series requires positive prices",
187            });
188        }
189        if p > peak {
190            peak = p;
191        }
192        out.push((peak - p) / peak);
193    }
194    Ok(out)
195}
196
197/// Running maximum drawdown magnitude observed up to each price index.
198///
199/// # Examples
200/// ```
201/// use finance_solution::{rolling_max_drawdown, FinanceError};
202///
203/// let r = rolling_max_drawdown(&[100.0, 90.0]).unwrap();
204/// assert!((r[1] - 0.10).abs() < 1e-12);
205/// assert!(matches!(
206///     rolling_max_drawdown(&[]),
207///     Err(FinanceError::Unsolvable { .. })
208/// ));
209/// ```
210pub fn rolling_max_drawdown(prices: &[f64]) -> FinanceResult<Vec<f64>> {
211    let dd = drawdown_series(prices)?;
212    let mut out = Vec::with_capacity(dd.len());
213    let mut running = 0.0_f64;
214    for d in dd {
215        running = running.max(d);
216        out.push(running);
217    }
218    Ok(out)
219}
220
221/// OLS beta of asset returns vs market returns (same length series).
222///
223/// # Examples
224/// ```
225/// use finance_solution::{beta, FinanceError};
226///
227/// let market = [0.01, 0.02, -0.01, 0.03];
228/// let asset = [0.02, 0.04, -0.02, 0.06]; // ~2x market
229/// let b = beta(&asset, &market).unwrap();
230/// assert!((b - 2.0).abs() < 1e-9);
231///
232/// assert!(matches!(
233///     beta(&[0.1], &[0.1]),
234///     Err(FinanceError::Unsolvable { .. })
235/// ));
236/// match beta(&[0.1, 0.2], &[0.1]) {
237///     Err(FinanceError::Unsolvable { message }) => assert!(message.contains("equal length")),
238///     other => panic!("expected Unsolvable, got {other:?}"),
239/// }
240/// ```
241pub fn beta(asset_returns: &[f64], market_returns: &[f64]) -> FinanceResult<f64> {
242    if asset_returns.len() != market_returns.len() {
243        return Err(FinanceError::Unsolvable {
244            message: "beta requires asset and market return series of equal length",
245        });
246    }
247    if asset_returns.len() < 2 {
248        return Err(FinanceError::Unsolvable {
249            message: "beta requires at least two paired returns",
250        });
251    }
252    for r in asset_returns.iter().chain(market_returns.iter()) {
253        require_finite("returns", *r)?;
254    }
255    let mean_a = mean_return(asset_returns)?;
256    let mean_m = mean_return(market_returns)?;
257    let n = asset_returns.len() as f64;
258    let mut cov = 0.0;
259    let mut var_m = 0.0;
260    for i in 0..asset_returns.len() {
261        let da = asset_returns[i] - mean_a;
262        let dm = market_returns[i] - mean_m;
263        cov += da * dm;
264        var_m += dm * dm;
265    }
266    cov /= n - 1.0;
267    var_m /= n - 1.0;
268    if var_m == 0.0 {
269        return Err(FinanceError::Unsolvable {
270            message: "beta undefined when market variance is zero",
271        });
272    }
273    Ok(cov / var_m)
274}
275
276/// Volatility of simple returns computed from consecutive prices.
277///
278/// # Examples
279/// ```
280/// use finance_solution::{price_volatility, FinanceError};
281///
282/// assert!(price_volatility(&[100.0, 110.0, 105.0]).is_ok());
283/// // Only one return → sample volatility undefined.
284/// match price_volatility(&[100.0, 110.0]) {
285///     Err(FinanceError::Unsolvable { message }) => assert!(message.contains("two")),
286///     other => panic!("expected Unsolvable, got {other:?}"),
287/// }
288/// ```
289pub fn price_volatility(prices: &[f64]) -> FinanceResult<f64> {
290    let rets = simple_returns(prices)?;
291    volatility(&rets)
292}
293
294#[cfg(test)]
295mod tests {
296    use super::*;
297    use crate::*;
298
299    #[test]
300    fn test_volatility_constant_zero() {
301        let returns = [0.01, 0.01, 0.01, 0.01];
302        assert_approx_equal!(volatility(&returns).unwrap(), 0.0);
303    }
304
305    #[test]
306    fn test_max_drawdown() {
307        let prices = [100.0, 120.0, 90.0, 95.0];
308        assert_approx_equal!(max_drawdown(&prices).unwrap(), 0.25);
309    }
310
311    #[test]
312    fn test_sharpe() {
313        let returns = [0.02, 0.01, 0.03, -0.01, 0.02];
314        let s = sharpe_ratio(&returns, 0.0).unwrap();
315        assert!(s.is_finite() && s > 0.0);
316    }
317
318    #[test]
319    fn test_sortino_has_downside() {
320        let returns = [0.02, -0.03, 0.01, -0.01, 0.02];
321        let s = sortino_ratio(&returns, 0.0).unwrap();
322        assert!(s.is_finite());
323    }
324
325    #[test]
326    fn test_sortino_no_downside_errs() {
327        assert!(sortino_ratio(&[0.01, 0.02, 0.03], 0.0).is_err());
328    }
329
330    #[test]
331    fn test_beta_double() {
332        let market = [0.01, 0.02, -0.01, 0.03];
333        let asset: Vec<f64> = market.iter().map(|r| 2.0 * r).collect();
334        assert!((beta(&asset, &market).unwrap() - 2.0).abs() < 1e-9);
335    }
336
337    #[test]
338    fn test_rolling_max_drawdown() {
339        let prices = [100.0, 120.0, 90.0, 95.0, 130.0];
340        let r = rolling_max_drawdown(&prices).unwrap();
341        assert_eq!(r.len(), 5);
342        assert_approx_equal!(r[2], 0.25);
343        assert_approx_equal!(r[4], 0.25);
344    }
345
346    #[test]
347    fn test_drawdown_series() {
348        let prices = [100.0, 120.0, 90.0];
349        let d = drawdown_series(&prices).unwrap();
350        assert_approx_equal!(d[0], 0.0);
351        assert_approx_equal!(d[1], 0.0);
352        assert_approx_equal!(d[2], 0.25);
353    }
354}