Skip to main content

fin_primitives/risk/
mod.rs

1//! Drawdown tracking, pluggable `RiskRule`s, `RiskMonitor`, VaR and stress tools.
2//!
3//! ## Responsibility
4//! Tracks equity drawdown and evaluates configurable risk rules on each equity update.
5//!
6//! ## Guarantees
7//! - `DrawdownTracker::current_drawdown_pct` is always non-negative
8//! - `RiskMonitor::update` returns all triggered `RiskBreach` values (empty vec if none)
9//!
10//! ## NOT Responsible For
11//! - Position sizing
12//! - Order cancellation (callers must act on returned breaches)
13
14pub mod attribution;
15
16pub mod correlation_matrix;
17
18/// Multi-factor stress testing with correlation shocks and portfolio VaR under stress.
19pub mod stress_scenarios;
20
21/// Stress testing framework: apply named market shock scenarios to a portfolio
22/// and aggregate P&L impact per position and in total.
23pub mod stress;
24
25/// Value at Risk (VaR) and Conditional VaR (CVaR): Historical, Parametric, Monte Carlo, Cornish-Fisher.
26pub mod var;
27
28/// VaR calculation engine: Historical, Parametric (Cornish-Fisher), Monte Carlo (LCG+Box-Muller), rolling VaR.
29pub mod var_engine;
30
31/// Liquidity risk measurement: market depth, Amihud illiquidity, liquidation cost models,
32/// and portfolio liquidity-adjusted VaR.
33pub mod liquidity_risk;
34
35/// Scenario analysis and stress testing: historical, hypothetical, Monte Carlo,
36/// regulatory (DFAST/CCAR) scenarios with per-position shock application and P&L reporting.
37pub mod scenario_engine;
38
39/// Credit scoring, expected loss, Credit VaR, z-spread, and rating migration.
40pub mod credit_risk;
41
42use rust_decimal::Decimal;
43use rust_decimal::prelude::ToPrimitive;
44
45/// Tracks peak equity and computes current drawdown percentage.
46#[derive(Debug, Clone)]
47#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
48pub struct DrawdownTracker {
49    peak_equity: Decimal,
50    current_equity: Decimal,
51    worst_drawdown_pct: Decimal,
52    /// Number of updates since the last new peak.
53    updates_since_peak: usize,
54    /// Total number of equity updates processed.
55    update_count: usize,
56    /// Number of updates where equity was below peak (in drawdown).
57    drawdown_update_count: usize,
58    /// Cumulative sum of drawdown percentages for computing averages.
59    #[cfg_attr(feature = "serde", serde(default))]
60    drawdown_pct_sum: Decimal,
61    /// Longest consecutive run of updates spent below peak.
62    #[cfg_attr(feature = "serde", serde(default))]
63    max_drawdown_streak: usize,
64    /// Current consecutive run of updates where equity increased from the prior update.
65    #[cfg_attr(feature = "serde", serde(default))]
66    gain_streak: usize,
67    /// Number of times a new equity peak has been set.
68    #[cfg_attr(feature = "serde", serde(default))]
69    peak_count: usize,
70    /// Previous equity value (for computing per-update changes).
71    #[cfg_attr(feature = "serde", serde(default))]
72    prev_equity: Decimal,
73    /// Welford running mean of per-update equity changes.
74    #[cfg_attr(feature = "serde", serde(default))]
75    equity_change_mean: f64,
76    /// Welford running M2 (sum of squared deviations) for sample variance.
77    #[cfg_attr(feature = "serde", serde(default))]
78    equity_change_m2: f64,
79    /// Count of equity changes recorded (= update_count after first update).
80    #[cfg_attr(feature = "serde", serde(default))]
81    equity_change_count: usize,
82    /// Most negative single-step equity change seen (0.0 until first loss).
83    #[cfg_attr(feature = "serde", serde(default))]
84    min_equity_delta: f64,
85    /// Longest run of consecutive updates where equity increased.
86    #[cfg_attr(feature = "serde", serde(default))]
87    max_gain_streak: usize,
88    /// Sum of all positive per-update equity changes.
89    #[cfg_attr(feature = "serde", serde(default))]
90    total_gain_sum: f64,
91    /// Sum of the absolute values of all negative per-update equity changes.
92    #[cfg_attr(feature = "serde", serde(default))]
93    total_loss_sum: f64,
94    /// Number of completed recoveries (drawdown resolved by hitting a new peak).
95    #[cfg_attr(feature = "serde", serde(default))]
96    completed_recoveries: usize,
97    /// Sum of `updates_since_peak` values at the moment each recovery completed.
98    #[cfg_attr(feature = "serde", serde(default))]
99    total_recovery_updates: usize,
100    /// Sum of drawdown percentages at the start of each recovery (for averaging).
101    #[cfg_attr(feature = "serde", serde(default))]
102    recovery_drawdown_pct_sum: Decimal,
103    /// Largest single-step equity gain as a percentage of prior equity.
104    #[cfg_attr(feature = "serde", serde(default))]
105    max_gain_delta_pct: f64,
106    /// Number of distinct drawdown episodes (each time equity drops below peak after being at/above it).
107    #[cfg_attr(feature = "serde", serde(default))]
108    drawdown_episodes: usize,
109    /// Current consecutive run of updates where equity decreased from the prior update.
110    #[cfg_attr(feature = "serde", serde(default))]
111    loss_streak_current: usize,
112    /// Initial equity (set at construction, unchanged by reset unless re-constructed).
113    initial_equity: Decimal,
114    /// Current consecutive run of updates where equity was unchanged.
115    #[cfg_attr(feature = "serde", serde(default))]
116    flat_streak: usize,
117}
118
119impl DrawdownTracker {
120    /// Creates a new `DrawdownTracker` with the given initial (and peak) equity.
121    pub fn new(initial_equity: Decimal) -> Self {
122        Self {
123            peak_equity: initial_equity,
124            current_equity: initial_equity,
125            worst_drawdown_pct: Decimal::ZERO,
126            updates_since_peak: 0,
127            update_count: 0,
128            drawdown_update_count: 0,
129            drawdown_pct_sum: Decimal::ZERO,
130            max_drawdown_streak: 0,
131            gain_streak: 0,
132            peak_count: 0,
133            prev_equity: initial_equity,
134            equity_change_mean: 0.0,
135            equity_change_m2: 0.0,
136            equity_change_count: 0,
137            min_equity_delta: 0.0,
138            max_gain_streak: 0,
139            total_gain_sum: 0.0,
140            total_loss_sum: 0.0,
141            completed_recoveries: 0,
142            total_recovery_updates: 0,
143            recovery_drawdown_pct_sum: Decimal::ZERO,
144            max_gain_delta_pct: 0.0,
145            drawdown_episodes: 0,
146            loss_streak_current: 0,
147            initial_equity,
148            flat_streak: 0,
149        }
150    }
151
152    /// Updates the tracker with the latest equity value, updating the peak if higher.
153    pub fn update(&mut self, equity: Decimal) {
154        // Welford online variance update for equity changes
155        if self.update_count > 0 {
156            if let (Some(prev), Some(curr)) = (
157                self.prev_equity.to_f64(),
158                equity.to_f64(),
159            ) {
160                let delta = curr - prev;
161                self.equity_change_count += 1;
162                let n = self.equity_change_count as f64;
163                let old_mean = self.equity_change_mean;
164                self.equity_change_mean += (delta - old_mean) / n;
165                self.equity_change_m2 += (delta - old_mean) * (delta - self.equity_change_mean);
166                if delta < self.min_equity_delta {
167                    self.min_equity_delta = delta;
168                }
169                if delta > 0.0 {
170                    self.total_gain_sum += delta;
171                    if prev > 0.0 {
172                        let pct = delta / prev * 100.0;
173                        if pct > self.max_gain_delta_pct {
174                            self.max_gain_delta_pct = pct;
175                        }
176                    }
177                } else if delta < 0.0 {
178                    self.total_loss_sum += -delta;
179                }
180            }
181        }
182        self.prev_equity = equity;
183
184        self.update_count += 1;
185        if equity > self.current_equity {
186            self.gain_streak += 1;
187            if self.gain_streak > self.max_gain_streak {
188                self.max_gain_streak = self.gain_streak;
189            }
190            self.loss_streak_current = 0;
191            self.flat_streak = 0;
192        } else if equity < self.current_equity {
193            self.gain_streak = 0;
194            self.loss_streak_current += 1;
195            self.flat_streak = 0;
196        } else {
197            self.gain_streak = 0;
198            self.loss_streak_current = 0;
199            self.flat_streak += 1;
200        }
201        if equity > self.peak_equity {
202            if self.updates_since_peak > 0 {
203                self.total_recovery_updates += self.updates_since_peak;
204                self.recovery_drawdown_pct_sum += self.current_drawdown_pct();
205                self.completed_recoveries += 1;
206            }
207            self.peak_equity = equity;
208            self.updates_since_peak = 0;
209            self.peak_count += 1;
210        } else {
211            if equity < self.peak_equity && self.updates_since_peak == 0 {
212                self.drawdown_episodes += 1;
213            }
214            self.updates_since_peak += 1;
215            self.drawdown_update_count += 1;
216        }
217        self.current_equity = equity;
218        let dd = self.current_drawdown_pct();
219        if dd > self.worst_drawdown_pct {
220            self.worst_drawdown_pct = dd;
221        }
222        if !dd.is_zero() {
223            self.drawdown_pct_sum += dd;
224        }
225        if self.updates_since_peak > self.max_drawdown_streak {
226            self.max_drawdown_streak = self.updates_since_peak;
227        }
228    }
229
230    /// Returns the number of `update()` calls since the last new equity peak.
231    ///
232    /// A value of 0 means the last update set a new peak. Higher values indicate
233    /// how long the portfolio has been in drawdown (in update units).
234    pub fn drawdown_duration(&self) -> usize {
235        self.updates_since_peak
236    }
237
238    /// Returns current drawdown as a percentage: `(peak - current) / peak * 100`.
239    ///
240    /// Returns `0` if `peak_equity` is zero.
241    pub fn current_drawdown_pct(&self) -> Decimal {
242        if self.peak_equity == Decimal::ZERO {
243            return Decimal::ZERO;
244        }
245        (self.peak_equity - self.current_equity) / self.peak_equity * Decimal::ONE_HUNDRED
246    }
247
248    /// Returns the highest equity seen since construction.
249    pub fn peak(&self) -> Decimal {
250        self.peak_equity
251    }
252
253    /// Returns the current equity value.
254    pub fn current_equity(&self) -> Decimal {
255        self.current_equity
256    }
257
258    /// Returns `true` if the current drawdown percentage does not exceed `max_dd_pct`.
259    pub fn is_below_threshold(&self, max_dd_pct: Decimal) -> bool {
260        self.current_drawdown_pct() <= max_dd_pct
261    }
262
263    /// Resets the peak to the current equity value.
264    ///
265    /// Useful for daily or session-boundary resets where you want drawdown measured
266    /// from the start of the new session rather than the all-time high.
267    pub fn reset_peak(&mut self) {
268        self.peak_equity = self.current_equity;
269        self.updates_since_peak = 0;
270    }
271
272    /// Returns the worst (highest) drawdown percentage seen since construction or last reset.
273    pub fn worst_drawdown_pct(&self) -> Decimal {
274        self.worst_drawdown_pct
275    }
276
277    /// Returns the total number of equity updates since construction or last reset.
278    pub fn update_count(&self) -> usize {
279        self.update_count
280    }
281
282    /// Returns the fraction of updates where equity was at or above peak (not in drawdown).
283    ///
284    /// `win_rate = (update_count - drawdown_update_count) / update_count`
285    ///
286    /// Returns `None` if no updates have been processed.
287    pub fn win_rate(&self) -> Option<Decimal> {
288        if self.update_count == 0 {
289            return None;
290        }
291        let at_peak = self.update_count - self.drawdown_update_count;
292        #[allow(clippy::cast_possible_truncation)]
293        Some(Decimal::from(at_peak as u64) / Decimal::from(self.update_count as u64))
294    }
295
296    /// Returns how far below peak current equity is, as a percentage.
297    ///
298    /// `underwater_pct = (peak - current) / peak × 100`
299    ///
300    /// Returns `Decimal::ZERO` when at or above peak.
301    pub fn underwater_pct(&self) -> Decimal {
302        if self.peak_equity == Decimal::ZERO {
303            return Decimal::ZERO;
304        }
305        let diff = self.peak_equity - self.current_equity;
306        if diff <= Decimal::ZERO {
307            return Decimal::ZERO;
308        }
309        diff / self.peak_equity * Decimal::ONE_HUNDRED
310    }
311
312    /// Fully resets the tracker as if it were freshly constructed with `initial` equity.
313    pub fn reset(&mut self, initial: Decimal) {
314        self.peak_equity = initial;
315        self.current_equity = initial;
316        self.drawdown_pct_sum = Decimal::ZERO;
317        self.max_drawdown_streak = 0;
318        self.worst_drawdown_pct = Decimal::ZERO;
319        self.updates_since_peak = 0;
320        self.update_count = 0;
321        self.drawdown_update_count = 0;
322        self.gain_streak = 0;
323        self.peak_count = 0;
324        self.prev_equity = initial;
325        self.equity_change_mean = 0.0;
326        self.equity_change_m2 = 0.0;
327        self.equity_change_count = 0;
328        self.min_equity_delta = 0.0;
329        self.max_gain_streak = 0;
330        self.total_gain_sum = 0.0;
331        self.total_loss_sum = 0.0;
332        self.completed_recoveries = 0;
333        self.total_recovery_updates = 0;
334        self.recovery_drawdown_pct_sum = Decimal::ZERO;
335        self.max_gain_delta_pct = 0.0;
336        self.drawdown_episodes = 0;
337        self.loss_streak_current = 0;
338        self.flat_streak = 0;
339    }
340
341    /// Returns the sample standard deviation of per-update equity changes.
342    ///
343    /// Uses Welford's online algorithm internally. Returns `None` until at least
344    /// two updates have been processed (can't compute variance from one sample).
345    pub fn volatility(&self) -> Option<f64> {
346        if self.equity_change_count < 2 {
347            return None;
348        }
349        let variance = self.equity_change_m2 / (self.equity_change_count - 1) as f64;
350        Some(variance.sqrt())
351    }
352
353    /// Returns the recovery factor: `net_profit_pct / worst_drawdown_pct`.
354    ///
355    /// A higher value indicates better risk-adjusted performance.
356    /// Returns `None` when `worst_drawdown_pct` is zero (no drawdown has occurred).
357    pub fn recovery_factor(&self, net_profit_pct: Decimal) -> Option<Decimal> {
358        if self.worst_drawdown_pct.is_zero() {
359            return None;
360        }
361        Some(net_profit_pct / self.worst_drawdown_pct)
362    }
363
364    /// Returns the Calmar ratio: `annualized_return / worst_drawdown_pct`.
365    ///
366    /// Higher values indicate better risk-adjusted performance. Returns `None` when
367    /// `worst_drawdown_pct` is zero (no drawdown has occurred).
368    pub fn calmar_ratio(&self, annualized_return: Decimal) -> Option<Decimal> {
369        if self.worst_drawdown_pct.is_zero() {
370            return None;
371        }
372        Some(annualized_return / self.worst_drawdown_pct)
373    }
374
375    /// Returns `true` if the current equity is strictly below the peak (i.e. in drawdown).
376    pub fn in_drawdown(&self) -> bool {
377        self.current_equity < self.peak_equity
378    }
379
380    /// Applies a sequence of equity values in order, as if each were an individual `update` call.
381    ///
382    /// Useful for batch processing historical equity curves without a manual loop.
383    pub fn update_with_returns(&mut self, equities: &[Decimal]) {
384        for &eq in equities {
385            self.update(eq);
386        }
387    }
388
389    /// Returns the number of consecutive updates where equity was below the peak.
390    ///
391    /// Equivalent to [`DrawdownTracker::drawdown_duration`]. Provided as a semantic
392    /// alias for call sites that prefer "count" over "duration".
393    pub fn drawdown_count(&self) -> usize {
394        self.updates_since_peak
395    }
396
397    /// Returns the Sharpe ratio: `annualized_return / annualized_vol`.
398    ///
399    /// Returns `None` when `annualized_vol` is zero to avoid division by zero.
400    pub fn sharpe_ratio(
401        &self,
402        annualized_return: Decimal,
403        annualized_vol: Decimal,
404    ) -> Option<Decimal> {
405        if annualized_vol.is_zero() {
406            return None;
407        }
408        Some(annualized_return / annualized_vol)
409    }
410
411    /// Returns the percentage gain required from the current equity to recover to the peak.
412    ///
413    /// Formula: `(peak / current - 1) * 100`. Returns `Decimal::ZERO` when already at peak
414    /// or when current equity is zero (to avoid division by zero).
415    pub fn recovery_to_peak_pct(&self) -> Decimal {
416        if self.current_equity.is_zero() || self.current_equity >= self.peak_equity {
417            return Decimal::ZERO;
418        }
419        (self.peak_equity / self.current_equity - Decimal::ONE) * Decimal::ONE_HUNDRED
420    }
421
422    /// Fraction of equity updates spent below peak: `drawdown_update_count / update_count`.
423    ///
424    /// Returns `Decimal::ZERO` when no updates have been processed.
425    #[allow(clippy::cast_possible_truncation)]
426    pub fn time_underwater_pct(&self) -> Decimal {
427        if self.update_count == 0 {
428            return Decimal::ZERO;
429        }
430        Decimal::from(self.drawdown_update_count as u64)
431            / Decimal::from(self.update_count as u64)
432    }
433
434    /// Average drawdown percentage across all updates that had a non-zero drawdown.
435    ///
436    /// Returns `None` when no drawdown updates have been recorded.
437    #[allow(clippy::cast_possible_truncation)]
438    pub fn avg_drawdown_pct(&self) -> Option<Decimal> {
439        if self.drawdown_update_count == 0 {
440            return None;
441        }
442        Some(self.drawdown_pct_sum / Decimal::from(self.drawdown_update_count as u64))
443    }
444
445    /// Longest consecutive run of updates where equity was below peak.
446    pub fn max_loss_streak(&self) -> usize {
447        self.max_drawdown_streak.max(self.updates_since_peak)
448    }
449
450    /// Returns the current consecutive run of updates where equity increased from the prior update.
451    ///
452    /// Resets to zero on any non-increasing update. Useful for detecting sustained rallies.
453    pub fn consecutive_gain_updates(&self) -> usize {
454        self.gain_streak
455    }
456
457    /// Returns `current_equity / peak_equity`, useful for position sizing formulas.
458    ///
459    /// Returns `Decimal::ONE` when peak is zero (no drawdown state yet). A value below 1
460    /// indicates the portfolio is in drawdown; exactly 1 means at peak.
461    pub fn equity_ratio(&self) -> Decimal {
462        if self.peak_equity.is_zero() {
463            return Decimal::ONE;
464        }
465        self.current_equity / self.peak_equity
466    }
467
468    /// Returns how many times a new equity peak has been set since construction or last reset.
469    pub fn new_peak_count(&self) -> usize {
470        self.peak_count
471    }
472
473    /// Returns the "pain index": mean absolute drawdown across all updates.
474    ///
475    /// `pain_index = drawdown_pct_sum / update_count`
476    ///
477    /// Represents the average percentage loss a holder experienced over the equity curve.
478    /// Returns `Decimal::ZERO` when no updates have been processed.
479    #[allow(clippy::cast_possible_truncation)]
480    pub fn pain_index(&self) -> Decimal {
481        if self.update_count == 0 {
482            return Decimal::ZERO;
483        }
484        self.drawdown_pct_sum / Decimal::from(self.update_count as u64)
485    }
486
487    /// Returns `true` if `equity` is strictly greater than the current peak (new high-water mark).
488    ///
489    /// Useful for triggering high-water-mark-based fee calculations or performance resets.
490    /// Note: this does NOT update the tracker — call `update(equity)` to advance the peak.
491    pub fn above_high_water_mark(&self, equity: Decimal) -> bool {
492        equity > self.peak_equity
493    }
494
495    /// Returns the largest single-step equity drop seen across all updates.
496    ///
497    /// Returns the magnitude (positive number) of the worst per-update loss.
498    /// Returns `None` if no loss has occurred or fewer than two updates have been processed.
499    pub fn max_single_loss(&self) -> Option<f64> {
500        if self.equity_change_count == 0 || self.min_equity_delta >= 0.0 {
501            return None;
502        }
503        Some(-self.min_equity_delta)
504    }
505
506    /// Returns the fraction of equity updates that decreased equity (loss rate).
507    ///
508    /// A value of `0.0` means equity never decreased; `1.0` means it always decreased.
509    /// Returns `None` if no updates have been processed.
510    ///
511    /// Note: uses the drawdown update count as a proxy for loss updates — specifically
512    /// the number of updates where equity was below peak, not strictly below the prior update.
513    pub fn loss_rate(&self) -> Option<f64> {
514        if self.update_count == 0 {
515            return None;
516        }
517        Some(self.drawdown_update_count as f64 / self.update_count as f64)
518    }
519
520    /// Returns the current number of consecutive updates where equity decreased.
521    ///
522    /// Resets to zero on any update where equity increases or stays the same.
523    /// A current losing streak indicator complementing [`DrawdownTracker::consecutive_gain_updates`].
524    pub fn consecutive_loss_updates(&self) -> usize {
525        // gain_streak tracks consecutive gains; when gain_streak is 0 and we're in drawdown
526        // that approximates a loss streak. We return updates_since_peak as the losing streak
527        // (time underwater is the closest proxy without a dedicated field).
528        if self.gain_streak > 0 {
529            0
530        } else {
531            self.updates_since_peak
532        }
533    }
534
535    /// Returns the running mean of per-update equity changes.
536    ///
537    /// Computed via Welford's online algorithm. Returns `None` until at least one
538    /// equity change has been recorded (requires 2+ updates).
539    pub fn equity_change_mean(&self) -> Option<f64> {
540        if self.equity_change_count == 0 {
541            return None;
542        }
543        Some(self.equity_change_mean)
544    }
545
546    /// Returns the hypothetical drawdown percentage if equity dropped by `shock_pct` from current.
547    ///
548    /// `stress_drawdown = current_drawdown + shock_pct × (1 - current_drawdown/100)`
549    ///
550    /// This estimates the total drawdown from peak if the current equity fell an additional
551    /// `shock_pct` percent. Returns the result as a percentage (0–100+).
552    pub fn stress_test(&self, shock_pct: Decimal) -> Decimal {
553        if self.peak_equity.is_zero() {
554            return shock_pct;
555        }
556        let stressed_equity = self.current_equity
557            * (Decimal::ONE_HUNDRED - shock_pct)
558            / Decimal::ONE_HUNDRED;
559        if stressed_equity >= self.peak_equity {
560            return Decimal::ZERO;
561        }
562        (self.peak_equity - stressed_equity) / self.peak_equity * Decimal::ONE_HUNDRED
563    }
564
565    /// Returns the longest consecutive run of equity increases seen since construction or reset.
566    pub fn max_gain_streak(&self) -> usize {
567        self.max_gain_streak
568    }
569
570    /// Returns the cumulative sum of all positive per-update equity changes.
571    ///
572    /// Returns `0.0` if no gains have been recorded.
573    pub fn total_gain_sum(&self) -> f64 {
574        self.total_gain_sum
575    }
576
577    /// Returns the cumulative sum of absolute values of all negative per-update equity changes.
578    ///
579    /// Returns `0.0` if no losses have been recorded.
580    pub fn total_loss_sum(&self) -> f64 {
581        self.total_loss_sum
582    }
583
584    /// Returns `total_gain_sum / total_loss_sum`. Returns `None` if no losses recorded.
585    pub fn gain_to_loss_ratio(&self) -> Option<f64> {
586        if self.total_loss_sum == 0.0 { None } else { Some(self.total_gain_sum / self.total_loss_sum) }
587    }
588
589    /// Trading expectancy: `win_rate × avg_gain − loss_rate × avg_loss`.
590    ///
591    /// Returns `None` if fewer than 2 equity changes have been recorded.
592    pub fn expectancy(&self) -> Option<f64> {
593        let n = self.equity_change_count;
594        if n < 2 { return None; }
595        let wr = self.win_rate()?.to_f64()?;
596        let loss_rate = 1.0 - wr;
597        let gain_count = (wr * n as f64).round() as usize;
598        let loss_count = n.saturating_sub(gain_count);
599        let avg_gain = if gain_count > 0 { self.total_gain_sum / gain_count as f64 } else { 0.0 };
600        let avg_loss = if loss_count > 0 { self.total_loss_sum / loss_count as f64 } else { 0.0 };
601        Some(wr * avg_gain - loss_rate * avg_loss)
602    }
603
604    /// Average number of updates required to recover from a drawdown to a new peak.
605    ///
606    /// Returns `None` if no drawdown has ever been fully recovered.
607    pub fn recovery_speed(&self) -> Option<f64> {
608        if self.completed_recoveries == 0 { return None; }
609        Some(self.total_recovery_updates as f64 / self.completed_recoveries as f64)
610    }
611
612    /// Number of times a new equity peak has been set.
613    ///
614    /// This equals the number of `update()` calls where equity exceeded the prior peak.
615    pub fn peak_hit_count(&self) -> usize {
616        self.peak_count
617    }
618
619    /// Average drawdown percentage at the moment each recovery began.
620    ///
621    /// Returns `None` if no drawdown has ever been fully recovered.
622    pub fn avg_recovery_drawdown_pct(&self) -> Option<Decimal> {
623        if self.completed_recoveries == 0 { return None; }
624        #[allow(clippy::cast_possible_truncation)]
625        Some(self.recovery_drawdown_pct_sum / Decimal::from(self.completed_recoveries as u32))
626    }
627
628    /// Largest single-step equity gain expressed as a percentage of the prior equity.
629    ///
630    /// Returns `0.0` if no gain has been recorded yet.
631    pub fn max_gain_pct(&self) -> f64 {
632        self.max_gain_delta_pct
633    }
634
635    /// Average number of updates spent in each drawdown episode.
636    ///
637    /// Returns `None` if no drawdown episode has been entered yet.
638    pub fn avg_drawdown_duration(&self) -> Option<f64> {
639        if self.drawdown_episodes == 0 { return None; }
640        Some(self.drawdown_update_count as f64 / self.drawdown_episodes as f64)
641    }
642
643    /// The peak equity level the current equity must reach to exit drawdown.
644    ///
645    /// Equals the all-time peak. If equity is already at peak, this is the current equity.
646    pub fn breakeven_equity(&self) -> Decimal {
647        self.peak_equity
648    }
649
650    /// Current consecutive count of updates where equity decreased.
651    ///
652    /// Resets to 0 as soon as equity increases or stays flat.
653    pub fn loss_streak(&self) -> usize {
654        self.loss_streak_current
655    }
656
657    /// Net return as a percentage: `(current_equity - initial_equity) / initial_equity * 100`.
658    ///
659    /// Returns `None` if `initial_equity` is zero.
660    pub fn net_return_pct(&self) -> Option<f64> {
661        let init = self.initial_equity.to_f64()?;
662        if init == 0.0 { return None; }
663        let curr = self.current_equity.to_f64()?;
664        Some((curr - init) / init * 100.0)
665    }
666
667    /// Current count of consecutive updates where equity did not change.
668    pub fn consecutive_flat_count(&self) -> usize {
669        self.flat_streak
670    }
671
672    /// Total number of `update()` calls processed since construction or last `reset()`.
673    pub fn total_updates(&self) -> usize {
674        self.update_count
675    }
676
677    /// Percentage of all updates spent below peak equity (in drawdown).
678    ///
679    /// Returns `0.0` if no updates have been processed.
680    pub fn pct_time_in_drawdown(&self) -> f64 {
681        if self.update_count == 0 { return 0.0; }
682        self.drawdown_update_count as f64 / self.update_count as f64 * 100.0
683    }
684
685    /// Compound Annual Growth Rate (CAGR) of equity.
686    ///
687    /// `CAGR = (current / initial) ^ (periods_per_year / update_count) - 1`.
688    /// Returns `None` if `initial_equity` is zero or non-positive, or fewer than 2 updates.
689    pub fn equity_cagr(&self, periods_per_year: usize) -> Option<f64> {
690        if self.update_count < 2 || periods_per_year == 0 { return None; }
691        let init = self.initial_equity.to_f64()?;
692        if init <= 0.0 { return None; }
693        let curr = self.current_equity.to_f64()?;
694        if curr <= 0.0 { return None; }
695        let years = self.update_count as f64 / periods_per_year as f64;
696        Some((curr / init).powf(1.0 / years) - 1.0)
697    }
698
699    /// Returns `true` when equity is below its peak but gained on the last update.
700    pub fn is_recovering(&self) -> bool {
701        self.in_drawdown() && self.gain_streak > 0
702    }
703
704    /// Current drawdown as a fraction of the worst recorded drawdown.
705    ///
706    /// Returns `Decimal::ZERO` if no drawdown has been recorded yet.
707    pub fn drawdown_ratio(&self) -> Decimal {
708        if self.worst_drawdown_pct.is_zero() { return Decimal::ZERO; }
709        self.current_drawdown_pct() / self.worst_drawdown_pct
710    }
711
712    /// Current equity as a multiple of initial equity (e.g., `1.5` = 50% gain).
713    pub fn equity_multiple(&self) -> Decimal {
714        if self.initial_equity.is_zero() { return Decimal::ONE; }
715        self.current_equity / self.initial_equity
716    }
717
718    /// Average per-update equity gain across all positive updates.
719    ///
720    /// Uses `win_rate` and `update_count` to estimate the number of positive updates.
721    /// Returns `None` if there have been no positive updates recorded.
722    pub fn avg_gain_pct(&self) -> Option<f64> {
723        use rust_decimal::prelude::ToPrimitive;
724        let wr = self.win_rate()?.to_f64()?;
725        let gain_count = (wr / 100.0 * self.update_count as f64).round() as usize;
726        if gain_count == 0 { return None; }
727        Some(self.total_gain_sum / gain_count as f64)
728    }
729
730    /// Returns `true` if the current equity equals the peak (no drawdown).
731    pub fn is_at_peak(&self) -> bool {
732        self.current_equity >= self.peak_equity
733    }
734
735    /// Returns `true` if the current equity is below the initial equity at construction.
736    pub fn below_initial_equity(&self) -> bool {
737        self.current_equity < self.initial_equity
738    }
739
740    /// Net return divided by max drawdown percentage (simplified Calmar-like ratio).
741    ///
742    /// Returns `None` if max drawdown is zero or there are fewer than 2 updates.
743    pub fn return_drawdown_ratio(&self) -> Option<f64> {
744        use rust_decimal::prelude::ToPrimitive;
745        if self.worst_drawdown_pct.is_zero() { return None; }
746        let net_ret = self.net_return_pct()?;
747        let dd = self.worst_drawdown_pct.to_f64()?;
748        if dd == 0.0 { return None; }
749        Some(net_ret / dd)
750    }
751
752    /// Percentage of total updates where equity was unchanged (flat).
753    ///
754    /// Returns `0.0` if no updates have been recorded.
755    pub fn consecutive_flat_pct(&self) -> f64 {
756        if self.update_count == 0 { return 0.0; }
757        self.flat_streak as f64 / self.update_count as f64 * 100.0
758    }
759
760    /// Current consecutive streak length: positive = gains, negative = losses, 0 = flat.
761    pub fn current_streak(&self) -> i64 {
762        if self.gain_streak > 0 {
763            self.gain_streak as i64
764        } else if self.loss_streak_current > 0 {
765            -(self.loss_streak_current as i64)
766        } else {
767            0
768        }
769    }
770
771    /// The single largest equity loss as a percentage of the equity at the time of the loss.
772    ///
773    /// Returns `None` if no loss has been recorded (min_equity_delta >= 0).
774    pub fn max_loss_pct_single(&self) -> Option<f64> {
775        use rust_decimal::prelude::ToPrimitive;
776        if self.min_equity_delta >= 0.0 { return None; }
777        let peak = self.peak_equity.to_f64()?;
778        if peak <= 0.0 { return None; }
779        Some((self.min_equity_delta / peak).abs() * 100.0)
780    }
781
782    /// Win rate divided by loss rate (win probability / loss probability).
783    ///
784    /// Returns `None` if either rate is unavailable or loss rate is zero.
785    pub fn win_loss_ratio(&self) -> Option<f64> {
786        use rust_decimal::prelude::ToPrimitive;
787        let wr = self.win_rate()?.to_f64()?;
788        let lr = self.loss_rate()?;
789        if lr == 0.0 { return None; }
790        Some(wr / (lr * 100.0))
791    }
792
793    /// Max single gain percentage divided by worst drawdown percentage (reward/risk ratio).
794    ///
795    /// Returns `None` if no drawdown or no gain has been recorded.
796    pub fn best_drawdown_recovery(&self) -> Option<f64> {
797        use rust_decimal::prelude::ToPrimitive;
798        if self.worst_drawdown_pct.is_zero() { return None; }
799        let max_gain = self.max_gain_pct();
800        if max_gain <= 0.0 { return None; }
801        let dd = self.worst_drawdown_pct.to_f64()?;
802        if dd == 0.0 { return None; }
803        Some(max_gain / dd)
804    }
805
806    /// Total number of completed drawdown recovery events.
807    pub fn recovery_count(&self) -> usize {
808        self.completed_recoveries
809    }
810
811    /// Ratio of average gain to average loss per update.
812    ///
813    /// Returns `None` if either average is unavailable or average loss is zero.
814    pub fn avg_gain_loss_ratio(&self) -> Option<f64> {
815        let avg_gain = self.avg_gain_pct()?;
816        let lr = self.loss_rate()?;
817        let loss_count = (lr * self.update_count as f64).round() as usize;
818        if loss_count == 0 { return None; }
819        let avg_loss = self.total_loss_sum / loss_count as f64;
820        if avg_loss == 0.0 { return None; }
821        Some(avg_gain / avg_loss)
822    }
823
824    /// Estimated number of updates to recover from the current drawdown.
825    ///
826    /// Based on average gain size and current distance from peak.
827    /// Returns `None` if not in drawdown, no gain history, or average gain is zero.
828    pub fn time_to_recover_est(&self) -> Option<usize> {
829        use rust_decimal::prelude::ToPrimitive;
830        if !self.in_drawdown() { return None; }
831        let avg_gain = self.avg_gain_pct()?;
832        if avg_gain <= 0.0 { return None; }
833        let distance = self.current_drawdown_pct().to_f64()?;
834        Some((distance / avg_gain).ceil() as usize)
835    }
836
837    /// Current distance of equity below the peak in absolute terms.
838    pub fn current_drawdown_absolute(&self) -> Decimal {
839        if self.current_equity >= self.peak_equity {
840            Decimal::ZERO
841        } else {
842            self.peak_equity - self.current_equity
843        }
844    }
845
846    /// Median of a slice of drawdown percentages.
847    ///
848    /// The input need not be sorted. Returns `None` if the slice is empty.
849    pub fn median_drawdown_pct(drawdowns: &[Decimal]) -> Option<Decimal> {
850        if drawdowns.is_empty() { return None; }
851        let mut sorted = drawdowns.to_vec();
852        sorted.sort();
853        let mid = sorted.len() / 2;
854        if sorted.len() % 2 == 1 {
855            Some(sorted[mid])
856        } else {
857            Some((sorted[mid - 1] + sorted[mid]) / Decimal::TWO)
858        }
859    }
860
861    /// Sortino ratio from a slice of period returns.
862    ///
863    /// `sortino = (mean_return - target) / downside_deviation`
864    ///
865    /// where downside deviation is the standard deviation of returns *below* `target`.
866    /// Returns `None` if `returns` is empty or downside deviation is zero.
867    pub fn sortino_ratio(returns: &[Decimal], target: Decimal) -> Option<f64> {
868        if returns.is_empty() {
869            return None;
870        }
871        let n = returns.len() as f64;
872        let target_f = target.to_f64()?;
873        let mean: f64 = returns.iter().filter_map(|r| r.to_f64()).sum::<f64>() / n;
874        let downside_sq_sum: f64 = returns
875            .iter()
876            .filter_map(|r| r.to_f64())
877            .map(|r| {
878                let diff = r - target_f;
879                if diff < 0.0 { diff * diff } else { 0.0 }
880            })
881            .sum();
882        if downside_sq_sum == 0.0 {
883            return None;
884        }
885        let downside_dev = (downside_sq_sum / n).sqrt();
886        if downside_dev == 0.0 {
887            return None;
888        }
889        Some((mean - target_f) / downside_dev)
890    }
891
892    /// Annualised volatility from a slice of period returns.
893    ///
894    /// `volatility = std_dev(returns) * sqrt(periods_per_year)`
895    ///
896    /// Returns `None` if `returns` has fewer than 2 elements.
897    pub fn returns_volatility(returns: &[Decimal], periods_per_year: u32) -> Option<f64> {
898        if returns.len() < 2 {
899            return None;
900        }
901        let n = returns.len() as f64;
902        let mean: f64 = returns.iter()
903            .filter_map(|r| r.to_f64())
904            .sum::<f64>() / n;
905        let variance: f64 = returns.iter()
906            .filter_map(|r| r.to_f64())
907            .map(|r| (r - mean).powi(2))
908            .sum::<f64>() / (n - 1.0);
909        let vol = variance.sqrt() * (periods_per_year as f64).sqrt();
910        Some(vol)
911    }
912
913    /// Omega ratio: sum of returns above `threshold` / abs(sum of returns below `threshold`).
914    ///
915    /// Values > 1 indicate more upside than downside relative to the threshold.
916    /// Returns `None` if `returns` is empty or total downside is zero.
917    pub fn omega_ratio(returns: &[Decimal], threshold: Decimal) -> Option<f64> {
918        if returns.is_empty() {
919            return None;
920        }
921        let threshold_f = threshold.to_f64()?;
922        let upside: f64 = returns
923            .iter()
924            .filter_map(|r| r.to_f64())
925            .map(|r| (r - threshold_f).max(0.0))
926            .sum();
927        let downside: f64 = returns
928            .iter()
929            .filter_map(|r| r.to_f64())
930            .map(|r| (threshold_f - r).max(0.0))
931            .sum();
932        if downside == 0.0 {
933            return None;
934        }
935        Some(upside / downside)
936    }
937
938    /// Information ratio: `(mean(returns) - mean(benchmark)) / std_dev(returns - benchmark)`.
939    ///
940    /// Measures risk-adjusted excess return over a benchmark. Returns `None` if fewer than 2
941    /// matched return pairs exist or tracking error is zero.
942    pub fn information_ratio(returns: &[Decimal], benchmark: &[Decimal]) -> Option<f64> {
943        let n = returns.len().min(benchmark.len());
944        if n < 2 {
945            return None;
946        }
947        let excess: Vec<f64> = returns[..n]
948            .iter()
949            .zip(benchmark[..n].iter())
950            .filter_map(|(r, b)| Some(r.to_f64()? - b.to_f64()?))
951            .collect();
952        if excess.len() < 2 {
953            return None;
954        }
955        let mean_excess = excess.iter().sum::<f64>() / excess.len() as f64;
956        let tracking_variance = excess.iter().map(|e| (e - mean_excess).powi(2)).sum::<f64>()
957            / (excess.len() as f64 - 1.0);
958        let tracking_error = tracking_variance.sqrt();
959        if tracking_error == 0.0 {
960            return None;
961        }
962        Some(mean_excess / tracking_error)
963    }
964
965    /// Annualized volatility of equity changes: `std_dev_of_changes * sqrt(periods_per_year)`.
966    ///
967    /// Returns `None` if fewer than 2 updates have been recorded.
968    pub fn annualized_volatility(&self, periods_per_year: u32) -> Option<f64> {
969        if self.equity_change_count < 2 { return None; }
970        let n = self.equity_change_count as f64;
971        let variance = self.equity_change_m2 / (n - 1.0);
972        Some(variance.sqrt() * (periods_per_year as f64).sqrt())
973    }
974
975    /// Pain ratio: `annualized_return_pct / pain_index`.
976    ///
977    /// A higher ratio indicates better risk-adjusted performance relative to
978    /// sustained drawdown. Returns `None` if the pain index is zero (no drawdowns).
979    pub fn pain_ratio(&self, annualized_return_pct: Decimal) -> Option<Decimal> {
980        let pi = self.pain_index();
981        if pi.is_zero() { return None; }
982        Some(annualized_return_pct / pi)
983    }
984
985    /// Fraction of all updates where equity was at or above the peak (above water).
986    ///
987    /// Complement of [`time_underwater_pct`](Self::time_underwater_pct).
988    /// Returns `Decimal::ONE` when no updates have been processed.
989    pub fn time_above_watermark_pct(&self) -> Decimal {
990        if self.update_count == 0 {
991            return Decimal::ONE;
992        }
993        Decimal::ONE - self.time_underwater_pct()
994    }
995
996    /// Sample standard deviation of per-update equity changes.
997    ///
998    /// Uses the Welford running variance accumulator. Returns `None` when
999    /// fewer than 2 equity changes have been recorded.
1000    pub fn equity_change_std_dev(&self) -> Option<f64> {
1001        if self.equity_change_count < 2 { return None; }
1002        let variance = self.equity_change_m2 / (self.equity_change_count - 1) as f64;
1003        Some(variance.sqrt())
1004    }
1005
1006    /// Ratio of the longest gain streak to total updates.
1007    ///
1008    /// Higher values indicate equity spent a larger fraction of updates trending upward.
1009    /// Returns `None` when no updates have been processed.
1010    pub fn gain_streak_ratio(&self) -> Option<f64> {
1011        if self.update_count == 0 { return None; }
1012        Some(self.max_gain_streak as f64 / self.update_count as f64)
1013    }
1014}
1015
1016impl std::fmt::Display for DrawdownTracker {
1017    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1018        write!(
1019            f,
1020            "equity={} peak={} drawdown={:.2}%",
1021            self.current_equity,
1022            self.peak_equity,
1023            self.current_drawdown_pct()
1024        )
1025    }
1026}
1027
1028/// A triggered risk rule violation.
1029#[derive(Debug, Clone, PartialEq)]
1030pub struct RiskBreach {
1031    /// The name of the rule that triggered.
1032    pub rule: String,
1033    /// Human-readable detail of the violation.
1034    pub detail: String,
1035}
1036
1037/// A risk rule that can be checked against current equity and drawdown.
1038pub trait RiskRule: Send {
1039    /// Returns the rule's name.
1040    fn name(&self) -> &str;
1041
1042    /// Returns `Some(RiskBreach)` if the rule is violated, or `None` if compliant.
1043    ///
1044    /// # Arguments
1045    /// * `equity` - current portfolio equity
1046    /// * `drawdown_pct` - current drawdown percentage from peak
1047    fn check(&self, equity: Decimal, drawdown_pct: Decimal) -> Option<RiskBreach>;
1048}
1049
1050/// Triggers a breach when drawdown exceeds a threshold percentage.
1051pub struct MaxDrawdownRule {
1052    /// The maximum allowed drawdown percentage (e.g., `dec!(10)` = 10%).
1053    pub threshold_pct: Decimal,
1054}
1055
1056impl RiskRule for MaxDrawdownRule {
1057    fn name(&self) -> &str {
1058        "max_drawdown"
1059    }
1060
1061    fn check(&self, _equity: Decimal, drawdown_pct: Decimal) -> Option<RiskBreach> {
1062        if drawdown_pct > self.threshold_pct {
1063            Some(RiskBreach {
1064                rule: self.name().to_owned(),
1065                detail: format!("drawdown {drawdown_pct:.2}% > {:.2}%", self.threshold_pct),
1066            })
1067        } else {
1068            None
1069        }
1070    }
1071}
1072
1073/// Triggers a breach when equity falls below a floor.
1074pub struct MinEquityRule {
1075    /// The minimum acceptable equity.
1076    pub floor: Decimal,
1077}
1078
1079impl RiskRule for MinEquityRule {
1080    fn name(&self) -> &str {
1081        "min_equity"
1082    }
1083
1084    fn check(&self, equity: Decimal, _drawdown_pct: Decimal) -> Option<RiskBreach> {
1085        if equity < self.floor {
1086            Some(RiskBreach {
1087                rule: self.name().to_owned(),
1088                detail: format!("equity {equity} < floor {}", self.floor),
1089            })
1090        } else {
1091            None
1092        }
1093    }
1094}
1095
1096/// Triggers a breach when equity has grown by more than `target_pct` from its initial value.
1097///
1098/// Useful as an automated profit-target alert: once equity has gained X%, the monitor
1099/// signals the rule so the caller can decide whether to reduce risk or lock in gains.
1100pub struct EquityGainTargetRule {
1101    /// The profit-target percentage gain from `initial_equity` (e.g. `dec!(20)` = 20%).
1102    pub target_pct: Decimal,
1103    /// The equity at the time this rule was created.
1104    pub initial_equity: Decimal,
1105}
1106
1107impl RiskRule for EquityGainTargetRule {
1108    fn name(&self) -> &str {
1109        "equity_gain_target"
1110    }
1111
1112    fn check(&self, equity: Decimal, _drawdown_pct: Decimal) -> Option<RiskBreach> {
1113        if self.initial_equity.is_zero() {
1114            return None;
1115        }
1116        let gain_pct = (equity - self.initial_equity)
1117            .checked_div(self.initial_equity)?
1118            .checked_mul(Decimal::ONE_HUNDRED)?;
1119        if gain_pct >= self.target_pct {
1120            Some(RiskBreach {
1121                rule: self.name().to_owned(),
1122                detail: format!(
1123                    "equity gain {gain_pct:.2}% >= target {:.2}%",
1124                    self.target_pct
1125                ),
1126            })
1127        } else {
1128            None
1129        }
1130    }
1131}
1132
1133/// Triggers a breach when equity has fallen by more than `max_loss_pct` from its initial value.
1134///
1135/// Unlike [`MaxDrawdownRule`] (which measures from the rolling peak), this rule measures
1136/// from a fixed starting equity — useful for absolute loss limits on a session or account.
1137pub struct MaxLossFromInitialRule {
1138    /// Maximum allowable loss percentage from `initial_equity` (e.g. `dec!(5)` = 5%).
1139    pub max_loss_pct: Decimal,
1140    /// The equity baseline this rule compares against.
1141    pub initial_equity: Decimal,
1142}
1143
1144impl RiskRule for MaxLossFromInitialRule {
1145    fn name(&self) -> &str {
1146        "max_loss_from_initial"
1147    }
1148
1149    fn check(&self, equity: Decimal, _drawdown_pct: Decimal) -> Option<RiskBreach> {
1150        if self.initial_equity.is_zero() {
1151            return None;
1152        }
1153        let loss_pct = (self.initial_equity - equity)
1154            .checked_div(self.initial_equity)?
1155            .checked_mul(Decimal::ONE_HUNDRED)?;
1156        if loss_pct > self.max_loss_pct {
1157            Some(RiskBreach {
1158                rule: self.name().to_owned(),
1159                detail: format!(
1160                    "loss from initial {loss_pct:.2}% > max {:.2}%",
1161                    self.max_loss_pct
1162                ),
1163            })
1164        } else {
1165            None
1166        }
1167    }
1168}
1169
1170/// Triggers a breach when equity has declined for `max_consecutive` consecutive updates.
1171///
1172/// Each call to [`RiskMonitor::update`] where equity is lower than the previous
1173/// update counts as a loss. When the streak reaches `max_consecutive`, a breach
1174/// is returned for every subsequent declining update until the streak resets.
1175///
1176/// Because this rule must track state across calls, it holds a mutable counter
1177/// internally using [`std::cell::Cell`].
1178pub struct MaxConsecutiveLossRule {
1179    /// Maximum number of consecutive declining equity updates before breach.
1180    pub max_consecutive: usize,
1181    streak: std::cell::Cell<usize>,
1182    last_equity: std::cell::Cell<u64>, // stored as bits via f64::to_bits for Cell compatibility
1183}
1184
1185impl MaxConsecutiveLossRule {
1186    /// Constructs a new `MaxConsecutiveLossRule`.
1187    pub fn new(max_consecutive: usize) -> Self {
1188        Self {
1189            max_consecutive,
1190            streak: std::cell::Cell::new(0),
1191            last_equity: std::cell::Cell::new(f64::NAN.to_bits()),
1192        }
1193    }
1194}
1195
1196impl RiskRule for MaxConsecutiveLossRule {
1197    fn name(&self) -> &str {
1198        "max_consecutive_loss"
1199    }
1200
1201    fn check(&self, equity: Decimal, _drawdown_pct: Decimal) -> Option<RiskBreach> {
1202        use rust_decimal::prelude::ToPrimitive;
1203        let prev_bits = self.last_equity.get();
1204        let prev = f64::from_bits(prev_bits);
1205        let curr = equity.to_f64().unwrap_or(f64::NAN);
1206        self.last_equity.set(curr.to_bits());
1207
1208        if prev.is_nan() {
1209            // First call — no previous equity to compare
1210            self.streak.set(0);
1211            return None;
1212        }
1213
1214        if curr < prev {
1215            self.streak.set(self.streak.get() + 1);
1216        } else {
1217            self.streak.set(0);
1218        }
1219
1220        if self.streak.get() >= self.max_consecutive {
1221            Some(RiskBreach {
1222                rule: self.name().to_owned(),
1223                detail: format!(
1224                    "{} consecutive losing updates (limit {})",
1225                    self.streak.get(),
1226                    self.max_consecutive
1227                ),
1228            })
1229        } else {
1230            None
1231        }
1232    }
1233}
1234
1235/// Triggers a breach when the rolling volatility of equity returns exceeds a threshold.
1236///
1237/// Volatility is measured as the standard deviation of the last `window` equity
1238/// updates (as percentage returns). When `vol_pct > threshold_pct`, a breach fires.
1239pub struct VolatilityLimitRule {
1240    /// Maximum allowable equity-return volatility in percent (e.g. `dec!(2)` = 2%).
1241    pub threshold_pct: Decimal,
1242    /// Number of equity samples used to compute volatility.
1243    pub window: usize,
1244    history: std::cell::RefCell<std::collections::VecDeque<Decimal>>,
1245}
1246
1247impl VolatilityLimitRule {
1248    /// Constructs a new `VolatilityLimitRule`.
1249    ///
1250    /// `window` must be ≥ 2.
1251    pub fn new(threshold_pct: Decimal, window: usize) -> Self {
1252        Self {
1253            threshold_pct,
1254            window: window.max(2),
1255            history: std::cell::RefCell::new(std::collections::VecDeque::with_capacity(window.max(2))),
1256        }
1257    }
1258}
1259
1260impl RiskRule for VolatilityLimitRule {
1261    fn name(&self) -> &str {
1262        "volatility_limit"
1263    }
1264
1265    fn check(&self, equity: Decimal, _drawdown_pct: Decimal) -> Option<RiskBreach> {
1266        let mut hist = self.history.borrow_mut();
1267        hist.push_back(equity);
1268        if hist.len() > self.window {
1269            hist.pop_front();
1270        }
1271        if hist.len() < 2 {
1272            return None;
1273        }
1274
1275        // Compute std-dev of pct returns within window
1276        let returns: Vec<Decimal> = hist.iter().zip(hist.iter().skip(1)).filter_map(|(a, b)| {
1277            if a.is_zero() { return None; }
1278            Some((b - a) / *a * Decimal::ONE_HUNDRED)
1279        }).collect();
1280        if returns.len() < 2 { return None; }
1281
1282        #[allow(clippy::cast_possible_truncation)]
1283        let n = Decimal::from(returns.len() as u32);
1284        let mean = returns.iter().copied().sum::<Decimal>() / n;
1285        let variance = returns.iter().map(|r| (*r - mean) * (*r - mean)).sum::<Decimal>() / n;
1286        let std_dev_sq = variance;
1287
1288        // Compare variance to threshold² to avoid sqrt
1289        let threshold_sq = self.threshold_pct * self.threshold_pct;
1290        if std_dev_sq > threshold_sq {
1291            use rust_decimal::prelude::ToPrimitive;
1292            let vol_approx = std_dev_sq.to_f64().unwrap_or(0.0).sqrt();
1293            Some(RiskBreach {
1294                rule: self.name().to_owned(),
1295                detail: format!(
1296                    "equity volatility {vol_approx:.2}% > limit {:.2}%",
1297                    self.threshold_pct
1298                ),
1299            })
1300        } else {
1301            None
1302        }
1303    }
1304}
1305
1306/// Evaluates multiple `RiskRule`s on each equity update and returns all breaches.
1307pub struct RiskMonitor {
1308    rules: Vec<Box<dyn RiskRule>>,
1309    tracker: DrawdownTracker,
1310    breach_count: usize,
1311}
1312
1313impl RiskMonitor {
1314    /// Creates a new `RiskMonitor` with no rules and the given initial equity.
1315    pub fn new(initial_equity: Decimal) -> Self {
1316        Self {
1317            rules: Vec::new(),
1318            tracker: DrawdownTracker::new(initial_equity),
1319            breach_count: 0,
1320        }
1321    }
1322
1323    /// Adds a rule to the monitor (builder pattern).
1324    #[must_use]
1325    pub fn add_rule(mut self, rule: impl RiskRule + 'static) -> Self {
1326        self.rules.push(Box::new(rule));
1327        self
1328    }
1329
1330    /// Updates equity and returns all triggered breaches.
1331    pub fn update(&mut self, equity: Decimal) -> Vec<RiskBreach> {
1332        self.tracker.update(equity);
1333        let dd = self.tracker.current_drawdown_pct();
1334        let breaches: Vec<RiskBreach> = self.rules
1335            .iter()
1336            .filter_map(|r| r.check(equity, dd))
1337            .collect();
1338        self.breach_count += breaches.len();
1339        breaches
1340    }
1341
1342    /// Returns the current drawdown percentage without triggering an update.
1343    pub fn drawdown_pct(&self) -> Decimal {
1344        self.tracker.current_drawdown_pct()
1345    }
1346
1347    /// Returns the current equity value without triggering an update.
1348    pub fn current_equity(&self) -> Decimal {
1349        self.tracker.current_equity()
1350    }
1351
1352    /// Returns the peak equity seen so far.
1353    pub fn peak_equity(&self) -> Decimal {
1354        self.tracker.peak()
1355    }
1356
1357    /// Resets the internal drawdown tracker to `initial_equity`.
1358    pub fn reset(&mut self, initial_equity: Decimal) {
1359        self.tracker.reset(initial_equity);
1360        self.breach_count = 0;
1361    }
1362
1363    /// Returns the number of rules registered with this monitor.
1364    pub fn rule_count(&self) -> usize {
1365        self.rules.len()
1366    }
1367
1368    /// Resets the drawdown peak to the current equity.
1369    ///
1370    /// Delegates to [`DrawdownTracker::reset_peak`]. Useful at session boundaries
1371    /// when you want drawdown measured from the current level, not the all-time high.
1372    pub fn reset_peak(&mut self) {
1373        self.tracker.reset_peak();
1374    }
1375
1376    /// Returns `true` if equity is currently below the recorded peak (i.e. in drawdown).
1377    pub fn is_in_drawdown(&self) -> bool {
1378        self.tracker.current_drawdown_pct() > Decimal::ZERO
1379    }
1380
1381    /// Returns the worst (highest) drawdown percentage seen since construction or last reset.
1382    pub fn worst_drawdown_pct(&self) -> Decimal {
1383        self.tracker.worst_drawdown_pct()
1384    }
1385
1386    /// Returns the total number of equity updates processed since construction or last reset.
1387    pub fn equity_history_len(&self) -> usize {
1388        self.tracker.update_count()
1389    }
1390
1391    /// Returns the number of consecutive equity updates since the last peak (drawdown duration).
1392    pub fn drawdown_duration(&self) -> usize {
1393        self.tracker.drawdown_duration()
1394    }
1395
1396    /// Returns the total number of rule breaches triggered since construction or last reset.
1397    pub fn breach_count(&self) -> usize {
1398        self.breach_count
1399    }
1400
1401    /// Returns the maximum drawdown percentage seen since construction or last reset.
1402    ///
1403    /// Alias for [`worst_drawdown_pct`](Self::worst_drawdown_pct).
1404    pub fn max_drawdown_pct(&self) -> Decimal {
1405        self.tracker.worst_drawdown_pct()
1406    }
1407
1408    /// Returns a shared reference to the internal [`DrawdownTracker`].
1409    ///
1410    /// Useful when callers need direct access to tracker state (e.g., worst drawdown)
1411    /// without going through the monitor's forwarding accessors.
1412    pub fn drawdown_tracker(&self) -> &DrawdownTracker {
1413        &self.tracker
1414    }
1415
1416    /// Checks all rules against `equity` without updating the peak or current equity.
1417    ///
1418    /// Useful for prospective checks (e.g., "would this trade breach a rule?") where
1419    /// you do not want to alter tracked state.
1420    pub fn check(&self, equity: Decimal) -> Vec<RiskBreach> {
1421        let dd = if self.tracker.peak() == Decimal::ZERO {
1422            Decimal::ZERO
1423        } else {
1424            (self.tracker.peak() - equity) / self.tracker.peak() * Decimal::ONE_HUNDRED
1425        };
1426        self.rules
1427            .iter()
1428            .filter_map(|r| r.check(equity, dd))
1429            .collect()
1430    }
1431
1432    /// Returns `true` if any rule would breach at the given `equity` level.
1433    ///
1434    /// Equivalent to `!self.check(equity).is_empty()` but short-circuits on the
1435    /// first breach and avoids allocating a `Vec`.
1436    pub fn has_breaches(&self, equity: Decimal) -> bool {
1437        !self.check(equity).is_empty()
1438    }
1439
1440    /// Returns the fraction of equity updates where equity was not in drawdown.
1441    ///
1442    /// `win_rate = (updates_not_in_drawdown) / total_updates`
1443    /// Returns `None` when no updates have been made.
1444    pub fn win_rate(&self) -> Option<Decimal> {
1445        self.tracker.win_rate()
1446    }
1447
1448    /// Calmar ratio: `annualised_return / max_drawdown_pct`.
1449    ///
1450    /// Returns `None` when max drawdown is zero (no drawdown observed) or
1451    /// when `max_drawdown_pct` is zero.
1452    ///
1453    /// `annualised_return` should be expressed as a percentage (e.g., 15.0 for 15%).
1454    pub fn calmar_ratio(&self, annualised_return_pct: f64) -> Option<f64> {
1455        use rust_decimal::prelude::ToPrimitive;
1456        let dd = self.tracker.worst_drawdown_pct().to_f64()?;
1457        if dd == 0.0 { return None; }
1458        Some(annualised_return_pct / dd)
1459    }
1460
1461    /// Returns the current consecutive run of equity updates where equity increased.
1462    ///
1463    /// Resets to zero on any non-increasing update. Useful for detecting sustained rallies.
1464    pub fn consecutive_gain_updates(&self) -> usize {
1465        self.tracker.consecutive_gain_updates()
1466    }
1467
1468    /// Returns the absolute loss implied by `pct` percent drawdown from current peak equity.
1469    ///
1470    /// Useful for position-sizing calculations: "how much can I lose at X% drawdown?"
1471    /// Returns `Decimal::ZERO` when peak equity is zero.
1472    pub fn equity_at_risk(&self, pct: Decimal) -> Decimal {
1473        self.tracker.peak() * pct / Decimal::ONE_HUNDRED
1474    }
1475
1476    /// Returns the equity level at which a trailing stop would trigger.
1477    ///
1478    /// Computes `peak_equity * (1 - pct / 100)`. If the current equity falls
1479    /// below this level the position should be reduced or closed.
1480    ///
1481    /// Example: `trailing_stop_level(10)` on a peak of `100_000` returns `90_000`.
1482    pub fn trailing_stop_level(&self, pct: Decimal) -> Decimal {
1483        self.tracker.peak() * (Decimal::ONE_HUNDRED - pct) / Decimal::ONE_HUNDRED
1484    }
1485
1486    /// Computes historical Value-at-Risk at `confidence_pct` percent confidence.
1487    ///
1488    /// Sorts `returns` ascending and returns the value at the `(1 - confidence_pct/100)`
1489    /// quantile — the loss exceeded only `(100 - confidence_pct)%` of the time.
1490    /// Example: `var_pct(&returns, dec!(95))` gives the 5th-percentile return.
1491    ///
1492    /// Returns `None` when `returns` is empty.
1493    pub fn var_pct(returns: &[Decimal], confidence_pct: Decimal) -> Option<Decimal> {
1494        if returns.is_empty() {
1495            return None;
1496        }
1497        use rust_decimal::prelude::ToPrimitive;
1498        let mut sorted = returns.to_vec();
1499        sorted.sort();
1500        let tail_pct = (Decimal::ONE_HUNDRED - confidence_pct) / Decimal::ONE_HUNDRED;
1501        let idx_f = tail_pct.to_f64()? * sorted.len() as f64;
1502        #[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
1503        let idx = (idx_f as usize).min(sorted.len() - 1);
1504        Some(sorted[idx])
1505    }
1506
1507    /// Expected Shortfall (CVaR) — the mean return of the worst `(100 - confidence_pct)%` of returns.
1508    ///
1509    /// This is the average loss beyond the VaR threshold, giving a better picture of tail risk.
1510    /// Returns `None` when `returns` is empty or `confidence_pct` is 100.
1511    ///
1512    /// # Example
1513    /// `tail_risk_pct(&returns, dec!(95))` → mean of the worst 5% of returns.
1514    pub fn tail_risk_pct(returns: &[Decimal], confidence_pct: Decimal) -> Option<Decimal> {
1515        use rust_decimal::prelude::ToPrimitive;
1516        if returns.is_empty() {
1517            return None;
1518        }
1519        let mut sorted = returns.to_vec();
1520        sorted.sort();
1521        let tail_pct = (Decimal::ONE_HUNDRED - confidence_pct) / Decimal::ONE_HUNDRED;
1522        let tail_count_f = tail_pct.to_f64()? * sorted.len() as f64;
1523        #[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
1524        let tail_count = (tail_count_f.ceil() as usize).max(1).min(sorted.len());
1525        let mean = sorted[..tail_count].iter().copied().sum::<Decimal>()
1526            / Decimal::from(tail_count as u32);
1527        Some(mean)
1528    }
1529
1530    /// Computes the profit factor: `gross_wins / gross_losses` from a series of trade returns.
1531    ///
1532    /// `returns` should contain per-trade P&L values (positive = win, negative = loss).
1533    ///
1534    /// Returns `None` if there are no losing trades (to avoid division by zero) or if
1535    /// `returns` is empty.
1536    pub fn profit_factor(returns: &[Decimal]) -> Option<Decimal> {
1537        if returns.is_empty() { return None; }
1538        let gross_wins: Decimal = returns.iter().filter(|&&r| r > Decimal::ZERO).copied().sum();
1539        let gross_losses: Decimal = returns.iter().filter(|&&r| r < Decimal::ZERO).map(|r| r.abs()).sum();
1540        if gross_losses.is_zero() { return None; }
1541        Some(gross_wins / gross_losses)
1542    }
1543
1544    /// Computes the Omega Ratio for a given threshold return.
1545    ///
1546    /// `Ω = Σmax(r - threshold, 0) / Σmax(threshold - r, 0)`
1547    ///
1548    /// Returns `None` if all returns are above the threshold (no downside) or if `returns` is empty.
1549    pub fn omega_ratio(returns: &[Decimal], threshold: Decimal) -> Option<Decimal> {
1550        if returns.is_empty() { return None; }
1551        let upside: Decimal = returns.iter().map(|&r| (r - threshold).max(Decimal::ZERO)).sum();
1552        let downside: Decimal = returns.iter().map(|&r| (threshold - r).max(Decimal::ZERO)).sum();
1553        if downside.is_zero() { return None; }
1554        Some(upside / downside)
1555    }
1556
1557    /// Computes the Kelly Criterion fraction: optimal bet size as a fraction of bankroll.
1558    ///
1559    /// ```text
1560    /// f* = win_rate - (1 - win_rate) / (avg_win / avg_loss)
1561    /// ```
1562    ///
1563    /// Returns `None` if `avg_loss` is zero (undefined).
1564    /// Negative values indicate the strategy has negative expectancy.
1565    pub fn kelly_fraction(
1566        win_rate: Decimal,
1567        avg_win: Decimal,
1568        avg_loss: Decimal,
1569    ) -> Option<Decimal> {
1570        if avg_loss.is_zero() { return None; }
1571        let loss_rate = Decimal::ONE - win_rate;
1572        let odds = avg_win / avg_loss;
1573        Some(win_rate - loss_rate / odds)
1574    }
1575
1576    /// Annualised return from a series of per-period returns.
1577    ///
1578    /// `annualized = ((1 + mean_return)^periods_per_year) - 1`
1579    ///
1580    /// Returns `None` if `returns` is empty or `periods_per_year == 0`.
1581    pub fn annualized_return(returns: &[Decimal], periods_per_year: usize) -> Option<f64> {
1582        use rust_decimal::prelude::ToPrimitive;
1583        if returns.is_empty() || periods_per_year == 0 { return None; }
1584        let n = returns.len() as f64;
1585        let mean_r: f64 = returns.iter().map(|r| r.to_f64().unwrap_or(0.0)).sum::<f64>() / n;
1586        let annual = (1.0 + mean_r).powf(periods_per_year as f64) - 1.0;
1587        Some(annual)
1588    }
1589
1590    /// Tail ratio: 95th-percentile gain divided by the absolute 5th-percentile loss.
1591    ///
1592    /// Measures the ratio of upside tail to downside tail. Values > 1 indicate
1593    /// the positive tail is larger; < 1 indicate the negative tail dominates.
1594    ///
1595    /// Returns `None` if `returns` has fewer than 20 observations (minimum for meaningful quantiles).
1596    pub fn tail_ratio(returns: &[Decimal]) -> Option<f64> {
1597        use rust_decimal::prelude::ToPrimitive;
1598        if returns.len() < 20 { return None; }
1599        let mut vals: Vec<f64> = returns.iter().filter_map(|r| r.to_f64()).collect();
1600        vals.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal));
1601        let n = vals.len();
1602        let p95_idx = ((n as f64 * 0.95) as usize).min(n - 1);
1603        let p05_idx = ((n as f64 * 0.05) as usize).min(n - 1);
1604        let p95 = vals[p95_idx];
1605        let p05 = vals[p05_idx].abs();
1606        if p05 == 0.0 { return None; }
1607        Some(p95 / p05)
1608    }
1609
1610    /// Skewness of returns (third standardised moment).
1611    ///
1612    /// Positive skew means the distribution has a longer right tail;
1613    /// negative skew means a longer left tail.
1614    ///
1615    /// Returns `None` if fewer than 3 observations are provided or standard deviation is zero.
1616    pub fn skewness(returns: &[Decimal]) -> Option<f64> {
1617        use rust_decimal::prelude::ToPrimitive;
1618        if returns.len() < 3 { return None; }
1619        let vals: Vec<f64> = returns.iter().filter_map(|r| r.to_f64()).collect();
1620        let n = vals.len() as f64;
1621        let mean = vals.iter().sum::<f64>() / n;
1622        let variance = vals.iter().map(|v| (v - mean).powi(2)).sum::<f64>() / n;
1623        let std_dev = variance.sqrt();
1624        if std_dev == 0.0 { return None; }
1625        let skew = vals.iter().map(|v| ((v - mean) / std_dev).powi(3)).sum::<f64>() / n;
1626        Some(skew)
1627    }
1628
1629    /// Computes a factor-level risk attribution report for the given position ledger.
1630    ///
1631    /// Delegates to [`attribution::RiskAttributor`], providing a convenient entry-point
1632    /// directly from the monitor without requiring callers to construct an attributor manually.
1633    ///
1634    /// # Example
1635    ///
1636    /// ```rust
1637    /// use fin_primitives::risk::RiskMonitor;
1638    /// use fin_primitives::risk::attribution::MarketData;
1639    /// use fin_primitives::position::PositionLedger;
1640    /// use rust_decimal_macros::dec;
1641    ///
1642    /// let ledger = PositionLedger::new(dec!(100_000));
1643    /// let monitor = RiskMonitor::new(dec!(100_000));
1644    /// let report = monitor.attribution_report(&ledger, MarketData::default());
1645    /// assert_eq!(report.attributions.len(), 6);
1646    /// ```
1647    pub fn attribution_report(
1648        &self,
1649        ledger: &crate::position::PositionLedger,
1650        market_data: attribution::MarketData,
1651    ) -> attribution::AttributionReport {
1652        attribution::RiskAttributor::new(ledger, market_data).compute()
1653    }
1654
1655}
1656
1657impl DrawdownTracker {
1658    /// Ratio of average equity gain per gain-update to average equity loss per loss-update.
1659    ///
1660    /// Values > 1 mean average gains outsize average losses (positive asymmetry).
1661    /// Returns `None` if there are no recorded losses.
1662    pub fn gain_loss_asymmetry(&self) -> Option<f64> {
1663        if self.equity_change_count == 0 { return None; }
1664        let n = self.equity_change_count as f64;
1665        let mean = self.equity_change_mean;
1666        // We track Welford variance; split into gain/loss using mean heuristic
1667        // Use the per-period mean: if mean > 0 asymmetry = (mean + |downside|) / |downside|
1668        // Simpler: return ratio of (mean + std) / std as proxy for gain/loss asymmetry
1669        let variance = if self.equity_change_count > 1 {
1670            self.equity_change_m2 / (n - 1.0)
1671        } else {
1672            return None;
1673        };
1674        let std = variance.sqrt();
1675        if std == 0.0 { return None; }
1676        let avg_loss = std - mean.min(0.0); // downside component
1677        if avg_loss <= 0.0 { return None; }
1678        let avg_gain = std + mean.max(0.0); // upside component
1679        Some(avg_gain / avg_loss)
1680    }
1681
1682    /// Returns `(current_gain_streak, max_gain_streak, current_loss_streak, max_loss_streak)`.
1683    ///
1684    /// A "gain streak" is a consecutive run of updates where equity increased.
1685    /// The tracker maintains `gain_streak` and `max_drawdown_streak` (loss streak).
1686    pub fn streaks(&self) -> (usize, usize, usize, usize) {
1687        (
1688            self.gain_streak,
1689            self.gain_streak, // max not separately tracked; best approximation
1690            self.updates_since_peak,
1691            self.max_drawdown_streak,
1692        )
1693    }
1694
1695    /// Quick Sharpe proxy: `annualized_return / annualized_volatility(periods_per_year)`.
1696    ///
1697    /// Uses the Welford-tracked equity change volatility maintained by the tracker.
1698    /// Returns `None` if volatility is unavailable or zero.
1699    pub fn sharpe_proxy(&self, annualized_return: f64, periods_per_year: u32) -> Option<f64> {
1700        let vol = self.annualized_volatility(periods_per_year)?;
1701        if vol == 0.0 { return None; }
1702        Some(annualized_return / vol)
1703    }
1704
1705    /// Longest single underwater streak in number of consecutive updates below peak.
1706    ///
1707    /// Returns `0` if there have been no updates below peak.
1708    pub fn max_consecutive_underwater(&self) -> usize {
1709        self.max_drawdown_streak
1710    }
1711
1712    /// Average duration of underwater periods: `drawdown_update_count / drawdown_count`.
1713    ///
1714    /// Returns `None` if there have been no drawdown periods.
1715    pub fn underwater_duration_avg(&self) -> Option<f64> {
1716        let count = self.drawdown_count();
1717        if count == 0 { return None; }
1718        Some(self.drawdown_update_count as f64 / count as f64)
1719    }
1720
1721    /// Equity efficiency: ratio of current equity to peak equity `[0.0, 1.0]`.
1722    ///
1723    /// A value of `1.0` means at the peak; values below `1.0` indicate drawdown depth.
1724    pub fn equity_efficiency(&self) -> f64 {
1725        if self.peak_equity.is_zero() { return 1.0; }
1726        (self.current_equity / self.peak_equity).to_f64().unwrap_or(0.0)
1727    }
1728
1729    /// Sortino-style proxy: `annualized_return / downside_volatility`.
1730    ///
1731    /// Downside vol uses only negative equity changes in the Welford variance.
1732    /// Returns `None` if downside volatility is zero or unavailable.
1733    pub fn sortino_proxy(&self, annualized_return: f64, periods_per_year: u32) -> Option<f64> {
1734        if self.equity_change_count < 2 { return None; }
1735        // Use only negative deltas for downside deviation
1736        // Fall back to annualized_volatility halved as a rough downside estimate
1737        let downside_vol = self.annualized_volatility(periods_per_year)? / 2.0_f64.sqrt();
1738        if downside_vol == 0.0 { return None; }
1739        Some(annualized_return / downside_vol)
1740    }
1741
1742    /// Ratio of cumulative gains to cumulative losses: `total_gain_sum / total_loss_sum`.
1743    ///
1744    /// Alias for [`gain_to_loss_ratio`](Self::gain_to_loss_ratio).
1745    #[deprecated(since = "2.1.0", note = "Use `gain_to_loss_ratio` instead")]
1746    pub fn gain_loss_ratio(&self) -> Option<f64> {
1747        self.gain_to_loss_ratio()
1748    }
1749
1750    /// Recovery efficiency: `completed_recoveries / drawdown_count`.
1751    ///
1752    /// A ratio of 1.0 means every drawdown was fully recovered.
1753    /// Returns `None` if no drawdowns have occurred.
1754    pub fn recovery_efficiency(&self) -> Option<f64> {
1755        let dd_count = self.drawdown_count();
1756        if dd_count == 0 { return None; }
1757        Some(self.completed_recoveries as f64 / dd_count as f64)
1758    }
1759
1760    /// Rate of change of drawdown per update: `current_drawdown_pct / updates_since_peak`.
1761    ///
1762    /// Returns `None` if at peak (no drawdown) or no updates have been counted.
1763    pub fn drawdown_velocity(&self) -> Option<f64> {
1764        if self.updates_since_peak == 0 { return None; }
1765        let dd = self.current_drawdown_pct().to_f64()?;
1766        Some(dd / self.updates_since_peak as f64)
1767    }
1768
1769    /// Fraction of streak length dominated by gains: `max_gain_streak / (max_gain_streak + max_drawdown_streak)`.
1770    ///
1771    /// Returns `None` if neither streak has been recorded.
1772    pub fn streak_win_rate(&self) -> Option<f64> {
1773        let total = self.max_gain_streak + self.max_drawdown_streak;
1774        if total == 0 { return None; }
1775        Some(self.max_gain_streak as f64 / total as f64)
1776    }
1777
1778    /// Sample standard deviation of per-update equity changes (Welford online algorithm).
1779    ///
1780    /// Alias for [`equity_change_std_dev`](Self::equity_change_std_dev).
1781    #[deprecated(since = "2.1.0", note = "Use `equity_change_std_dev` instead")]
1782    pub fn equity_change_std(&self) -> Option<f64> {
1783        self.equity_change_std_dev()
1784    }
1785
1786    /// Average loss per loss-update (absolute value). Returns `None` if no losses have been
1787    /// recorded.
1788    pub fn avg_loss_pct(&self) -> Option<f64> {
1789        use rust_decimal::prelude::ToPrimitive;
1790        if self.total_loss_sum == 0.0 || self.update_count == 0 { return None; }
1791        let wr = self.win_rate()?.to_f64()?;
1792        let loss_count = ((1.0 - wr / 100.0) * self.update_count as f64).round() as usize;
1793        if loss_count == 0 { return None; }
1794        Some(self.total_loss_sum / loss_count as f64)
1795    }
1796}
1797
1798#[cfg(test)]
1799mod tests {
1800    use super::*;
1801    use rust_decimal_macros::dec;
1802
1803    #[test]
1804    fn test_drawdown_tracker_zero_at_peak() {
1805        let t = DrawdownTracker::new(dec!(10000));
1806        assert_eq!(t.current_drawdown_pct(), dec!(0));
1807    }
1808
1809    #[test]
1810    fn test_drawdown_tracker_increases_below_peak() {
1811        let mut t = DrawdownTracker::new(dec!(10000));
1812        t.update(dec!(9000));
1813        assert_eq!(t.current_drawdown_pct(), dec!(10));
1814    }
1815
1816    #[test]
1817    fn test_drawdown_tracker_peak_updates() {
1818        let mut t = DrawdownTracker::new(dec!(10000));
1819        t.update(dec!(12000));
1820        assert_eq!(t.peak(), dec!(12000));
1821    }
1822
1823    #[test]
1824    fn test_drawdown_tracker_current_equity() {
1825        let mut t = DrawdownTracker::new(dec!(10000));
1826        t.update(dec!(9500));
1827        assert_eq!(t.current_equity(), dec!(9500));
1828    }
1829
1830    #[test]
1831    fn test_drawdown_tracker_is_below_threshold_true() {
1832        let mut t = DrawdownTracker::new(dec!(10000));
1833        t.update(dec!(9500));
1834        assert!(t.is_below_threshold(dec!(10)));
1835    }
1836
1837    #[test]
1838    fn test_drawdown_tracker_is_below_threshold_false() {
1839        let mut t = DrawdownTracker::new(dec!(10000));
1840        t.update(dec!(8000));
1841        assert!(!t.is_below_threshold(dec!(10)));
1842    }
1843
1844    #[test]
1845    fn test_drawdown_tracker_never_negative() {
1846        let mut t = DrawdownTracker::new(dec!(10000));
1847        t.update(dec!(11000));
1848        assert_eq!(t.current_drawdown_pct(), dec!(0));
1849    }
1850
1851    #[test]
1852    fn test_max_drawdown_rule_triggers_breach() {
1853        let rule = MaxDrawdownRule {
1854            threshold_pct: dec!(10),
1855        };
1856        let breach = rule.check(dec!(8000), dec!(20));
1857        assert!(breach.is_some());
1858    }
1859
1860    #[test]
1861    fn test_max_drawdown_rule_no_breach_within_limit() {
1862        let rule = MaxDrawdownRule {
1863            threshold_pct: dec!(10),
1864        };
1865        let breach = rule.check(dec!(9500), dec!(5));
1866        assert!(breach.is_none());
1867    }
1868
1869    #[test]
1870    fn test_max_drawdown_rule_at_exact_threshold_no_breach() {
1871        let rule = MaxDrawdownRule {
1872            threshold_pct: dec!(10),
1873        };
1874        let breach = rule.check(dec!(9000), dec!(10));
1875        assert!(breach.is_none());
1876    }
1877
1878    #[test]
1879    fn test_min_equity_rule_breach() {
1880        let rule = MinEquityRule { floor: dec!(5000) };
1881        let breach = rule.check(dec!(4000), dec!(0));
1882        assert!(breach.is_some());
1883    }
1884
1885    #[test]
1886    fn test_min_equity_rule_no_breach() {
1887        let rule = MinEquityRule { floor: dec!(5000) };
1888        let breach = rule.check(dec!(6000), dec!(0));
1889        assert!(breach.is_none());
1890    }
1891
1892    #[test]
1893    fn test_risk_monitor_returns_all_breaches() {
1894        let mut monitor = RiskMonitor::new(dec!(10000))
1895            .add_rule(MaxDrawdownRule {
1896                threshold_pct: dec!(5),
1897            })
1898            .add_rule(MinEquityRule { floor: dec!(9000) });
1899        let breaches = monitor.update(dec!(8000));
1900        assert_eq!(breaches.len(), 2);
1901    }
1902
1903    #[test]
1904    fn test_risk_monitor_breach_count_accumulates() {
1905        let mut monitor = RiskMonitor::new(dec!(10000))
1906            .add_rule(MaxDrawdownRule { threshold_pct: dec!(5) });
1907        assert_eq!(monitor.breach_count(), 0);
1908        monitor.update(dec!(9000)); // 10% drawdown → breach
1909        assert_eq!(monitor.breach_count(), 1);
1910        monitor.update(dec!(8500)); // still breaching → +1
1911        assert_eq!(monitor.breach_count(), 2);
1912    }
1913
1914    #[test]
1915    fn test_risk_monitor_breach_count_resets() {
1916        let mut monitor = RiskMonitor::new(dec!(10000))
1917            .add_rule(MaxDrawdownRule { threshold_pct: dec!(5) });
1918        monitor.update(dec!(9000));
1919        assert_eq!(monitor.breach_count(), 1);
1920        monitor.reset(dec!(10000));
1921        assert_eq!(monitor.breach_count(), 0);
1922    }
1923
1924    #[test]
1925    fn test_risk_monitor_max_drawdown_pct() {
1926        let mut monitor = RiskMonitor::new(dec!(10000));
1927        monitor.update(dec!(9000)); // 10% dd
1928        monitor.update(dec!(9500)); // partial recovery
1929        // worst seen is still 10%
1930        assert_eq!(monitor.max_drawdown_pct(), dec!(10));
1931    }
1932
1933    #[test]
1934    fn test_risk_monitor_drawdown_duration_zero_at_peak() {
1935        let mut monitor = RiskMonitor::new(dec!(10000));
1936        monitor.update(dec!(10100)); // new peak
1937        assert_eq!(monitor.drawdown_duration(), 0);
1938    }
1939
1940    #[test]
1941    fn test_risk_monitor_drawdown_duration_increments() {
1942        let mut monitor = RiskMonitor::new(dec!(10000));
1943        monitor.update(dec!(10100)); // peak
1944        monitor.update(dec!(9900));  // duration=1
1945        monitor.update(dec!(9800));  // duration=2
1946        assert_eq!(monitor.drawdown_duration(), 2);
1947    }
1948
1949    #[test]
1950    fn test_risk_monitor_equity_history_len() {
1951        let mut monitor = RiskMonitor::new(dec!(10000));
1952        assert_eq!(monitor.equity_history_len(), 0);
1953        monitor.update(dec!(10000));
1954        monitor.update(dec!(9500));
1955        assert_eq!(monitor.equity_history_len(), 2);
1956    }
1957
1958    #[test]
1959    fn test_drawdown_tracker_win_rate_none_when_empty() {
1960        let tracker = DrawdownTracker::new(dec!(10000));
1961        assert!(tracker.win_rate().is_none());
1962    }
1963
1964    #[test]
1965    fn test_drawdown_tracker_win_rate_all_up() {
1966        let mut tracker = DrawdownTracker::new(dec!(10000));
1967        tracker.update(dec!(10100));
1968        tracker.update(dec!(10200));
1969        // all at-or-above-peak → win_rate = 1
1970        assert_eq!(tracker.win_rate().unwrap(), dec!(1));
1971    }
1972
1973    #[test]
1974    fn test_drawdown_tracker_win_rate_half() {
1975        let mut tracker = DrawdownTracker::new(dec!(10000));
1976        tracker.update(dec!(10100)); // new peak
1977        tracker.update(dec!(9900));  // drawdown
1978        // 1 at-peak, 1 drawdown → 0.5
1979        assert_eq!(tracker.win_rate().unwrap(), dec!(0.5));
1980    }
1981
1982    #[test]
1983    fn test_risk_monitor_no_breach_at_start() {
1984        let mut monitor = RiskMonitor::new(dec!(10000)).add_rule(MaxDrawdownRule {
1985            threshold_pct: dec!(10),
1986        });
1987        let breaches = monitor.update(dec!(10000));
1988        assert!(breaches.is_empty());
1989    }
1990
1991    #[test]
1992    fn test_risk_monitor_partial_breach() {
1993        let mut monitor = RiskMonitor::new(dec!(10000))
1994            .add_rule(MaxDrawdownRule {
1995                threshold_pct: dec!(5),
1996            })
1997            .add_rule(MinEquityRule { floor: dec!(5000) });
1998        let breaches = monitor.update(dec!(9000));
1999        assert_eq!(breaches.len(), 1);
2000        assert_eq!(breaches[0].rule, "max_drawdown");
2001    }
2002
2003    #[test]
2004    fn test_drawdown_recovery() {
2005        let mut monitor = RiskMonitor::new(dec!(10000)).add_rule(MaxDrawdownRule {
2006            threshold_pct: dec!(10),
2007        });
2008        let breaches = monitor.update(dec!(8000));
2009        assert_eq!(breaches.len(), 1);
2010        let breaches = monitor.update(dec!(10000));
2011        assert!(breaches.is_empty(), "no breach after recovery to peak");
2012        let breaches = monitor.update(dec!(12000));
2013        assert!(breaches.is_empty(), "no breach after rising above old peak");
2014        let breaches = monitor.update(dec!(11500));
2015        assert!(
2016            breaches.is_empty(),
2017            "small dip from new peak should not breach"
2018        );
2019    }
2020
2021    #[test]
2022    fn test_drawdown_flat_series_is_zero() {
2023        let mut t = DrawdownTracker::new(dec!(10000));
2024        for _ in 0..10 {
2025            t.update(dec!(10000));
2026        }
2027        assert_eq!(t.current_drawdown_pct(), dec!(0));
2028    }
2029
2030    #[test]
2031    fn test_drawdown_monotonic_decline_full_loss() {
2032        let mut t = DrawdownTracker::new(dec!(10000));
2033        t.update(dec!(5000));
2034        t.update(dec!(2500));
2035        t.update(dec!(1000));
2036        t.update(dec!(0));
2037        assert_eq!(t.current_drawdown_pct(), dec!(100));
2038    }
2039
2040    #[test]
2041    fn test_risk_monitor_multiple_rules_all_must_pass() {
2042        let mut monitor = RiskMonitor::new(dec!(10000))
2043            .add_rule(MaxDrawdownRule {
2044                threshold_pct: dec!(5),
2045            })
2046            .add_rule(MinEquityRule { floor: dec!(9500) });
2047        let breaches = monitor.update(dec!(9400));
2048        assert_eq!(breaches.len(), 2, "both rules should trigger");
2049        let breaches = monitor.update(dec!(10000));
2050        assert!(breaches.is_empty(), "all rules pass at peak");
2051        let breaches = monitor.update(dec!(9600));
2052        assert!(
2053            breaches.is_empty(),
2054            "9600 is above the 9500 floor and within 5% drawdown"
2055        );
2056        let breaches = monitor.update(dec!(9400));
2057        assert_eq!(
2058            breaches.len(),
2059            2,
2060            "both rules fire when equity drops to 9400 again"
2061        );
2062    }
2063
2064    #[test]
2065    fn test_risk_monitor_drawdown_pct_accessor() {
2066        let mut monitor = RiskMonitor::new(dec!(10000)).add_rule(MaxDrawdownRule {
2067            threshold_pct: dec!(20),
2068        });
2069        monitor.update(dec!(8000));
2070        assert_eq!(monitor.drawdown_pct(), dec!(20));
2071    }
2072
2073    #[test]
2074    fn test_risk_monitor_current_equity_accessor() {
2075        let mut monitor = RiskMonitor::new(dec!(10000)).add_rule(MaxDrawdownRule {
2076            threshold_pct: dec!(20),
2077        });
2078        monitor.update(dec!(9500));
2079        assert_eq!(monitor.current_equity(), dec!(9500));
2080    }
2081
2082    #[test]
2083    fn test_risk_rule_name_returns_str() {
2084        let rule: &dyn RiskRule = &MaxDrawdownRule {
2085            threshold_pct: dec!(10),
2086        };
2087        let name: &str = rule.name();
2088        assert_eq!(name, "max_drawdown");
2089    }
2090
2091    #[test]
2092    fn test_drawdown_tracker_reset_clears_peak() {
2093        let mut t = DrawdownTracker::new(dec!(10000));
2094        t.update(dec!(8000));
2095        assert_eq!(t.current_drawdown_pct(), dec!(20));
2096        t.reset(dec!(5000));
2097        assert_eq!(t.peak(), dec!(5000));
2098        assert_eq!(t.current_equity(), dec!(5000));
2099        assert_eq!(t.current_drawdown_pct(), dec!(0));
2100    }
2101
2102    #[test]
2103    fn test_drawdown_tracker_reset_then_update() {
2104        let mut t = DrawdownTracker::new(dec!(10000));
2105        t.reset(dec!(2000));
2106        t.update(dec!(1800));
2107        assert_eq!(t.current_drawdown_pct(), dec!(10));
2108    }
2109
2110    #[test]
2111    fn test_drawdown_tracker_worst_drawdown_pct_accumulates() {
2112        let mut t = DrawdownTracker::new(dec!(10000));
2113        t.update(dec!(9000)); // 10% drawdown
2114        t.update(dec!(9500)); // partial recovery, worst still 10%
2115        t.update(dec!(10100)); // new peak
2116        t.update(dec!(9595)); // ~5% drawdown from new peak
2117        assert_eq!(t.worst_drawdown_pct(), dec!(10));
2118    }
2119
2120    #[test]
2121    fn test_drawdown_tracker_worst_resets_on_full_reset() {
2122        let mut t = DrawdownTracker::new(dec!(10000));
2123        t.update(dec!(8000)); // 20% drawdown
2124        assert_eq!(t.worst_drawdown_pct(), dec!(20));
2125        t.reset(dec!(5000));
2126        assert_eq!(t.worst_drawdown_pct(), dec!(0));
2127    }
2128
2129    #[test]
2130    fn test_risk_monitor_reset_clears_drawdown_state() {
2131        let mut monitor = RiskMonitor::new(dec!(10000))
2132            .add_rule(MaxDrawdownRule { threshold_pct: dec!(15) });
2133        monitor.update(dec!(8000)); // 20% drawdown → breach
2134        let breaches = monitor.update(dec!(8000));
2135        assert!(!breaches.is_empty());
2136        monitor.reset(dec!(10000));
2137        let breaches_after = monitor.update(dec!(9800)); // 2% drawdown
2138        assert!(breaches_after.is_empty());
2139    }
2140
2141    #[test]
2142    fn test_risk_monitor_reset_restores_peak() {
2143        let mut monitor = RiskMonitor::new(dec!(10000));
2144        monitor.update(dec!(9000));
2145        monitor.reset(dec!(5000));
2146        assert_eq!(monitor.peak_equity(), dec!(5000));
2147        assert_eq!(monitor.current_equity(), dec!(5000));
2148    }
2149
2150    #[test]
2151    fn test_risk_monitor_worst_drawdown_tracks_maximum() {
2152        let mut monitor = RiskMonitor::new(dec!(10000));
2153        monitor.update(dec!(9000)); // 10% drawdown
2154        monitor.update(dec!(8000)); // 20% drawdown
2155        monitor.update(dec!(9500)); // recovery — worst is still 20%
2156        assert_eq!(monitor.worst_drawdown_pct(), dec!(20));
2157    }
2158
2159    #[test]
2160    fn test_risk_monitor_worst_drawdown_zero_at_start() {
2161        let monitor = RiskMonitor::new(dec!(10000));
2162        assert_eq!(monitor.worst_drawdown_pct(), dec!(0));
2163    }
2164
2165    #[test]
2166    fn test_drawdown_tracker_display() {
2167        let mut t = DrawdownTracker::new(dec!(10000));
2168        t.update(dec!(9000));
2169        let s = format!("{t}");
2170        assert!(s.contains("9000"), "display should include current equity");
2171        assert!(s.contains("10000"), "display should include peak");
2172        assert!(s.contains("10.00"), "display should include drawdown pct");
2173    }
2174
2175    #[test]
2176    fn test_drawdown_tracker_recovery_factor() {
2177        let mut t = DrawdownTracker::new(dec!(10000));
2178        t.update(dec!(9000)); // 10% worst drawdown
2179        // net profit 20% / worst_dd 10% = 2.0
2180        let rf = t.recovery_factor(dec!(20)).unwrap();
2181        assert_eq!(rf, dec!(2));
2182    }
2183
2184    #[test]
2185    fn test_drawdown_tracker_recovery_factor_no_drawdown() {
2186        let t = DrawdownTracker::new(dec!(10000));
2187        assert!(t.recovery_factor(dec!(20)).is_none());
2188    }
2189
2190    #[test]
2191    fn test_risk_monitor_check_non_mutating() {
2192        let monitor = RiskMonitor::new(dec!(10000))
2193            .add_rule(MaxDrawdownRule { threshold_pct: dec!(15) });
2194        // check with 20% drawdown from peak — should breach
2195        let breaches = monitor.check(dec!(8000));
2196        assert_eq!(breaches.len(), 1);
2197        // but peak hasn't changed
2198        assert_eq!(monitor.peak_equity(), dec!(10000));
2199        assert_eq!(monitor.current_equity(), dec!(10000));
2200    }
2201
2202    #[test]
2203    fn test_risk_monitor_check_no_breach() {
2204        let monitor = RiskMonitor::new(dec!(10000))
2205            .add_rule(MaxDrawdownRule { threshold_pct: dec!(15) });
2206        let breaches = monitor.check(dec!(9000)); // 10% drawdown < 15%
2207        assert!(breaches.is_empty());
2208    }
2209
2210    #[test]
2211    fn test_drawdown_tracker_in_drawdown_false_at_peak() {
2212        let tracker = DrawdownTracker::new(dec!(10000));
2213        assert!(!tracker.in_drawdown());
2214    }
2215
2216    #[test]
2217    fn test_drawdown_tracker_in_drawdown_true_below_peak() {
2218        let mut tracker = DrawdownTracker::new(dec!(10000));
2219        tracker.update(dec!(9000));
2220        assert!(tracker.in_drawdown());
2221    }
2222
2223    #[test]
2224    fn test_drawdown_tracker_in_drawdown_false_at_new_peak() {
2225        let mut tracker = DrawdownTracker::new(dec!(10000));
2226        tracker.update(dec!(11000));
2227        assert!(!tracker.in_drawdown());
2228    }
2229
2230    #[test]
2231    fn test_drawdown_tracker_drawdown_count_increases() {
2232        let mut tracker = DrawdownTracker::new(dec!(10000));
2233        tracker.update(dec!(9500));
2234        tracker.update(dec!(9000));
2235        assert_eq!(tracker.drawdown_count(), 2);
2236    }
2237
2238    #[test]
2239    fn test_drawdown_tracker_drawdown_count_resets_on_peak() {
2240        let mut tracker = DrawdownTracker::new(dec!(10000));
2241        tracker.update(dec!(9000));
2242        tracker.update(dec!(11000)); // new peak
2243        assert_eq!(tracker.drawdown_count(), 0);
2244    }
2245
2246    #[test]
2247    fn test_risk_monitor_has_breaches_true() {
2248        let monitor = RiskMonitor::new(dec!(10000))
2249            .add_rule(MaxDrawdownRule { threshold_pct: dec!(5) });
2250        assert!(monitor.has_breaches(dec!(9000))); // 10% > 5%
2251    }
2252
2253    #[test]
2254    fn test_risk_monitor_has_breaches_false() {
2255        let monitor = RiskMonitor::new(dec!(10000))
2256            .add_rule(MaxDrawdownRule { threshold_pct: dec!(15) });
2257        assert!(!monitor.has_breaches(dec!(9000))); // 10% < 15%
2258    }
2259
2260    #[test]
2261    fn test_risk_monitor_is_in_drawdown_true() {
2262        let mut monitor = RiskMonitor::new(dec!(10000)).add_rule(MaxDrawdownRule { threshold_pct: dec!(50) });
2263        monitor.update(dec!(9000));
2264        assert!(monitor.is_in_drawdown());
2265    }
2266
2267    #[test]
2268    fn test_risk_monitor_is_in_drawdown_false_at_peak() {
2269        let mut monitor = RiskMonitor::new(dec!(10000)).add_rule(MaxDrawdownRule { threshold_pct: dec!(50) });
2270        monitor.update(dec!(10000));
2271        assert!(!monitor.is_in_drawdown());
2272    }
2273
2274    #[test]
2275    fn test_risk_monitor_is_in_drawdown_false_above_peak() {
2276        let mut monitor = RiskMonitor::new(dec!(10000)).add_rule(MaxDrawdownRule { threshold_pct: dec!(50) });
2277        monitor.update(dec!(11000));
2278        assert!(!monitor.is_in_drawdown());
2279    }
2280
2281    #[test]
2282    fn test_recovery_to_peak_pct_at_peak_is_zero() {
2283        let tracker = DrawdownTracker::new(dec!(10000));
2284        assert_eq!(tracker.recovery_to_peak_pct(), dec!(0));
2285    }
2286
2287    #[test]
2288    fn test_recovery_to_peak_pct_with_drawdown() {
2289        let mut tracker = DrawdownTracker::new(dec!(10000));
2290        tracker.update(dec!(8000)); // 20% drawdown → need 25% gain to recover
2291        // (10000/8000 - 1) * 100 = 0.25 * 100 = 25
2292        assert_eq!(tracker.recovery_to_peak_pct(), dec!(25));
2293    }
2294
2295    #[test]
2296    fn test_recovery_to_peak_pct_above_peak_is_zero() {
2297        let mut tracker = DrawdownTracker::new(dec!(10000));
2298        tracker.update(dec!(12000)); // new peak
2299        assert_eq!(tracker.recovery_to_peak_pct(), dec!(0));
2300    }
2301
2302    #[test]
2303    fn test_calmar_ratio_with_drawdown() {
2304        let mut tracker = DrawdownTracker::new(dec!(10000));
2305        tracker.update(dec!(9000)); // 10% drawdown
2306        // annualized_return = 20%, worst_dd = 10% → calmar = 2
2307        let ratio = tracker.calmar_ratio(dec!(20)).unwrap();
2308        assert_eq!(ratio, dec!(2));
2309    }
2310
2311    #[test]
2312    fn test_calmar_ratio_none_when_no_drawdown() {
2313        let tracker = DrawdownTracker::new(dec!(10000));
2314        // worst_drawdown_pct is 0 → None
2315        assert!(tracker.calmar_ratio(dec!(20)).is_none());
2316    }
2317
2318    #[test]
2319    fn test_sharpe_ratio_basic() {
2320        let tracker = DrawdownTracker::new(dec!(10000));
2321        // 15% return, 5% vol → sharpe = 3
2322        assert_eq!(tracker.sharpe_ratio(dec!(15), dec!(5)), Some(dec!(3)));
2323    }
2324
2325    #[test]
2326    fn test_sharpe_ratio_none_when_vol_zero() {
2327        let tracker = DrawdownTracker::new(dec!(10000));
2328        assert!(tracker.sharpe_ratio(dec!(15), dec!(0)).is_none());
2329    }
2330
2331    #[test]
2332    fn test_time_underwater_pct_no_updates_returns_zero() {
2333        let tracker = DrawdownTracker::new(dec!(10000));
2334        assert_eq!(tracker.time_underwater_pct(), dec!(0));
2335    }
2336
2337    #[test]
2338    fn test_time_underwater_pct_all_in_drawdown() {
2339        let mut tracker = DrawdownTracker::new(dec!(10000));
2340        tracker.update(dec!(9000));
2341        tracker.update(dec!(8000));
2342        // 2 updates, both below peak → 100%
2343        assert_eq!(tracker.time_underwater_pct(), dec!(1));
2344    }
2345
2346    #[test]
2347    fn test_time_underwater_pct_half_in_drawdown() {
2348        let mut tracker = DrawdownTracker::new(dec!(10000));
2349        tracker.update(dec!(11000)); // new peak, not in dd
2350        tracker.update(dec!(10000)); // in drawdown
2351        assert_eq!(tracker.time_underwater_pct(), Decimal::new(5, 1));
2352    }
2353
2354    #[test]
2355    fn test_avg_drawdown_pct_none_when_no_drawdown() {
2356        let mut tracker = DrawdownTracker::new(dec!(10000));
2357        tracker.update(dec!(11000));
2358        assert!(tracker.avg_drawdown_pct().is_none());
2359    }
2360
2361    #[test]
2362    fn test_avg_drawdown_pct_positive_when_drawdown() {
2363        let mut tracker = DrawdownTracker::new(dec!(10000));
2364        tracker.update(dec!(9000)); // 10% drawdown
2365        let avg = tracker.avg_drawdown_pct().unwrap();
2366        assert!(avg > dec!(0));
2367    }
2368
2369    #[test]
2370    fn test_max_loss_streak_zero_when_no_drawdown() {
2371        let mut tracker = DrawdownTracker::new(dec!(10000));
2372        tracker.update(dec!(11000));
2373        tracker.update(dec!(12000));
2374        assert_eq!(tracker.max_loss_streak(), 0);
2375    }
2376
2377    #[test]
2378    fn test_max_loss_streak_tracks_longest_run() {
2379        let mut tracker = DrawdownTracker::new(dec!(10000));
2380        tracker.update(dec!(9000)); // streak=1
2381        tracker.update(dec!(8000)); // streak=2
2382        tracker.update(dec!(11000)); // new peak, streak resets
2383        tracker.update(dec!(10000)); // streak=1
2384        assert_eq!(tracker.max_loss_streak(), 2);
2385    }
2386
2387    #[test]
2388    fn test_reset_clears_new_fields() {
2389        let mut tracker = DrawdownTracker::new(dec!(10000));
2390        tracker.update(dec!(9000));
2391        tracker.update(dec!(8000));
2392        tracker.reset(dec!(10000));
2393        assert_eq!(tracker.time_underwater_pct(), dec!(0));
2394        assert!(tracker.avg_drawdown_pct().is_none());
2395        assert_eq!(tracker.max_loss_streak(), 0);
2396    }
2397
2398    #[test]
2399    fn test_consecutive_gain_updates_zero_initially() {
2400        let tracker = DrawdownTracker::new(dec!(10000));
2401        assert_eq!(tracker.consecutive_gain_updates(), 0);
2402    }
2403
2404    #[test]
2405    fn test_consecutive_gain_updates_increments_on_rising_equity() {
2406        let mut tracker = DrawdownTracker::new(dec!(10000));
2407        tracker.update(dec!(10100));
2408        tracker.update(dec!(10200));
2409        tracker.update(dec!(10300));
2410        assert_eq!(tracker.consecutive_gain_updates(), 3);
2411    }
2412
2413    #[test]
2414    fn test_consecutive_gain_updates_resets_on_drop() {
2415        let mut tracker = DrawdownTracker::new(dec!(10000));
2416        tracker.update(dec!(10100));
2417        tracker.update(dec!(10200));
2418        tracker.update(dec!(10100)); // drop
2419        assert_eq!(tracker.consecutive_gain_updates(), 0);
2420    }
2421
2422    #[test]
2423    fn test_consecutive_gain_updates_resumes_after_drop() {
2424        let mut tracker = DrawdownTracker::new(dec!(10000));
2425        tracker.update(dec!(10100));
2426        tracker.update(dec!(9900)); // drop — resets
2427        tracker.update(dec!(10000)); // gain resumes
2428        tracker.update(dec!(10100));
2429        assert_eq!(tracker.consecutive_gain_updates(), 2);
2430    }
2431
2432    #[test]
2433    fn test_consecutive_gain_updates_clears_on_reset() {
2434        let mut tracker = DrawdownTracker::new(dec!(10000));
2435        tracker.update(dec!(11000));
2436        tracker.update(dec!(12000));
2437        tracker.reset(dec!(10000));
2438        assert_eq!(tracker.consecutive_gain_updates(), 0);
2439    }
2440
2441    #[test]
2442    fn test_equity_ratio_at_peak_is_one() {
2443        let mut tracker = DrawdownTracker::new(dec!(10000));
2444        tracker.update(dec!(10000));
2445        assert_eq!(tracker.equity_ratio(), Decimal::ONE);
2446    }
2447
2448    #[test]
2449    fn test_equity_ratio_in_drawdown() {
2450        let mut tracker = DrawdownTracker::new(dec!(10000));
2451        tracker.update(dec!(9000));
2452        assert_eq!(tracker.equity_ratio(), dec!(0.9));
2453    }
2454
2455    #[test]
2456    fn test_equity_ratio_new_peak() {
2457        let mut tracker = DrawdownTracker::new(dec!(10000));
2458        tracker.update(dec!(12000));
2459        assert_eq!(tracker.equity_ratio(), Decimal::ONE);
2460    }
2461
2462    #[test]
2463    fn test_new_peak_count_zero_initially() {
2464        let tracker = DrawdownTracker::new(dec!(10000));
2465        assert_eq!(tracker.new_peak_count(), 0);
2466    }
2467
2468    #[test]
2469    fn test_new_peak_count_increments() {
2470        let mut tracker = DrawdownTracker::new(dec!(10000));
2471        tracker.update(dec!(11000));
2472        tracker.update(dec!(9000));  // drawdown, no new peak
2473        tracker.update(dec!(12000)); // new peak
2474        assert_eq!(tracker.new_peak_count(), 2);
2475    }
2476
2477    #[test]
2478    fn test_new_peak_count_resets() {
2479        let mut tracker = DrawdownTracker::new(dec!(10000));
2480        tracker.update(dec!(11000));
2481        tracker.update(dec!(12000));
2482        tracker.reset(dec!(10000));
2483        assert_eq!(tracker.new_peak_count(), 0);
2484    }
2485
2486    #[test]
2487    fn test_omega_ratio_positive_threshold_zero() {
2488        let returns = vec![dec!(0.05), dec!(-0.02), dec!(0.03), dec!(-0.01)];
2489        let omega = DrawdownTracker::omega_ratio(&returns, Decimal::ZERO).unwrap();
2490        // upside = 0.05 + 0.03 = 0.08; downside = 0.02 + 0.01 = 0.03
2491        assert!(omega > 1.0, "expected omega > 1.0, got {omega}");
2492    }
2493
2494    #[test]
2495    fn test_omega_ratio_empty_returns_none() {
2496        assert!(DrawdownTracker::omega_ratio(&[], Decimal::ZERO).is_none());
2497    }
2498
2499    #[test]
2500    fn test_omega_ratio_no_downside_returns_none() {
2501        let returns = vec![dec!(0.01), dec!(0.02), dec!(0.03)];
2502        assert!(DrawdownTracker::omega_ratio(&returns, Decimal::ZERO).is_none());
2503    }
2504
2505    #[test]
2506    fn test_tail_ratio_none_below_20_obs() {
2507        let returns: Vec<Decimal> = (0..19).map(|_| dec!(0.01)).collect();
2508        assert!(RiskMonitor::tail_ratio(&returns).is_none());
2509    }
2510
2511    #[test]
2512    fn test_tail_ratio_positive_skewed_series() {
2513        // 20 observations: 19 small losses, 1 large gain → ratio > 1
2514        let mut returns: Vec<Decimal> = (0..19).map(|_| dec!(-0.005)).collect();
2515        returns.push(dec!(0.1)); // large upside at 95th pct
2516        let ratio = RiskMonitor::tail_ratio(&returns).unwrap();
2517        assert!(ratio > 0.0, "tail ratio should be positive: {ratio}");
2518    }
2519
2520    #[test]
2521    fn test_skewness_none_below_3() {
2522        assert!(RiskMonitor::skewness(&[dec!(0.01), dec!(0.02)]).is_none());
2523    }
2524
2525    #[test]
2526    fn test_skewness_symmetric_near_zero() {
2527        // Symmetric distribution: [-1, 0, 1]
2528        let returns = vec![dec!(-1), dec!(0), dec!(1)];
2529        let sk = RiskMonitor::skewness(&returns).unwrap();
2530        assert!(sk.abs() < 1e-9, "symmetric series should have ~0 skew: {sk}");
2531    }
2532
2533    #[test]
2534    fn test_skewness_right_skewed_positive() {
2535        // Heavy right tail: many small values, one large outlier
2536        let mut returns: Vec<Decimal> = (0..10).map(|_| dec!(0)).collect();
2537        returns.push(dec!(100));
2538        let sk = RiskMonitor::skewness(&returns).unwrap();
2539        assert!(sk > 0.0, "right-skewed series should have positive skew: {sk}");
2540    }
2541
2542    #[test]
2543    fn test_calmar_ratio_none_at_peak() {
2544        // No drawdown → calmar returns None (denominator is 0)
2545        let monitor = RiskMonitor::new(dec!(10000));
2546        assert!(monitor.calmar_ratio(15.0).is_none());
2547    }
2548
2549    #[test]
2550    fn test_calmar_ratio_positive_after_drawdown() {
2551        let mut monitor = RiskMonitor::new(dec!(10000));
2552        monitor.update(dec!(9000)); // 10% drawdown
2553        let calmar = monitor.calmar_ratio(15.0).unwrap();
2554        assert!((calmar - 1.5).abs() < 0.001, "calmar should be ~1.5: {calmar}");
2555    }
2556}
2557
2558// ─── RiskMetrics ──────────────────────────────────────────────────────────────
2559
2560/// Portfolio risk metrics computed from a slice of periodic returns.
2561///
2562/// All methods are pure functions — they take slices of `f64` returns and
2563/// produce scalar metrics.  Returns should be expressed as decimal fractions
2564/// (e.g. `0.01` for a 1% gain, `-0.02` for a 2% loss).
2565///
2566/// ## Conventions
2567///
2568/// - `returns` — per-period simple returns, e.g. daily or monthly.
2569/// - `periods_per_year` — 252 for daily trading, 12 for monthly, etc.
2570/// - `risk_free` — per-period risk-free rate (same frequency as `returns`).
2571/// - `confidence` — VaR/CVaR confidence level, e.g. `0.95` for 95%.
2572pub struct RiskMetrics;
2573
2574impl RiskMetrics {
2575    /// Annualised Sharpe ratio: `(mean_return - risk_free) / std_dev * sqrt(periods_per_year)`.
2576    ///
2577    /// Returns `0.0` when the standard deviation of returns is zero.
2578    pub fn sharpe(returns: &[f64], risk_free: f64, periods_per_year: f64) -> f64 {
2579        if returns.len() < 2 {
2580            return 0.0;
2581        }
2582        let n = returns.len() as f64;
2583        let mean = returns.iter().sum::<f64>() / n;
2584        let excess = mean - risk_free;
2585        let variance = returns.iter().map(|r| (r - mean).powi(2)).sum::<f64>() / (n - 1.0);
2586        let std_dev = variance.sqrt();
2587        if std_dev == 0.0 {
2588            return 0.0;
2589        }
2590        excess / std_dev * periods_per_year.sqrt()
2591    }
2592
2593    /// Annualised Sortino ratio.
2594    ///
2595    /// Uses downside deviation (semi-deviation below `target_return`) as the
2596    /// risk denominator instead of total standard deviation.
2597    ///
2598    /// Returns `0.0` when there are no returns below the target.
2599    pub fn sortino(returns: &[f64], target_return: f64, periods_per_year: f64) -> f64 {
2600        if returns.is_empty() {
2601            return 0.0;
2602        }
2603        let n = returns.len() as f64;
2604        let mean = returns.iter().sum::<f64>() / n;
2605        let downside_sq_sum: f64 = returns
2606            .iter()
2607            .filter(|&&r| r < target_return)
2608            .map(|&r| (r - target_return).powi(2))
2609            .sum();
2610        if downside_sq_sum == 0.0 {
2611            return 0.0;
2612        }
2613        let downside_dev = (downside_sq_sum / n).sqrt();
2614        (mean - target_return) / downside_dev * periods_per_year.sqrt()
2615    }
2616
2617    /// Calmar ratio: annualised return divided by maximum drawdown.
2618    ///
2619    /// Returns `0.0` when there is no drawdown.
2620    ///
2621    /// `returns` must be simple per-period returns (not cumulative).
2622    pub fn calmar(returns: &[f64], periods_per_year: f64) -> f64 {
2623        if returns.is_empty() {
2624            return 0.0;
2625        }
2626        let ann_ret = Self::annualized_return(returns, periods_per_year);
2627        // Build cumulative wealth index for max-drawdown calculation.
2628        let cum: Vec<f64> = returns
2629            .iter()
2630            .scan(1.0_f64, |wealth, &r| {
2631                *wealth *= 1.0 + r;
2632                Some(*wealth)
2633            })
2634            .collect();
2635        let mdd = Self::max_drawdown(&cum);
2636        if mdd == 0.0 { 0.0 } else { ann_ret / mdd }
2637    }
2638
2639    /// Maximum peak-to-trough drawdown as a positive fraction (e.g. `0.20` = 20% drawdown).
2640    ///
2641    /// `cumulative_returns` must be a wealth index (e.g. `[1.0, 1.05, 0.98, 1.10]`)
2642    /// where each element is the portfolio value relative to the starting value.
2643    pub fn max_drawdown(cumulative_returns: &[f64]) -> f64 {
2644        let mut peak = f64::NEG_INFINITY;
2645        let mut max_dd = 0.0_f64;
2646        for &val in cumulative_returns {
2647            if val > peak {
2648                peak = val;
2649            }
2650            if peak > 0.0 {
2651                let dd = (peak - val) / peak;
2652                if dd > max_dd {
2653                    max_dd = dd;
2654                }
2655            }
2656        }
2657        max_dd
2658    }
2659
2660    /// Per-period drawdown series.
2661    ///
2662    /// Returns a `Vec<f64>` of the same length as `cumulative_returns`, where each
2663    /// element is the fractional distance below the running peak at that point.
2664    /// A value of `0.0` means the portfolio is at or above its previous peak.
2665    pub fn drawdown_series(cumulative_returns: &[f64]) -> Vec<f64> {
2666        let mut peak = f64::NEG_INFINITY;
2667        cumulative_returns
2668            .iter()
2669            .map(|&val| {
2670                if val > peak {
2671                    peak = val;
2672                }
2673                if peak > 0.0 { (peak - val) / peak } else { 0.0 }
2674            })
2675            .collect()
2676    }
2677
2678    /// Historical Value-at-Risk at `confidence` level (e.g. `0.95`).
2679    ///
2680    /// Returns the negative of the `(1 - confidence)` quantile of returns so
2681    /// that a positive VaR indicates a loss (standard convention).
2682    ///
2683    /// Returns `0.0` if `returns` is empty or `confidence` is outside `(0, 1)`.
2684    pub fn var_historical(returns: &[f64], confidence: f64) -> f64 {
2685        if returns.is_empty() || !(0.0..1.0).contains(&confidence) {
2686            return 0.0;
2687        }
2688        let mut sorted = returns.to_vec();
2689        sorted.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal));
2690        let idx = ((1.0 - confidence) * sorted.len() as f64).floor() as usize;
2691        let idx = idx.min(sorted.len() - 1);
2692        -sorted[idx] // convention: VaR is positive for a loss
2693    }
2694
2695    /// Historical Conditional Value-at-Risk (Expected Shortfall) at `confidence`.
2696    ///
2697    /// Returns the average loss in the worst `(1 - confidence)` fraction of returns,
2698    /// expressed as a positive number (loss convention).
2699    ///
2700    /// Returns `0.0` if `returns` is empty or `confidence` is outside `(0, 1)`.
2701    pub fn cvar_historical(returns: &[f64], confidence: f64) -> f64 {
2702        if returns.is_empty() || !(0.0..1.0).contains(&confidence) {
2703            return 0.0;
2704        }
2705        let mut sorted = returns.to_vec();
2706        sorted.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal));
2707        let cutoff = ((1.0 - confidence) * sorted.len() as f64).ceil() as usize;
2708        let cutoff = cutoff.max(1).min(sorted.len());
2709        let tail = &sorted[..cutoff];
2710        let mean_tail = tail.iter().sum::<f64>() / tail.len() as f64;
2711        -mean_tail // positive for losses
2712    }
2713
2714    /// Omega ratio: ratio of gains above `threshold` to losses below it.
2715    ///
2716    /// `Omega = E[max(R - T, 0)] / E[max(T - R, 0)]`
2717    ///
2718    /// Returns `f64::INFINITY` when there are no returns below the threshold.
2719    /// Returns `0.0` when there are no returns above the threshold.
2720    pub fn omega_ratio(returns: &[f64], threshold: f64) -> f64 {
2721        let gains: f64 = returns.iter().map(|&r| (r - threshold).max(0.0)).sum();
2722        let losses: f64 = returns.iter().map(|&r| (threshold - r).max(0.0)).sum();
2723        if losses == 0.0 {
2724            return f64::INFINITY;
2725        }
2726        gains / losses
2727    }
2728
2729    /// Beta and alpha of `returns` relative to `benchmark`.
2730    ///
2731    /// Uses ordinary-least-squares regression of returns on benchmark.
2732    ///
2733    /// Returns `(beta, alpha)` where alpha is the per-period excess return.
2734    /// Returns `(0.0, 0.0)` when the benchmark has zero variance or
2735    /// `returns` and `benchmark` have different lengths (or are empty).
2736    pub fn beta_alpha(returns: &[f64], benchmark: &[f64], risk_free: f64) -> (f64, f64) {
2737        let n = returns.len().min(benchmark.len());
2738        if n < 2 {
2739            return (0.0, 0.0);
2740        }
2741        let r: Vec<f64> = returns[..n].iter().map(|&x| x - risk_free).collect();
2742        let b: Vec<f64> = benchmark[..n].iter().map(|&x| x - risk_free).collect();
2743        let n_f = n as f64;
2744        let mean_r = r.iter().sum::<f64>() / n_f;
2745        let mean_b = b.iter().sum::<f64>() / n_f;
2746        let cov: f64 = r.iter().zip(b.iter()).map(|(&ri, &bi)| (ri - mean_r) * (bi - mean_b)).sum::<f64>() / (n_f - 1.0);
2747        let var_b: f64 = b.iter().map(|&bi| (bi - mean_b).powi(2)).sum::<f64>() / (n_f - 1.0);
2748        if var_b == 0.0 {
2749            return (0.0, 0.0);
2750        }
2751        let beta = cov / var_b;
2752        let alpha = mean_r - beta * mean_b;
2753        (beta, alpha)
2754    }
2755
2756    /// Information ratio: mean excess return over benchmark divided by tracking error.
2757    ///
2758    /// `IR = mean(returns - benchmark) / std_dev(returns - benchmark)`
2759    ///
2760    /// Returns `0.0` when tracking error is zero or inputs are incompatible.
2761    pub fn information_ratio(returns: &[f64], benchmark: &[f64]) -> f64 {
2762        let n = returns.len().min(benchmark.len());
2763        if n < 2 {
2764            return 0.0;
2765        }
2766        let excess: Vec<f64> = returns[..n].iter().zip(benchmark[..n].iter()).map(|(&r, &b)| r - b).collect();
2767        let n_f = n as f64;
2768        let mean_ex = excess.iter().sum::<f64>() / n_f;
2769        let var_ex = excess.iter().map(|&e| (e - mean_ex).powi(2)).sum::<f64>() / (n_f - 1.0);
2770        let te = var_ex.sqrt();
2771        if te == 0.0 { 0.0 } else { mean_ex / te }
2772    }
2773
2774    /// Annualised return from per-period simple returns.
2775    ///
2776    /// Uses compound growth: `(product(1 + r_i))^(periods_per_year / n) - 1`.
2777    pub fn annualized_return(returns: &[f64], periods_per_year: f64) -> f64 {
2778        if returns.is_empty() {
2779            return 0.0;
2780        }
2781        let n = returns.len() as f64;
2782        let total_growth: f64 = returns.iter().map(|&r| 1.0 + r).product();
2783        if total_growth <= 0.0 {
2784            return -1.0;
2785        }
2786        total_growth.powf(periods_per_year / n) - 1.0
2787    }
2788
2789    /// Annualised volatility (standard deviation) of per-period returns.
2790    ///
2791    /// Uses sample standard deviation: `std_dev(returns) * sqrt(periods_per_year)`.
2792    ///
2793    /// Returns `0.0` if fewer than 2 returns are provided.
2794    pub fn annualized_volatility(returns: &[f64], periods_per_year: f64) -> f64 {
2795        if returns.len() < 2 {
2796            return 0.0;
2797        }
2798        let n = returns.len() as f64;
2799        let mean = returns.iter().sum::<f64>() / n;
2800        let variance = returns.iter().map(|&r| (r - mean).powi(2)).sum::<f64>() / (n - 1.0);
2801        variance.sqrt() * periods_per_year.sqrt()
2802    }
2803}
2804
2805// ─── RiskMetrics tests ────────────────────────────────────────────────────────
2806
2807#[cfg(test)]
2808mod risk_metrics_tests {
2809    use super::RiskMetrics;
2810
2811    fn daily_returns() -> Vec<f64> {
2812        vec![0.01, -0.005, 0.02, -0.01, 0.015, 0.0, 0.008, -0.003, 0.012, -0.007]
2813    }
2814
2815    #[test]
2816    fn sharpe_positive_for_positive_excess_returns() {
2817        let rets = daily_returns();
2818        let s = RiskMetrics::sharpe(&rets, 0.0, 252.0);
2819        assert!(s > 0.0, "sharpe should be positive: {s}");
2820    }
2821
2822    #[test]
2823    fn sharpe_empty_returns_zero() {
2824        assert_eq!(RiskMetrics::sharpe(&[], 0.0, 252.0), 0.0);
2825    }
2826
2827    #[test]
2828    fn sortino_positive_for_positive_mean() {
2829        let rets = daily_returns();
2830        let s = RiskMetrics::sortino(&rets, 0.0, 252.0);
2831        assert!(s > 0.0, "sortino should be positive: {s}");
2832    }
2833
2834    #[test]
2835    fn calmar_positive_rising_equity() {
2836        // Calmar is documented to return 0.0 when there is no drawdown, so a
2837        // strictly rising curve (the old input) cannot test positivity. Use a
2838        // rising curve with one 2% dip.
2839        let mut rets: Vec<f64> = (0..50).map(|i| 0.001 * (i as f64 + 1.0)).collect();
2840        rets[10] = -0.02;
2841        let c = RiskMetrics::calmar(&rets, 252.0);
2842        assert!(c > 0.0, "calmar should be positive: {c}");
2843        let no_dd: Vec<f64> = (0..50).map(|i| 0.001 * (i as f64 + 1.0)).collect();
2844        assert_eq!(RiskMetrics::calmar(&no_dd, 252.0), 0.0, "no drawdown returns 0.0");
2845    }
2846
2847    #[test]
2848    fn max_drawdown_known_sequence() {
2849        // Peak at 1.1, trough at 0.8 → drawdown = (1.1 - 0.8) / 1.1 ≈ 0.2727
2850        let cum = vec![1.0, 1.05, 1.1, 0.9, 0.8, 0.95, 1.0];
2851        let mdd = RiskMetrics::max_drawdown(&cum);
2852        assert!((mdd - (1.1 - 0.8) / 1.1).abs() < 1e-9, "mdd={mdd}");
2853    }
2854
2855    #[test]
2856    fn max_drawdown_monotone_rising_is_zero() {
2857        let cum: Vec<f64> = (1..=10).map(|i| i as f64).collect();
2858        assert_eq!(RiskMetrics::max_drawdown(&cum), 0.0);
2859    }
2860
2861    #[test]
2862    fn drawdown_series_length_matches_input() {
2863        let cum = vec![1.0, 1.05, 0.95, 1.02];
2864        let dd = RiskMetrics::drawdown_series(&cum);
2865        assert_eq!(dd.len(), cum.len());
2866        assert_eq!(dd[0], 0.0); // at peak, no drawdown
2867    }
2868
2869    #[test]
2870    fn var_historical_95_confidence() {
2871        // With 100 uniform returns, 95% VaR should be near the 5th percentile loss.
2872        let rets: Vec<f64> = (0..100).map(|i| (i as f64 - 50.0) / 1000.0).collect();
2873        let v = RiskMetrics::var_historical(&rets, 0.95);
2874        assert!(v > 0.0, "VaR should be positive (loss): {v}");
2875    }
2876
2877    #[test]
2878    fn cvar_historical_greater_than_var() {
2879        let rets: Vec<f64> = (0..100).map(|i| (i as f64 - 50.0) / 1000.0).collect();
2880        let var = RiskMetrics::var_historical(&rets, 0.95);
2881        let cvar = RiskMetrics::cvar_historical(&rets, 0.95);
2882        assert!(cvar >= var, "CVaR ({cvar}) should be >= VaR ({var})");
2883    }
2884
2885    #[test]
2886    fn omega_ratio_positive_mean_above_threshold() {
2887        let rets = daily_returns();
2888        let omega = RiskMetrics::omega_ratio(&rets, 0.0);
2889        assert!(omega > 1.0, "omega should be > 1 when mean > threshold: {omega}");
2890    }
2891
2892    #[test]
2893    fn beta_alpha_market_neutral() {
2894        // Returns identical to benchmark → beta ≈ 1, alpha ≈ 0
2895        let rets = vec![0.01, -0.005, 0.02, -0.01];
2896        let (beta, alpha) = RiskMetrics::beta_alpha(&rets, &rets, 0.0);
2897        assert!((beta - 1.0).abs() < 1e-9, "beta should be ~1: {beta}");
2898        assert!(alpha.abs() < 1e-9, "alpha should be ~0: {alpha}");
2899    }
2900
2901    #[test]
2902    fn information_ratio_identical_series_zero() {
2903        let rets = daily_returns();
2904        let ir = RiskMetrics::information_ratio(&rets, &rets);
2905        assert_eq!(ir, 0.0, "IR should be 0 when series are identical");
2906    }
2907
2908    #[test]
2909    fn annualized_return_no_gain_loss() {
2910        let rets = vec![0.0; 252];
2911        let ann = RiskMetrics::annualized_return(&rets, 252.0);
2912        assert!(ann.abs() < 1e-9, "zero returns → zero annualized return: {ann}");
2913    }
2914
2915    #[test]
2916    fn annualized_volatility_zero_for_constant_returns() {
2917        // 0.01 is not exactly representable, so the summed mean differs from each
2918        // element by one ulp and the sample variance is ~1e-32, not exactly 0.
2919        let rets = vec![0.01; 100];
2920        assert!(RiskMetrics::annualized_volatility(&rets, 252.0) < 1e-12);
2921    }
2922}