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