Skip to main content

finance_solution/stocks/
risk.rs

1//! Risk metrics: volatility, Sharpe, Sortino, max drawdown, beta, rolling drawdown,
2//! Calmar, Ulcer index, correlation, information ratio, and trade-PnL helpers.
3//!
4//! ## Which ratio when?
5//!
6//! | Metric | Question it answers | Needs |
7//! |--------|---------------------|--------|
8//! | [`sharpe_ratio`] | Return per unit total vol | Return series + RF |
9//! | [`sortino_ratio`] | Return per unit *downside* vol | Returns + target |
10//! | [`calmar_ratio`] | CAGR per unit peak–trough pain | Price path + years |
11//! | [`ulcer_index`] | How deep/persistent were drawdowns? | Prices |
12//! | [`information_ratio`] | Active return per tracking error | Asset + benchmark returns |
13//! | [`correlation`] | Do two series move together? | Paired series |
14//! | [`win_rate`] / [`profit_factor`] / [`expectancy`] | Trade list quality | Per-trade PnL |
15//!
16//! **Calmar** is path-level (prices + time); **Sharpe/Sortino** are return-series. Do not mix
17//! without aligning sampling frequency.
18//!
19//! # Error handling (v0.1+)
20//!
21//! All public functions return [`FinanceResult`]. Short series, zero sample vol, empty
22//! prices, and length mismatches yield structured [`FinanceError`] values.
23use crate::stocks::returns::{mean_return, simple_returns};
24use crate::util::error::{require_finite, FinanceError, FinanceResult};
25
26/// Sample standard deviation of a return series (population divisor `n - 1`).
27///
28/// # Examples
29/// ```
30/// use finance_solution::{volatility, FinanceError};
31///
32/// assert!(volatility(&[0.01, 0.02, -0.01]).is_ok());
33/// match volatility(&[0.01]) {
34///     Err(FinanceError::Unsolvable { message }) => assert!(message.contains("two")),
35///     other => panic!("expected Unsolvable, got {other:?}"),
36/// }
37/// ```
38pub fn volatility(returns: &[f64]) -> FinanceResult<f64> {
39    if returns.len() < 2 {
40        return Err(FinanceError::Unsolvable {
41            message: "volatility requires at least two returns",
42        });
43    }
44    let mean = mean_return(returns)?;
45    let mut sum_sq = 0.0;
46    for r in returns {
47        require_finite("returns", *r)?;
48        let d = r - mean;
49        sum_sq += d * d;
50    }
51    Ok((sum_sq / (returns.len() - 1) as f64).sqrt())
52}
53
54/// Annualized volatility: `volatility(returns) * sqrt(periods_per_year)`.
55///
56/// # Examples
57/// ```
58/// use finance_solution::{volatility_annualized, FinanceError};
59///
60/// assert!(volatility_annualized(&[0.01, 0.02], 12.0).is_ok());
61/// match volatility_annualized(&[0.01, 0.02], 0.0) {
62///     Err(FinanceError::Unsolvable { message }) => assert!(message.contains("periods_per_year")),
63///     other => panic!("expected Unsolvable, got {other:?}"),
64/// }
65/// ```
66pub fn volatility_annualized(returns: &[f64], periods_per_year: f64) -> FinanceResult<f64> {
67    require_finite("periods_per_year", periods_per_year)?;
68    if periods_per_year <= 0.0 {
69        return Err(FinanceError::Unsolvable {
70            message: "periods_per_year must be positive",
71        });
72    }
73    Ok(volatility(returns)? * periods_per_year.sqrt())
74}
75
76/// Sharpe ratio: `(mean - risk_free) / volatility` over the return series.
77///
78/// # Examples
79/// ```
80/// use finance_solution::{sharpe_ratio, FinanceError};
81///
82/// assert!(sharpe_ratio(&[0.02, 0.01, 0.03], 0.0).is_ok());
83/// // Constant returns → zero volatility → undefined Sharpe.
84/// match sharpe_ratio(&[0.01, 0.01, 0.01], 0.0) {
85///     Err(FinanceError::Unsolvable { message }) => assert!(message.contains("volatility")),
86///     other => panic!("expected Unsolvable, got {other:?}"),
87/// }
88/// ```
89pub fn sharpe_ratio(returns: &[f64], risk_free_rate: f64) -> FinanceResult<f64> {
90    require_finite("risk_free_rate", risk_free_rate)?;
91    let vol = volatility(returns)?;
92    if vol == 0.0 {
93        return Err(FinanceError::Unsolvable {
94            message: "sharpe_ratio undefined when volatility is zero",
95        });
96    }
97    let mean = mean_return(returns)?;
98    Ok((mean - risk_free_rate) / vol)
99}
100
101/// Sortino ratio: `(mean - target) / downside_deviation`, using returns below `target` only.
102///
103/// # Examples
104/// ```
105/// use finance_solution::{sortino_ratio, FinanceError};
106///
107/// assert!(sortino_ratio(&[0.02, -0.03, 0.01], 0.0).is_ok());
108/// // All returns above target → no downside.
109/// match sortino_ratio(&[0.01, 0.02, 0.03], 0.0) {
110///     Err(FinanceError::Unsolvable { message }) => assert!(message.contains("below target")),
111///     other => panic!("expected Unsolvable, got {other:?}"),
112/// }
113/// ```
114pub fn sortino_ratio(returns: &[f64], target: f64) -> FinanceResult<f64> {
115    require_finite("target", target)?;
116    if returns.len() < 2 {
117        return Err(FinanceError::Unsolvable {
118            message: "sortino_ratio requires at least two returns",
119        });
120    }
121    for r in returns {
122        require_finite("returns", *r)?;
123    }
124    let mut sum_sq = 0.0;
125    let mut downside_count = 0usize;
126    for &r in returns {
127        let shortfall = r - target;
128        if shortfall < 0.0 {
129            sum_sq += shortfall * shortfall;
130            downside_count += 1;
131        }
132    }
133    if downside_count == 0 {
134        return Err(FinanceError::Unsolvable {
135            message: "sortino_ratio undefined when no returns fall below target",
136        });
137    }
138    // Sample-style denominator over the full series length (n - 1).
139    let dd = (sum_sq / (returns.len() - 1) as f64).sqrt();
140    if dd == 0.0 {
141        return Err(FinanceError::Unsolvable {
142            message: "sortino_ratio undefined when downside deviation is zero",
143        });
144    }
145    let mean = mean_return(returns)?;
146    Ok((mean - target) / dd)
147}
148
149/// Maximum peak-to-trough drawdown over a positive price series (most negative fraction).
150///
151/// # Examples
152/// ```
153/// use finance_solution::{max_drawdown, FinanceError};
154///
155/// let dd = max_drawdown(&[100.0, 120.0, 90.0]).unwrap();
156/// assert!((dd - 0.25).abs() < 1e-12);
157/// match max_drawdown(&[100.0]) {
158///     Err(FinanceError::Unsolvable { message }) => assert!(message.contains("two")),
159///     other => panic!("expected Unsolvable, got {other:?}"),
160/// }
161/// ```
162pub fn max_drawdown(prices: &[f64]) -> FinanceResult<f64> {
163    if prices.len() < 2 {
164        return Err(FinanceError::Unsolvable {
165            message: "max_drawdown requires at least two prices",
166        });
167    }
168    let series = drawdown_series(prices)?;
169    Ok(series.into_iter().fold(0.0_f64, f64::max))
170}
171
172/// Running drawdown series (one value per price, starting at 0).
173///
174/// # Examples
175/// ```
176/// use finance_solution::{drawdown_series, FinanceError};
177///
178/// assert!(drawdown_series(&[100.0, 110.0]).is_ok());
179/// match drawdown_series(&[]) {
180///     Err(FinanceError::Unsolvable { message }) => assert!(message.contains("one")),
181///     other => panic!("expected Unsolvable, got {other:?}"),
182/// }
183/// ```
184pub fn drawdown_series(prices: &[f64]) -> FinanceResult<Vec<f64>> {
185    if prices.is_empty() {
186        return Err(FinanceError::Unsolvable {
187            message: "drawdown_series requires at least one price",
188        });
189    }
190    let mut peak = prices[0];
191    require_finite("prices", peak)?;
192    if peak <= 0.0 {
193        return Err(FinanceError::InvalidCashflow {
194            message: "drawdown_series requires positive prices",
195        });
196    }
197    let mut out = Vec::with_capacity(prices.len());
198    for &p in prices {
199        require_finite("prices", p)?;
200        if p <= 0.0 {
201            return Err(FinanceError::InvalidCashflow {
202                message: "drawdown_series requires positive prices",
203            });
204        }
205        if p > peak {
206            peak = p;
207        }
208        out.push((peak - p) / peak);
209    }
210    Ok(out)
211}
212
213/// Running maximum drawdown magnitude observed up to each price index.
214///
215/// # Examples
216/// ```
217/// use finance_solution::{rolling_max_drawdown, FinanceError};
218///
219/// let r = rolling_max_drawdown(&[100.0, 90.0]).unwrap();
220/// assert!((r[1] - 0.10).abs() < 1e-12);
221/// assert!(matches!(
222///     rolling_max_drawdown(&[]),
223///     Err(FinanceError::Unsolvable { .. })
224/// ));
225/// ```
226pub fn rolling_max_drawdown(prices: &[f64]) -> FinanceResult<Vec<f64>> {
227    let dd = drawdown_series(prices)?;
228    let mut out = Vec::with_capacity(dd.len());
229    let mut running = 0.0_f64;
230    for d in dd {
231        running = running.max(d);
232        out.push(running);
233    }
234    Ok(out)
235}
236
237/// OLS beta of asset returns vs market returns (same length series).
238///
239/// # Examples
240/// ```
241/// use finance_solution::{beta, FinanceError};
242///
243/// let market = [0.01, 0.02, -0.01, 0.03];
244/// let asset = [0.02, 0.04, -0.02, 0.06]; // ~2x market
245/// let b = beta(&asset, &market).unwrap();
246/// assert!((b - 2.0).abs() < 1e-9);
247///
248/// assert!(matches!(
249///     beta(&[0.1], &[0.1]),
250///     Err(FinanceError::Unsolvable { .. })
251/// ));
252/// match beta(&[0.1, 0.2], &[0.1]) {
253///     Err(FinanceError::Unsolvable { message }) => assert!(message.contains("equal length")),
254///     other => panic!("expected Unsolvable, got {other:?}"),
255/// }
256/// ```
257pub fn beta(asset_returns: &[f64], market_returns: &[f64]) -> FinanceResult<f64> {
258    if asset_returns.len() != market_returns.len() {
259        return Err(FinanceError::Unsolvable {
260            message: "beta requires asset and market return series of equal length",
261        });
262    }
263    if asset_returns.len() < 2 {
264        return Err(FinanceError::Unsolvable {
265            message: "beta requires at least two paired returns",
266        });
267    }
268    for r in asset_returns.iter().chain(market_returns.iter()) {
269        require_finite("returns", *r)?;
270    }
271    let mean_a = mean_return(asset_returns)?;
272    let mean_m = mean_return(market_returns)?;
273    let n = asset_returns.len() as f64;
274    let mut cov = 0.0;
275    let mut var_m = 0.0;
276    for i in 0..asset_returns.len() {
277        let da = asset_returns[i] - mean_a;
278        let dm = market_returns[i] - mean_m;
279        cov += da * dm;
280        var_m += dm * dm;
281    }
282    cov /= n - 1.0;
283    var_m /= n - 1.0;
284    if var_m == 0.0 {
285        return Err(FinanceError::Unsolvable {
286            message: "beta undefined when market variance is zero",
287        });
288    }
289    Ok(cov / var_m)
290}
291
292/// Volatility of simple returns computed from consecutive prices.
293///
294/// # Examples
295/// ```
296/// use finance_solution::{price_volatility, FinanceError};
297///
298/// assert!(price_volatility(&[100.0, 110.0, 105.0]).is_ok());
299/// // Only one return → sample volatility undefined.
300/// match price_volatility(&[100.0, 110.0]) {
301///     Err(FinanceError::Unsolvable { message }) => assert!(message.contains("two")),
302///     other => panic!("expected Unsolvable, got {other:?}"),
303/// }
304/// ```
305pub fn price_volatility(prices: &[f64]) -> FinanceResult<f64> {
306    let rets = simple_returns(prices)?;
307    volatility(&rets)
308}
309
310/// CAGR from a positive price path over `years` years: `(end/start)^(1/years) − 1`.
311///
312/// # Examples
313/// ```
314/// use finance_solution::cagr_from_prices;
315/// // Double in 2 years → CAGR ≈ 41.42%
316/// let g = cagr_from_prices(&[100.0, 200.0], 2.0).unwrap();
317/// assert!((g - (2.0_f64.sqrt() - 1.0)).abs() < 1e-12);
318/// ```
319pub fn cagr_from_prices(prices: &[f64], years: f64) -> FinanceResult<f64> {
320    require_finite("years", years)?;
321    if years <= 0.0 {
322        return Err(FinanceError::Unsolvable {
323            message: "years must be positive",
324        });
325    }
326    if prices.len() < 2 {
327        return Err(FinanceError::Unsolvable {
328            message: "cagr_from_prices requires at least two prices",
329        });
330    }
331    let start = prices[0];
332    let end = *prices.last().unwrap();
333    require_finite("prices", start)?;
334    require_finite("prices", end)?;
335    if start <= 0.0 || end <= 0.0 {
336        return Err(FinanceError::Unsolvable {
337            message: "cagr_from_prices requires strictly positive prices",
338        });
339    }
340    Ok((end / start).powf(1.0 / years) - 1.0)
341}
342
343/// CAGR using `(prices.len()−1) / periods_per_year` as the year fraction.
344pub fn cagr_from_prices_periods(prices: &[f64], periods_per_year: f64) -> FinanceResult<f64> {
345    require_finite("periods_per_year", periods_per_year)?;
346    if periods_per_year <= 0.0 {
347        return Err(FinanceError::Unsolvable {
348            message: "periods_per_year must be positive",
349        });
350    }
351    if prices.len() < 2 {
352        return Err(FinanceError::Unsolvable {
353            message: "cagr_from_prices_periods requires at least two prices",
354        });
355    }
356    let years = (prices.len() - 1) as f64 / periods_per_year;
357    cagr_from_prices(prices, years)
358}
359
360/// Calmar ratio: `CAGR / |max drawdown|` on a positive price series.
361///
362/// Higher is better (more growth per unit historical peak-to-trough loss).  
363/// Undefined when max drawdown is zero (monotone path). Compare funds only with
364/// similar `years` / sampling.
365///
366/// # Examples
367/// ```
368/// use finance_solution::calmar_ratio;
369/// // Mild path with some drawdown
370/// let prices = [100.0, 120.0, 90.0, 150.0];
371/// let c = calmar_ratio(&prices, 2.0).unwrap();
372/// assert!(c.is_finite());
373/// ```
374pub fn calmar_ratio(prices: &[f64], years: f64) -> FinanceResult<f64> {
375    let cagr = cagr_from_prices(prices, years)?;
376    let dd = max_drawdown(prices)?.abs();
377    if dd == 0.0 {
378        return Err(FinanceError::Unsolvable {
379            message: "calmar_ratio undefined when max drawdown is zero",
380        });
381    }
382    Ok(cagr / dd)
383}
384
385/// Calmar with year fraction from sample length and `periods_per_year`.
386pub fn calmar_ratio_periods(prices: &[f64], periods_per_year: f64) -> FinanceResult<f64> {
387    let cagr = cagr_from_prices_periods(prices, periods_per_year)?;
388    let dd = max_drawdown(prices)?.abs();
389    if dd == 0.0 {
390        return Err(FinanceError::Unsolvable {
391            message: "calmar_ratio undefined when max drawdown is zero",
392        });
393    }
394    Ok(cagr / dd)
395}
396
397/// Ulcer index: `sqrt(mean of squared percentage drawdowns)` (Martin).
398///
399/// Uses [`drawdown_series`] fractions (≤ 0), converted to percent points before squaring.
400/// Higher Ulcer ⇒ more painful path; often paired with return for “Ulcer Performance”.
401pub fn ulcer_index(prices: &[f64]) -> FinanceResult<f64> {
402    let dd = drawdown_series(prices)?;
403    if dd.is_empty() {
404        return Err(FinanceError::Unsolvable {
405            message: "ulcer_index requires prices",
406        });
407    }
408    let mut sum = 0.0;
409    for d in &dd {
410        // store as percent points (×100) for classic Ulcer scale
411        let pct = d * 100.0;
412        sum += pct * pct;
413    }
414    Ok((sum / dd.len() as f64).sqrt())
415}
416
417/// Pearson correlation of two equal-length series.
418///
419/// Uses the algebraically equivalent form
420/// `Σ(dx·dy) / sqrt(Σdx² · Σdy²)` (same as sample correlation; `n−1` cancels).
421/// Errors if either series has zero variance.
422///
423/// # Examples
424/// ```
425/// use finance_solution::correlation;
426/// let x = [1.0, 2.0, 3.0, 4.0];
427/// let y = [2.0, 4.0, 6.0, 8.0];
428/// assert!((correlation(&x, &y).unwrap() - 1.0).abs() < 1e-12);
429/// ```
430pub fn correlation(x: &[f64], y: &[f64]) -> FinanceResult<f64> {
431    if x.len() != y.len() {
432        return Err(FinanceError::Unsolvable {
433            message: "correlation requires equal-length series",
434        });
435    }
436    if x.len() < 2 {
437        return Err(FinanceError::Unsolvable {
438            message: "correlation requires at least two pairs",
439        });
440    }
441    for (a, b) in x.iter().zip(y.iter()) {
442        require_finite("x", *a)?;
443        require_finite("y", *b)?;
444    }
445    let n = x.len() as f64;
446    let mean_x = x.iter().sum::<f64>() / n;
447    let mean_y = y.iter().sum::<f64>() / n;
448    let mut cov = 0.0;
449    let mut vx = 0.0;
450    let mut vy = 0.0;
451    for i in 0..x.len() {
452        let dx = x[i] - mean_x;
453        let dy = y[i] - mean_y;
454        cov += dx * dy;
455        vx += dx * dx;
456        vy += dy * dy;
457    }
458    let denom = (vx * vy).sqrt();
459    if denom == 0.0 {
460        return Err(FinanceError::Unsolvable {
461            message: "correlation undefined when a series has zero variance",
462        });
463    }
464    Ok(cov / denom)
465}
466
467/// Information ratio: `mean(active) / stdev(active)` where `active[i] = asset[i] − benchmark[i]`.
468///
469/// Active returns and tracking error use the same sampling frequency as the inputs
470/// (not annualized). For desk IR, pass already-period-matched excess returns.
471pub fn information_ratio(asset_returns: &[f64], benchmark_returns: &[f64]) -> FinanceResult<f64> {
472    if asset_returns.len() != benchmark_returns.len() {
473        return Err(FinanceError::Unsolvable {
474            message: "information_ratio requires equal-length series",
475        });
476    }
477    if asset_returns.len() < 2 {
478        return Err(FinanceError::Unsolvable {
479            message: "information_ratio requires at least two returns",
480        });
481    }
482    let mut active = Vec::with_capacity(asset_returns.len());
483    for i in 0..asset_returns.len() {
484        require_finite("asset_returns", asset_returns[i])?;
485        require_finite("benchmark_returns", benchmark_returns[i])?;
486        active.push(asset_returns[i] - benchmark_returns[i]);
487    }
488    let vol = volatility(&active)?;
489    if vol == 0.0 {
490        return Err(FinanceError::Unsolvable {
491            message: "information_ratio undefined when tracking error is zero",
492        });
493    }
494    Ok(mean_return(&active)? / vol)
495}
496
497/// Win rate over a trade P&amp;L series: `count(pnl > 0) / n` (zeros count as non-wins).
498pub fn win_rate(trade_pnls: &[f64]) -> FinanceResult<f64> {
499    if trade_pnls.is_empty() {
500        return Err(FinanceError::Unsolvable {
501            message: "win_rate requires at least one trade",
502        });
503    }
504    let mut wins = 0usize;
505    for &p in trade_pnls {
506        require_finite("trade_pnls", p)?;
507        if p > 0.0 {
508            wins += 1;
509        }
510    }
511    Ok(wins as f64 / trade_pnls.len() as f64)
512}
513
514/// Profit factor: `sum(positive pnl) / |sum(negative pnl)|`.
515///
516/// Errors if there are no losses (denominator zero) or no trades.
517pub fn profit_factor(trade_pnls: &[f64]) -> FinanceResult<f64> {
518    if trade_pnls.is_empty() {
519        return Err(FinanceError::Unsolvable {
520            message: "profit_factor requires at least one trade",
521        });
522    }
523    let mut gp = 0.0;
524    let mut gl = 0.0;
525    for &p in trade_pnls {
526        require_finite("trade_pnls", p)?;
527        if p > 0.0 {
528            gp += p;
529        } else if p < 0.0 {
530            gl += -p;
531        }
532    }
533    if gl == 0.0 {
534        return Err(FinanceError::Unsolvable {
535            message: "profit_factor undefined when there are no losing trades",
536        });
537    }
538    Ok(gp / gl)
539}
540
541/// Expectancy: mean trade P&amp;L (including zeros).
542pub fn expectancy(trade_pnls: &[f64]) -> FinanceResult<f64> {
543    if trade_pnls.is_empty() {
544        return Err(FinanceError::Unsolvable {
545            message: "expectancy requires at least one trade",
546        });
547    }
548    for &p in trade_pnls {
549        require_finite("trade_pnls", p)?;
550    }
551    Ok(trade_pnls.iter().sum::<f64>() / trade_pnls.len() as f64)
552}
553
554#[cfg(test)]
555mod tests {
556    use super::*;
557    use crate::*;
558
559    #[test]
560    fn test_volatility_constant_zero() {
561        let returns = [0.01, 0.01, 0.01, 0.01];
562        assert_approx_equal!(volatility(&returns).unwrap(), 0.0);
563    }
564
565    #[test]
566    fn test_max_drawdown() {
567        let prices = [100.0, 120.0, 90.0, 95.0];
568        assert_approx_equal!(max_drawdown(&prices).unwrap(), 0.25);
569    }
570
571    #[test]
572    fn test_sharpe() {
573        let returns = [0.02, 0.01, 0.03, -0.01, 0.02];
574        let s = sharpe_ratio(&returns, 0.0).unwrap();
575        assert!(s.is_finite() && s > 0.0);
576    }
577
578    #[test]
579    fn test_sortino_has_downside() {
580        let returns = [0.02, -0.03, 0.01, -0.01, 0.02];
581        let s = sortino_ratio(&returns, 0.0).unwrap();
582        assert!(s.is_finite());
583    }
584
585    #[test]
586    fn test_sortino_no_downside_errs() {
587        assert!(sortino_ratio(&[0.01, 0.02, 0.03], 0.0).is_err());
588    }
589
590    #[test]
591    fn test_beta_double() {
592        let market = [0.01, 0.02, -0.01, 0.03];
593        let asset: Vec<f64> = market.iter().map(|r| 2.0 * r).collect();
594        assert!((beta(&asset, &market).unwrap() - 2.0).abs() < 1e-9);
595    }
596
597    #[test]
598    fn test_rolling_max_drawdown() {
599        let prices = [100.0, 120.0, 90.0, 95.0, 130.0];
600        let r = rolling_max_drawdown(&prices).unwrap();
601        assert_eq!(r.len(), 5);
602        assert_approx_equal!(r[2], 0.25);
603        assert_approx_equal!(r[4], 0.25);
604    }
605
606    #[test]
607    fn test_drawdown_series() {
608        let prices = [100.0, 120.0, 90.0];
609        let d = drawdown_series(&prices).unwrap();
610        assert_approx_equal!(d[0], 0.0);
611        assert_approx_equal!(d[1], 0.0);
612        assert_approx_equal!(d[2], 0.25);
613    }
614
615    #[test]
616    fn test_cagr_and_calmar() {
617        let prices = [100.0, 200.0];
618        let g = cagr_from_prices(&prices, 2.0).unwrap();
619        assert!((g - (2.0_f64.sqrt() - 1.0)).abs() < 1e-12);
620        // With drawdown path
621        let p = [100.0, 150.0, 80.0, 200.0];
622        let c = calmar_ratio(&p, 3.0).unwrap();
623        assert!(c.is_finite() && c > 0.0);
624        assert!(calmar_ratio(&[100.0, 110.0, 120.0], 1.0).is_err()); // no DD
625    }
626
627    #[test]
628    fn test_ulcer_and_correlation() {
629        let prices = [100.0, 120.0, 90.0, 100.0];
630        let u = ulcer_index(&prices).unwrap();
631        assert!(u > 0.0);
632        let x = [1.0, 2.0, 3.0, 4.0];
633        let y = [2.0, 4.0, 6.0, 8.0];
634        assert!((correlation(&x, &y).unwrap() - 1.0).abs() < 1e-12);
635    }
636
637    #[test]
638    fn test_information_ratio_and_trades() {
639        let a = [0.02, 0.01, 0.03, -0.01];
640        let b = [0.01, 0.01, 0.01, 0.01];
641        let ir = information_ratio(&a, &b).unwrap();
642        assert!(ir.is_finite());
643        let pnls = [10.0, -5.0, 20.0, -2.0, 0.0];
644        assert!((win_rate(&pnls).unwrap() - 2.0 / 5.0).abs() < 1e-12);
645        assert!((profit_factor(&pnls).unwrap() - 30.0 / 7.0).abs() < 1e-12);
646        assert!((expectancy(&pnls).unwrap() - 23.0 / 5.0).abs() < 1e-12);
647    }
648}