Skip to main content

fin_primitives/risk/
mod.rs

1//! # Module: risk
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
14use rust_decimal::Decimal;
15use rust_decimal::prelude::ToPrimitive;
16
17/// Tracks peak equity and computes current drawdown percentage.
18#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
19pub struct DrawdownTracker {
20    peak_equity: Decimal,
21    current_equity: Decimal,
22    worst_drawdown_pct: Decimal,
23    /// Number of updates since the last new peak.
24    updates_since_peak: usize,
25    /// Total number of equity updates processed.
26    update_count: usize,
27    /// Number of updates where equity was below peak (in drawdown).
28    drawdown_update_count: usize,
29    /// Cumulative sum of drawdown percentages for computing averages.
30    #[serde(default)]
31    drawdown_pct_sum: Decimal,
32    /// Longest consecutive run of updates spent below peak.
33    #[serde(default)]
34    max_drawdown_streak: usize,
35    /// Current consecutive run of updates where equity increased from the prior update.
36    #[serde(default)]
37    gain_streak: usize,
38    /// Number of times a new equity peak has been set.
39    #[serde(default)]
40    peak_count: usize,
41    /// Previous equity value (for computing per-update changes).
42    #[serde(default)]
43    prev_equity: Decimal,
44    /// Welford running mean of per-update equity changes.
45    #[serde(default)]
46    equity_change_mean: f64,
47    /// Welford running M2 (sum of squared deviations) for sample variance.
48    #[serde(default)]
49    equity_change_m2: f64,
50    /// Count of equity changes recorded (= update_count after first update).
51    #[serde(default)]
52    equity_change_count: usize,
53    /// Most negative single-step equity change seen (0.0 until first loss).
54    #[serde(default)]
55    min_equity_delta: f64,
56    /// Longest run of consecutive updates where equity increased.
57    #[serde(default)]
58    max_gain_streak: usize,
59    /// Sum of all positive per-update equity changes.
60    #[serde(default)]
61    total_gain_sum: f64,
62    /// Sum of the absolute values of all negative per-update equity changes.
63    #[serde(default)]
64    total_loss_sum: f64,
65    /// Number of completed recoveries (drawdown resolved by hitting a new peak).
66    #[serde(default)]
67    completed_recoveries: usize,
68    /// Sum of `updates_since_peak` values at the moment each recovery completed.
69    #[serde(default)]
70    total_recovery_updates: usize,
71    /// Sum of drawdown percentages at the start of each recovery (for averaging).
72    #[serde(default)]
73    recovery_drawdown_pct_sum: Decimal,
74    /// Largest single-step equity gain as a percentage of prior equity.
75    #[serde(default)]
76    max_gain_delta_pct: f64,
77    /// Number of distinct drawdown episodes (each time equity drops below peak after being at/above it).
78    #[serde(default)]
79    drawdown_episodes: usize,
80    /// Current consecutive run of updates where equity decreased from the prior update.
81    #[serde(default)]
82    loss_streak_current: usize,
83    /// Initial equity (set at construction, unchanged by reset unless re-constructed).
84    initial_equity: Decimal,
85    /// Current consecutive run of updates where equity was unchanged.
86    #[serde(default)]
87    flat_streak: usize,
88}
89
90impl DrawdownTracker {
91    /// Creates a new `DrawdownTracker` with the given initial (and peak) equity.
92    pub fn new(initial_equity: Decimal) -> Self {
93        Self {
94            peak_equity: initial_equity,
95            current_equity: initial_equity,
96            worst_drawdown_pct: Decimal::ZERO,
97            updates_since_peak: 0,
98            update_count: 0,
99            drawdown_update_count: 0,
100            drawdown_pct_sum: Decimal::ZERO,
101            max_drawdown_streak: 0,
102            gain_streak: 0,
103            peak_count: 0,
104            prev_equity: initial_equity,
105            equity_change_mean: 0.0,
106            equity_change_m2: 0.0,
107            equity_change_count: 0,
108            min_equity_delta: 0.0,
109            max_gain_streak: 0,
110            total_gain_sum: 0.0,
111            total_loss_sum: 0.0,
112            completed_recoveries: 0,
113            total_recovery_updates: 0,
114            recovery_drawdown_pct_sum: Decimal::ZERO,
115            max_gain_delta_pct: 0.0,
116            drawdown_episodes: 0,
117            loss_streak_current: 0,
118            initial_equity,
119            flat_streak: 0,
120        }
121    }
122
123    /// Updates the tracker with the latest equity value, updating the peak if higher.
124    pub fn update(&mut self, equity: Decimal) {
125        // Welford online variance update for equity changes
126        if self.update_count > 0 {
127            if let (Some(prev), Some(curr)) = (
128                self.prev_equity.to_f64(),
129                equity.to_f64(),
130            ) {
131                let delta = curr - prev;
132                self.equity_change_count += 1;
133                let n = self.equity_change_count as f64;
134                let old_mean = self.equity_change_mean;
135                self.equity_change_mean += (delta - old_mean) / n;
136                self.equity_change_m2 += (delta - old_mean) * (delta - self.equity_change_mean);
137                if delta < self.min_equity_delta {
138                    self.min_equity_delta = delta;
139                }
140                if delta > 0.0 {
141                    self.total_gain_sum += delta;
142                    if prev > 0.0 {
143                        let pct = delta / prev * 100.0;
144                        if pct > self.max_gain_delta_pct {
145                            self.max_gain_delta_pct = pct;
146                        }
147                    }
148                } else if delta < 0.0 {
149                    self.total_loss_sum += -delta;
150                }
151            }
152        }
153        self.prev_equity = equity;
154
155        self.update_count += 1;
156        if equity > self.current_equity {
157            self.gain_streak += 1;
158            if self.gain_streak > self.max_gain_streak {
159                self.max_gain_streak = self.gain_streak;
160            }
161            self.loss_streak_current = 0;
162            self.flat_streak = 0;
163        } else if equity < self.current_equity {
164            self.gain_streak = 0;
165            self.loss_streak_current += 1;
166            self.flat_streak = 0;
167        } else {
168            self.gain_streak = 0;
169            self.loss_streak_current = 0;
170            self.flat_streak += 1;
171        }
172        if equity > self.peak_equity {
173            if self.updates_since_peak > 0 {
174                self.total_recovery_updates += self.updates_since_peak;
175                self.recovery_drawdown_pct_sum += self.current_drawdown_pct();
176                self.completed_recoveries += 1;
177            }
178            self.peak_equity = equity;
179            self.updates_since_peak = 0;
180            self.peak_count += 1;
181        } else {
182            if equity < self.peak_equity && self.updates_since_peak == 0 {
183                self.drawdown_episodes += 1;
184            }
185            self.updates_since_peak += 1;
186            self.drawdown_update_count += 1;
187        }
188        self.current_equity = equity;
189        let dd = self.current_drawdown_pct();
190        if dd > self.worst_drawdown_pct {
191            self.worst_drawdown_pct = dd;
192        }
193        if !dd.is_zero() {
194            self.drawdown_pct_sum += dd;
195        }
196        if self.updates_since_peak > self.max_drawdown_streak {
197            self.max_drawdown_streak = self.updates_since_peak;
198        }
199    }
200
201    /// Returns the number of `update()` calls since the last new equity peak.
202    ///
203    /// A value of 0 means the last update set a new peak. Higher values indicate
204    /// how long the portfolio has been in drawdown (in update units).
205    pub fn drawdown_duration(&self) -> usize {
206        self.updates_since_peak
207    }
208
209    /// Returns current drawdown as a percentage: `(peak - current) / peak * 100`.
210    ///
211    /// Returns `0` if `peak_equity` is zero.
212    pub fn current_drawdown_pct(&self) -> Decimal {
213        if self.peak_equity == Decimal::ZERO {
214            return Decimal::ZERO;
215        }
216        (self.peak_equity - self.current_equity) / self.peak_equity * Decimal::ONE_HUNDRED
217    }
218
219    /// Returns the highest equity seen since construction.
220    pub fn peak(&self) -> Decimal {
221        self.peak_equity
222    }
223
224    /// Returns the current equity value.
225    pub fn current_equity(&self) -> Decimal {
226        self.current_equity
227    }
228
229    /// Returns `true` if the current drawdown percentage does not exceed `max_dd_pct`.
230    pub fn is_below_threshold(&self, max_dd_pct: Decimal) -> bool {
231        self.current_drawdown_pct() <= max_dd_pct
232    }
233
234    /// Resets the peak to the current equity value.
235    ///
236    /// Useful for daily or session-boundary resets where you want drawdown measured
237    /// from the start of the new session rather than the all-time high.
238    pub fn reset_peak(&mut self) {
239        self.peak_equity = self.current_equity;
240        self.updates_since_peak = 0;
241    }
242
243    /// Returns the worst (highest) drawdown percentage seen since construction or last reset.
244    pub fn worst_drawdown_pct(&self) -> Decimal {
245        self.worst_drawdown_pct
246    }
247
248    /// Returns the total number of equity updates since construction or last reset.
249    pub fn update_count(&self) -> usize {
250        self.update_count
251    }
252
253    /// Returns the fraction of updates where equity was at or above peak (not in drawdown).
254    ///
255    /// `win_rate = (update_count - drawdown_update_count) / update_count`
256    ///
257    /// Returns `None` if no updates have been processed.
258    pub fn win_rate(&self) -> Option<Decimal> {
259        if self.update_count == 0 {
260            return None;
261        }
262        let at_peak = self.update_count - self.drawdown_update_count;
263        #[allow(clippy::cast_possible_truncation)]
264        Some(Decimal::from(at_peak as u64) / Decimal::from(self.update_count as u64))
265    }
266
267    /// Returns how far below peak current equity is, as a percentage.
268    ///
269    /// `underwater_pct = (peak - current) / peak × 100`
270    ///
271    /// Returns `Decimal::ZERO` when at or above peak.
272    pub fn underwater_pct(&self) -> Decimal {
273        if self.peak_equity == Decimal::ZERO {
274            return Decimal::ZERO;
275        }
276        let diff = self.peak_equity - self.current_equity;
277        if diff <= Decimal::ZERO {
278            return Decimal::ZERO;
279        }
280        diff / self.peak_equity * Decimal::ONE_HUNDRED
281    }
282
283    /// Fully resets the tracker as if it were freshly constructed with `initial` equity.
284    pub fn reset(&mut self, initial: Decimal) {
285        self.peak_equity = initial;
286        self.current_equity = initial;
287        self.drawdown_pct_sum = Decimal::ZERO;
288        self.max_drawdown_streak = 0;
289        self.worst_drawdown_pct = Decimal::ZERO;
290        self.updates_since_peak = 0;
291        self.update_count = 0;
292        self.drawdown_update_count = 0;
293        self.gain_streak = 0;
294        self.peak_count = 0;
295        self.prev_equity = initial;
296        self.equity_change_mean = 0.0;
297        self.equity_change_m2 = 0.0;
298        self.equity_change_count = 0;
299        self.min_equity_delta = 0.0;
300        self.max_gain_streak = 0;
301        self.total_gain_sum = 0.0;
302        self.total_loss_sum = 0.0;
303        self.completed_recoveries = 0;
304        self.total_recovery_updates = 0;
305        self.recovery_drawdown_pct_sum = Decimal::ZERO;
306        self.max_gain_delta_pct = 0.0;
307        self.drawdown_episodes = 0;
308        self.loss_streak_current = 0;
309        self.flat_streak = 0;
310    }
311
312    /// Returns the sample standard deviation of per-update equity changes.
313    ///
314    /// Uses Welford's online algorithm internally. Returns `None` until at least
315    /// two updates have been processed (can't compute variance from one sample).
316    pub fn volatility(&self) -> Option<f64> {
317        if self.equity_change_count < 2 {
318            return None;
319        }
320        let variance = self.equity_change_m2 / (self.equity_change_count - 1) as f64;
321        Some(variance.sqrt())
322    }
323
324    /// Returns the recovery factor: `net_profit_pct / worst_drawdown_pct`.
325    ///
326    /// A higher value indicates better risk-adjusted performance.
327    /// Returns `None` when `worst_drawdown_pct` is zero (no drawdown has occurred).
328    pub fn recovery_factor(&self, net_profit_pct: Decimal) -> Option<Decimal> {
329        if self.worst_drawdown_pct.is_zero() {
330            return None;
331        }
332        Some(net_profit_pct / self.worst_drawdown_pct)
333    }
334
335    /// Returns the Calmar ratio: `annualized_return / worst_drawdown_pct`.
336    ///
337    /// Higher values indicate better risk-adjusted performance. Returns `None` when
338    /// `worst_drawdown_pct` is zero (no drawdown has occurred).
339    pub fn calmar_ratio(&self, annualized_return: Decimal) -> Option<Decimal> {
340        if self.worst_drawdown_pct.is_zero() {
341            return None;
342        }
343        Some(annualized_return / self.worst_drawdown_pct)
344    }
345
346    /// Returns `true` if the current equity is strictly below the peak (i.e. in drawdown).
347    pub fn in_drawdown(&self) -> bool {
348        self.current_equity < self.peak_equity
349    }
350
351    /// Applies a sequence of equity values in order, as if each were an individual `update` call.
352    ///
353    /// Useful for batch processing historical equity curves without a manual loop.
354    pub fn update_with_returns(&mut self, equities: &[Decimal]) {
355        for &eq in equities {
356            self.update(eq);
357        }
358    }
359
360    /// Returns the number of consecutive updates where equity was below the peak.
361    ///
362    /// Equivalent to [`DrawdownTracker::drawdown_duration`]. Provided as a semantic
363    /// alias for call sites that prefer "count" over "duration".
364    pub fn drawdown_count(&self) -> usize {
365        self.updates_since_peak
366    }
367
368    /// Returns the Sharpe ratio: `annualized_return / annualized_vol`.
369    ///
370    /// Returns `None` when `annualized_vol` is zero to avoid division by zero.
371    pub fn sharpe_ratio(
372        &self,
373        annualized_return: Decimal,
374        annualized_vol: Decimal,
375    ) -> Option<Decimal> {
376        if annualized_vol.is_zero() {
377            return None;
378        }
379        Some(annualized_return / annualized_vol)
380    }
381
382    /// Returns the percentage gain required from the current equity to recover to the peak.
383    ///
384    /// Formula: `(peak / current - 1) * 100`. Returns `Decimal::ZERO` when already at peak
385    /// or when current equity is zero (to avoid division by zero).
386    pub fn recovery_to_peak_pct(&self) -> Decimal {
387        if self.current_equity.is_zero() || self.current_equity >= self.peak_equity {
388            return Decimal::ZERO;
389        }
390        (self.peak_equity / self.current_equity - Decimal::ONE) * Decimal::ONE_HUNDRED
391    }
392
393    /// Fraction of equity updates spent below peak: `drawdown_update_count / update_count`.
394    ///
395    /// Returns `Decimal::ZERO` when no updates have been processed.
396    #[allow(clippy::cast_possible_truncation)]
397    pub fn time_underwater_pct(&self) -> Decimal {
398        if self.update_count == 0 {
399            return Decimal::ZERO;
400        }
401        Decimal::from(self.drawdown_update_count as u64)
402            / Decimal::from(self.update_count as u64)
403    }
404
405    /// Average drawdown percentage across all updates that had a non-zero drawdown.
406    ///
407    /// Returns `None` when no drawdown updates have been recorded.
408    #[allow(clippy::cast_possible_truncation)]
409    pub fn avg_drawdown_pct(&self) -> Option<Decimal> {
410        if self.drawdown_update_count == 0 {
411            return None;
412        }
413        Some(self.drawdown_pct_sum / Decimal::from(self.drawdown_update_count as u64))
414    }
415
416    /// Longest consecutive run of updates where equity was below peak.
417    pub fn max_loss_streak(&self) -> usize {
418        self.max_drawdown_streak.max(self.updates_since_peak)
419    }
420
421    /// Returns the current consecutive run of updates where equity increased from the prior update.
422    ///
423    /// Resets to zero on any non-increasing update. Useful for detecting sustained rallies.
424    pub fn consecutive_gain_updates(&self) -> usize {
425        self.gain_streak
426    }
427
428    /// Returns `current_equity / peak_equity`, useful for position sizing formulas.
429    ///
430    /// Returns `Decimal::ONE` when peak is zero (no drawdown state yet). A value below 1
431    /// indicates the portfolio is in drawdown; exactly 1 means at peak.
432    pub fn equity_ratio(&self) -> Decimal {
433        if self.peak_equity.is_zero() {
434            return Decimal::ONE;
435        }
436        self.current_equity / self.peak_equity
437    }
438
439    /// Returns how many times a new equity peak has been set since construction or last reset.
440    pub fn new_peak_count(&self) -> usize {
441        self.peak_count
442    }
443
444    /// Returns the "pain index": mean absolute drawdown across all updates.
445    ///
446    /// `pain_index = drawdown_pct_sum / update_count`
447    ///
448    /// Represents the average percentage loss a holder experienced over the equity curve.
449    /// Returns `Decimal::ZERO` when no updates have been processed.
450    #[allow(clippy::cast_possible_truncation)]
451    pub fn pain_index(&self) -> Decimal {
452        if self.update_count == 0 {
453            return Decimal::ZERO;
454        }
455        self.drawdown_pct_sum / Decimal::from(self.update_count as u64)
456    }
457
458    /// Returns `true` if `equity` is strictly greater than the current peak (new high-water mark).
459    ///
460    /// Useful for triggering high-water-mark-based fee calculations or performance resets.
461    /// Note: this does NOT update the tracker — call `update(equity)` to advance the peak.
462    pub fn above_high_water_mark(&self, equity: Decimal) -> bool {
463        equity > self.peak_equity
464    }
465
466    /// Returns the largest single-step equity drop seen across all updates.
467    ///
468    /// Returns the magnitude (positive number) of the worst per-update loss.
469    /// Returns `None` if no loss has occurred or fewer than two updates have been processed.
470    pub fn max_single_loss(&self) -> Option<f64> {
471        if self.equity_change_count == 0 || self.min_equity_delta >= 0.0 {
472            return None;
473        }
474        Some(-self.min_equity_delta)
475    }
476
477    /// Returns the fraction of equity updates that decreased equity (loss rate).
478    ///
479    /// A value of `0.0` means equity never decreased; `1.0` means it always decreased.
480    /// Returns `None` if no updates have been processed.
481    ///
482    /// Note: uses the drawdown update count as a proxy for loss updates — specifically
483    /// the number of updates where equity was below peak, not strictly below the prior update.
484    pub fn loss_rate(&self) -> Option<f64> {
485        if self.update_count == 0 {
486            return None;
487        }
488        Some(self.drawdown_update_count as f64 / self.update_count as f64)
489    }
490
491    /// Returns the current number of consecutive updates where equity decreased.
492    ///
493    /// Resets to zero on any update where equity increases or stays the same.
494    /// A current losing streak indicator complementing [`DrawdownTracker::consecutive_gain_updates`].
495    pub fn consecutive_loss_updates(&self) -> usize {
496        // gain_streak tracks consecutive gains; when gain_streak is 0 and we're in drawdown
497        // that approximates a loss streak. We return updates_since_peak as the losing streak
498        // (time underwater is the closest proxy without a dedicated field).
499        if self.gain_streak > 0 {
500            0
501        } else {
502            self.updates_since_peak
503        }
504    }
505
506    /// Returns the running mean of per-update equity changes.
507    ///
508    /// Computed via Welford's online algorithm. Returns `None` until at least one
509    /// equity change has been recorded (requires 2+ updates).
510    pub fn equity_change_mean(&self) -> Option<f64> {
511        if self.equity_change_count == 0 {
512            return None;
513        }
514        Some(self.equity_change_mean)
515    }
516
517    /// Returns the hypothetical drawdown percentage if equity dropped by `shock_pct` from current.
518    ///
519    /// `stress_drawdown = current_drawdown + shock_pct × (1 - current_drawdown/100)`
520    ///
521    /// This estimates the total drawdown from peak if the current equity fell an additional
522    /// `shock_pct` percent. Returns the result as a percentage (0–100+).
523    pub fn stress_test(&self, shock_pct: Decimal) -> Decimal {
524        if self.peak_equity.is_zero() {
525            return shock_pct;
526        }
527        let stressed_equity = self.current_equity
528            * (Decimal::ONE_HUNDRED - shock_pct)
529            / Decimal::ONE_HUNDRED;
530        if stressed_equity >= self.peak_equity {
531            return Decimal::ZERO;
532        }
533        (self.peak_equity - stressed_equity) / self.peak_equity * Decimal::ONE_HUNDRED
534    }
535
536    /// Returns the longest consecutive run of equity increases seen since construction or reset.
537    pub fn max_gain_streak(&self) -> usize {
538        self.max_gain_streak
539    }
540
541    /// Returns the cumulative sum of all positive per-update equity changes.
542    ///
543    /// Returns `0.0` if no gains have been recorded.
544    pub fn total_gain_sum(&self) -> f64 {
545        self.total_gain_sum
546    }
547
548    /// Returns the cumulative sum of absolute values of all negative per-update equity changes.
549    ///
550    /// Returns `0.0` if no losses have been recorded.
551    pub fn total_loss_sum(&self) -> f64 {
552        self.total_loss_sum
553    }
554
555    /// Returns `total_gain_sum / total_loss_sum`. Returns `None` if no losses recorded.
556    pub fn gain_to_loss_ratio(&self) -> Option<f64> {
557        if self.total_loss_sum == 0.0 { None } else { Some(self.total_gain_sum / self.total_loss_sum) }
558    }
559
560    /// Trading expectancy: `win_rate × avg_gain − loss_rate × avg_loss`.
561    ///
562    /// Returns `None` if fewer than 2 equity changes have been recorded.
563    pub fn expectancy(&self) -> Option<f64> {
564        let n = self.equity_change_count;
565        if n < 2 { return None; }
566        let wr = self.win_rate()?.to_f64()?;
567        let loss_rate = 1.0 - wr;
568        let gain_count = (wr * n as f64).round() as usize;
569        let loss_count = n.saturating_sub(gain_count);
570        let avg_gain = if gain_count > 0 { self.total_gain_sum / gain_count as f64 } else { 0.0 };
571        let avg_loss = if loss_count > 0 { self.total_loss_sum / loss_count as f64 } else { 0.0 };
572        Some(wr * avg_gain - loss_rate * avg_loss)
573    }
574
575    /// Average number of updates required to recover from a drawdown to a new peak.
576    ///
577    /// Returns `None` if no drawdown has ever been fully recovered.
578    pub fn recovery_speed(&self) -> Option<f64> {
579        if self.completed_recoveries == 0 { return None; }
580        Some(self.total_recovery_updates as f64 / self.completed_recoveries as f64)
581    }
582
583    /// Number of times a new equity peak has been set.
584    ///
585    /// This equals the number of `update()` calls where equity exceeded the prior peak.
586    pub fn peak_hit_count(&self) -> usize {
587        self.peak_count
588    }
589
590    /// Average drawdown percentage at the moment each recovery began.
591    ///
592    /// Returns `None` if no drawdown has ever been fully recovered.
593    pub fn avg_recovery_drawdown_pct(&self) -> Option<Decimal> {
594        if self.completed_recoveries == 0 { return None; }
595        #[allow(clippy::cast_possible_truncation)]
596        Some(self.recovery_drawdown_pct_sum / Decimal::from(self.completed_recoveries as u32))
597    }
598
599    /// Largest single-step equity gain expressed as a percentage of the prior equity.
600    ///
601    /// Returns `0.0` if no gain has been recorded yet.
602    pub fn max_gain_pct(&self) -> f64 {
603        self.max_gain_delta_pct
604    }
605
606    /// Average number of updates spent in each drawdown episode.
607    ///
608    /// Returns `None` if no drawdown episode has been entered yet.
609    pub fn avg_drawdown_duration(&self) -> Option<f64> {
610        if self.drawdown_episodes == 0 { return None; }
611        Some(self.drawdown_update_count as f64 / self.drawdown_episodes as f64)
612    }
613
614    /// The peak equity level the current equity must reach to exit drawdown.
615    ///
616    /// Equals the all-time peak. If equity is already at peak, this is the current equity.
617    pub fn breakeven_equity(&self) -> Decimal {
618        self.peak_equity
619    }
620
621    /// Current consecutive count of updates where equity decreased.
622    ///
623    /// Resets to 0 as soon as equity increases or stays flat.
624    pub fn loss_streak(&self) -> usize {
625        self.loss_streak_current
626    }
627
628    /// Net return as a percentage: `(current_equity - initial_equity) / initial_equity * 100`.
629    ///
630    /// Returns `None` if `initial_equity` is zero.
631    pub fn net_return_pct(&self) -> Option<f64> {
632        let init = self.initial_equity.to_f64()?;
633        if init == 0.0 { return None; }
634        let curr = self.current_equity.to_f64()?;
635        Some((curr - init) / init * 100.0)
636    }
637
638    /// Current count of consecutive updates where equity did not change.
639    pub fn consecutive_flat_count(&self) -> usize {
640        self.flat_streak
641    }
642
643    /// Total number of `update()` calls processed since construction or last `reset()`.
644    pub fn total_updates(&self) -> usize {
645        self.update_count
646    }
647
648    /// Percentage of all updates spent below peak equity (in drawdown).
649    ///
650    /// Returns `0.0` if no updates have been processed.
651    pub fn pct_time_in_drawdown(&self) -> f64 {
652        if self.update_count == 0 { return 0.0; }
653        self.drawdown_update_count as f64 / self.update_count as f64 * 100.0
654    }
655
656    /// Compound Annual Growth Rate (CAGR) of equity.
657    ///
658    /// `CAGR = (current / initial) ^ (periods_per_year / update_count) - 1`.
659    /// Returns `None` if `initial_equity` is zero or non-positive, or fewer than 2 updates.
660    pub fn equity_cagr(&self, periods_per_year: usize) -> Option<f64> {
661        if self.update_count < 2 || periods_per_year == 0 { return None; }
662        let init = self.initial_equity.to_f64()?;
663        if init <= 0.0 { return None; }
664        let curr = self.current_equity.to_f64()?;
665        if curr <= 0.0 { return None; }
666        let years = self.update_count as f64 / periods_per_year as f64;
667        Some((curr / init).powf(1.0 / years) - 1.0)
668    }
669
670    /// Returns `true` when equity is below its peak but gained on the last update.
671    pub fn is_recovering(&self) -> bool {
672        self.in_drawdown() && self.gain_streak > 0
673    }
674
675    /// Current drawdown as a fraction of the worst recorded drawdown.
676    ///
677    /// Returns `Decimal::ZERO` if no drawdown has been recorded yet.
678    pub fn drawdown_ratio(&self) -> Decimal {
679        if self.worst_drawdown_pct.is_zero() { return Decimal::ZERO; }
680        self.current_drawdown_pct() / self.worst_drawdown_pct
681    }
682
683    /// Current equity as a multiple of initial equity (e.g., `1.5` = 50% gain).
684    pub fn equity_multiple(&self) -> Decimal {
685        if self.initial_equity.is_zero() { return Decimal::ONE; }
686        self.current_equity / self.initial_equity
687    }
688
689    /// Average per-update equity gain across all positive updates.
690    ///
691    /// Uses `win_rate` and `update_count` to estimate the number of positive updates.
692    /// Returns `None` if there have been no positive updates recorded.
693    pub fn avg_gain_pct(&self) -> Option<f64> {
694        let wr: f64 = self.win_rate()?.to_string().parse().ok()?;
695        let gain_count = (wr / 100.0 * self.update_count as f64).round() as usize;
696        if gain_count == 0 { return None; }
697        Some(self.total_gain_sum / gain_count as f64)
698    }
699
700    /// Returns `true` if the current equity equals the peak (no drawdown).
701    pub fn is_at_peak(&self) -> bool {
702        self.current_equity >= self.peak_equity
703    }
704
705    /// Returns `true` if the current equity is below the initial equity at construction.
706    pub fn below_initial_equity(&self) -> bool {
707        self.current_equity < self.initial_equity
708    }
709
710    /// Net return divided by max drawdown percentage (simplified Calmar-like ratio).
711    ///
712    /// Returns `None` if max drawdown is zero or there are fewer than 2 updates.
713    pub fn return_drawdown_ratio(&self) -> Option<f64> {
714        if self.worst_drawdown_pct.is_zero() { return None; }
715        let net_ret = self.net_return_pct()?;
716        let dd: f64 = self.worst_drawdown_pct.to_string().parse().ok()?;
717        if dd == 0.0 { return None; }
718        Some(net_ret / dd)
719    }
720
721    /// Percentage of total updates where equity was unchanged (flat).
722    ///
723    /// Returns `0.0` if no updates have been recorded.
724    pub fn consecutive_flat_pct(&self) -> f64 {
725        if self.update_count == 0 { return 0.0; }
726        self.flat_streak as f64 / self.update_count as f64 * 100.0
727    }
728
729    /// Current consecutive streak length: positive = gains, negative = losses, 0 = flat.
730    pub fn current_streak(&self) -> i64 {
731        if self.gain_streak > 0 {
732            self.gain_streak as i64
733        } else if self.loss_streak_current > 0 {
734            -(self.loss_streak_current as i64)
735        } else {
736            0
737        }
738    }
739
740    /// The single largest equity loss as a percentage of the equity at the time of the loss.
741    ///
742    /// Returns `None` if no loss has been recorded (min_equity_delta >= 0).
743    pub fn max_loss_pct_single(&self) -> Option<f64> {
744        if self.min_equity_delta >= 0.0 { return None; }
745        let peak: f64 = self.peak_equity.to_string().parse().ok()?;
746        if peak <= 0.0 { return None; }
747        Some((self.min_equity_delta / peak).abs() * 100.0)
748    }
749
750    /// Win rate divided by loss rate (win probability / loss probability).
751    ///
752    /// Returns `None` if either rate is unavailable or loss rate is zero.
753    pub fn win_loss_ratio(&self) -> Option<f64> {
754        let wr = self.win_rate()?.to_string().parse::<f64>().ok()?;
755        let lr = self.loss_rate()?;
756        if lr == 0.0 { return None; }
757        Some(wr / (lr * 100.0))
758    }
759
760    /// Max single gain percentage divided by worst drawdown percentage (reward/risk ratio).
761    ///
762    /// Returns `None` if no drawdown or no gain has been recorded.
763    pub fn best_drawdown_recovery(&self) -> Option<f64> {
764        if self.worst_drawdown_pct.is_zero() { return None; }
765        let max_gain = self.max_gain_pct();
766        if max_gain <= 0.0 { return None; }
767        let dd: f64 = self.worst_drawdown_pct.to_string().parse().ok()?;
768        if dd == 0.0 { return None; }
769        Some(max_gain / dd)
770    }
771
772    /// Total number of completed drawdown recovery events.
773    pub fn recovery_count(&self) -> usize {
774        self.completed_recoveries
775    }
776
777    /// Ratio of average gain to average loss per update.
778    ///
779    /// Returns `None` if either average is unavailable or average loss is zero.
780    pub fn avg_gain_loss_ratio(&self) -> Option<f64> {
781        let avg_gain = self.avg_gain_pct()?;
782        let lr = self.loss_rate()?;
783        let loss_count = (lr * self.update_count as f64).round() as usize;
784        if loss_count == 0 { return None; }
785        let avg_loss = self.total_loss_sum / loss_count as f64;
786        if avg_loss == 0.0 { return None; }
787        Some(avg_gain / avg_loss)
788    }
789
790    /// Estimated number of updates to recover from the current drawdown.
791    ///
792    /// Based on average gain size and current distance from peak.
793    /// Returns `None` if not in drawdown, no gain history, or average gain is zero.
794    pub fn time_to_recover_est(&self) -> Option<usize> {
795        if !self.in_drawdown() { return None; }
796        let avg_gain = self.avg_gain_pct()?;
797        if avg_gain <= 0.0 { return None; }
798        let distance: f64 = self.current_drawdown_pct().to_string().parse().ok()?;
799        Some((distance / avg_gain).ceil() as usize)
800    }
801
802    /// Current distance of equity below the peak in absolute terms.
803    pub fn current_drawdown_absolute(&self) -> Decimal {
804        if self.current_equity >= self.peak_equity {
805            Decimal::ZERO
806        } else {
807            self.peak_equity - self.current_equity
808        }
809    }
810
811    /// Median of a slice of drawdown percentages.
812    ///
813    /// The input need not be sorted. Returns `None` if the slice is empty.
814    pub fn median_drawdown_pct(drawdowns: &[Decimal]) -> Option<Decimal> {
815        if drawdowns.is_empty() { return None; }
816        let mut sorted = drawdowns.to_vec();
817        sorted.sort();
818        let mid = sorted.len() / 2;
819        if sorted.len() % 2 == 1 {
820            Some(sorted[mid])
821        } else {
822            Some((sorted[mid - 1] + sorted[mid]) / Decimal::TWO)
823        }
824    }
825
826    /// Sortino ratio from a slice of period returns.
827    ///
828    /// `sortino = (mean_return - target) / downside_deviation`
829    ///
830    /// where downside deviation is the standard deviation of returns *below* `target`.
831    /// Returns `None` if `returns` is empty or downside deviation is zero.
832    pub fn sortino_ratio(returns: &[Decimal], target: Decimal) -> Option<f64> {
833        if returns.is_empty() {
834            return None;
835        }
836        let n = returns.len() as f64;
837        let target_f = target.to_f64()?;
838        let mean: f64 = returns.iter().filter_map(|r| r.to_f64()).sum::<f64>() / n;
839        let downside_sq_sum: f64 = returns
840            .iter()
841            .filter_map(|r| r.to_f64())
842            .map(|r| {
843                let diff = r - target_f;
844                if diff < 0.0 { diff * diff } else { 0.0 }
845            })
846            .sum();
847        if downside_sq_sum == 0.0 {
848            return None;
849        }
850        let downside_dev = (downside_sq_sum / n).sqrt();
851        if downside_dev == 0.0 {
852            return None;
853        }
854        Some((mean - target_f) / downside_dev)
855    }
856
857    /// Annualised volatility from a slice of period returns.
858    ///
859    /// `volatility = std_dev(returns) * sqrt(periods_per_year)`
860    ///
861    /// Returns `None` if `returns` has fewer than 2 elements.
862    pub fn returns_volatility(returns: &[Decimal], periods_per_year: u32) -> Option<f64> {
863        if returns.len() < 2 {
864            return None;
865        }
866        let n = returns.len() as f64;
867        let mean: f64 = returns.iter()
868            .filter_map(|r| r.to_f64())
869            .sum::<f64>() / n;
870        let variance: f64 = returns.iter()
871            .filter_map(|r| r.to_f64())
872            .map(|r| (r - mean).powi(2))
873            .sum::<f64>() / (n - 1.0);
874        let vol = variance.sqrt() * (periods_per_year as f64).sqrt();
875        Some(vol)
876    }
877
878    /// Omega ratio: sum of returns above `threshold` / abs(sum of returns below `threshold`).
879    ///
880    /// Values > 1 indicate more upside than downside relative to the threshold.
881    /// Returns `None` if `returns` is empty or total downside is zero.
882    pub fn omega_ratio(returns: &[Decimal], threshold: Decimal) -> Option<f64> {
883        if returns.is_empty() {
884            return None;
885        }
886        let threshold_f = threshold.to_f64()?;
887        let upside: f64 = returns
888            .iter()
889            .filter_map(|r| r.to_f64())
890            .map(|r| (r - threshold_f).max(0.0))
891            .sum();
892        let downside: f64 = returns
893            .iter()
894            .filter_map(|r| r.to_f64())
895            .map(|r| (threshold_f - r).max(0.0))
896            .sum();
897        if downside == 0.0 {
898            return None;
899        }
900        Some(upside / downside)
901    }
902
903    /// Information ratio: `(mean(returns) - mean(benchmark)) / std_dev(returns - benchmark)`.
904    ///
905    /// Measures risk-adjusted excess return over a benchmark. Returns `None` if fewer than 2
906    /// matched return pairs exist or tracking error is zero.
907    pub fn information_ratio(returns: &[Decimal], benchmark: &[Decimal]) -> Option<f64> {
908        let n = returns.len().min(benchmark.len());
909        if n < 2 {
910            return None;
911        }
912        let excess: Vec<f64> = returns[..n]
913            .iter()
914            .zip(benchmark[..n].iter())
915            .filter_map(|(r, b)| Some(r.to_f64()? - b.to_f64()?))
916            .collect();
917        if excess.len() < 2 {
918            return None;
919        }
920        let mean_excess = excess.iter().sum::<f64>() / excess.len() as f64;
921        let tracking_variance = excess.iter().map(|e| (e - mean_excess).powi(2)).sum::<f64>()
922            / (excess.len() as f64 - 1.0);
923        let tracking_error = tracking_variance.sqrt();
924        if tracking_error == 0.0 {
925            return None;
926        }
927        Some(mean_excess / tracking_error)
928    }
929
930    /// Annualized volatility of equity changes: `std_dev_of_changes * sqrt(periods_per_year)`.
931    ///
932    /// Returns `None` if fewer than 2 updates have been recorded.
933    pub fn annualized_volatility(&self, periods_per_year: u32) -> Option<f64> {
934        if self.equity_change_count < 2 { return None; }
935        let n = self.equity_change_count as f64;
936        let variance = self.equity_change_m2 / (n - 1.0);
937        Some(variance.sqrt() * (periods_per_year as f64).sqrt())
938    }
939
940    /// Pain ratio: `annualized_return_pct / pain_index`.
941    ///
942    /// A higher ratio indicates better risk-adjusted performance relative to
943    /// sustained drawdown. Returns `None` if the pain index is zero (no drawdowns).
944    pub fn pain_ratio(&self, annualized_return_pct: Decimal) -> Option<Decimal> {
945        let pi = self.pain_index();
946        if pi.is_zero() { return None; }
947        Some(annualized_return_pct / pi)
948    }
949
950    /// Fraction of all updates where equity was at or above the peak (above water).
951    ///
952    /// Complement of [`time_underwater_pct`](Self::time_underwater_pct).
953    /// Returns `Decimal::ONE` when no updates have been processed.
954    pub fn time_above_watermark_pct(&self) -> Decimal {
955        if self.update_count == 0 {
956            return Decimal::ONE;
957        }
958        Decimal::ONE - self.time_underwater_pct()
959    }
960
961    /// Sample standard deviation of per-update equity changes.
962    ///
963    /// Uses the Welford running variance accumulator. Returns `None` when
964    /// fewer than 2 equity changes have been recorded.
965    pub fn equity_change_std_dev(&self) -> Option<f64> {
966        if self.equity_change_count < 2 { return None; }
967        let variance = self.equity_change_m2 / (self.equity_change_count - 1) as f64;
968        Some(variance.sqrt())
969    }
970
971    /// Ratio of the longest gain streak to total updates.
972    ///
973    /// Higher values indicate equity spent a larger fraction of updates trending upward.
974    /// Returns `None` when no updates have been processed.
975    pub fn gain_streak_ratio(&self) -> Option<f64> {
976        if self.update_count == 0 { return None; }
977        Some(self.max_gain_streak as f64 / self.update_count as f64)
978    }
979}
980
981impl std::fmt::Display for DrawdownTracker {
982    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
983        write!(
984            f,
985            "equity={} peak={} drawdown={:.2}%",
986            self.current_equity,
987            self.peak_equity,
988            self.current_drawdown_pct()
989        )
990    }
991}
992
993/// A triggered risk rule violation.
994#[derive(Debug, Clone, PartialEq)]
995pub struct RiskBreach {
996    /// The name of the rule that triggered.
997    pub rule: String,
998    /// Human-readable detail of the violation.
999    pub detail: String,
1000}
1001
1002/// A risk rule that can be checked against current equity and drawdown.
1003pub trait RiskRule: Send {
1004    /// Returns the rule's name.
1005    fn name(&self) -> &str;
1006
1007    /// Returns `Some(RiskBreach)` if the rule is violated, or `None` if compliant.
1008    ///
1009    /// # Arguments
1010    /// * `equity` - current portfolio equity
1011    /// * `drawdown_pct` - current drawdown percentage from peak
1012    fn check(&self, equity: Decimal, drawdown_pct: Decimal) -> Option<RiskBreach>;
1013}
1014
1015/// Triggers a breach when drawdown exceeds a threshold percentage.
1016pub struct MaxDrawdownRule {
1017    /// The maximum allowed drawdown percentage (e.g., `dec!(10)` = 10%).
1018    pub threshold_pct: Decimal,
1019}
1020
1021impl RiskRule for MaxDrawdownRule {
1022    fn name(&self) -> &str {
1023        "max_drawdown"
1024    }
1025
1026    fn check(&self, _equity: Decimal, drawdown_pct: Decimal) -> Option<RiskBreach> {
1027        if drawdown_pct > self.threshold_pct {
1028            Some(RiskBreach {
1029                rule: self.name().to_owned(),
1030                detail: format!("drawdown {drawdown_pct:.2}% > {:.2}%", self.threshold_pct),
1031            })
1032        } else {
1033            None
1034        }
1035    }
1036}
1037
1038/// Triggers a breach when equity falls below a floor.
1039pub struct MinEquityRule {
1040    /// The minimum acceptable equity.
1041    pub floor: Decimal,
1042}
1043
1044impl RiskRule for MinEquityRule {
1045    fn name(&self) -> &str {
1046        "min_equity"
1047    }
1048
1049    fn check(&self, equity: Decimal, _drawdown_pct: Decimal) -> Option<RiskBreach> {
1050        if equity < self.floor {
1051            Some(RiskBreach {
1052                rule: self.name().to_owned(),
1053                detail: format!("equity {equity} < floor {}", self.floor),
1054            })
1055        } else {
1056            None
1057        }
1058    }
1059}
1060
1061/// Evaluates multiple `RiskRule`s on each equity update and returns all breaches.
1062pub struct RiskMonitor {
1063    rules: Vec<Box<dyn RiskRule>>,
1064    tracker: DrawdownTracker,
1065    breach_count: usize,
1066}
1067
1068impl RiskMonitor {
1069    /// Creates a new `RiskMonitor` with no rules and the given initial equity.
1070    pub fn new(initial_equity: Decimal) -> Self {
1071        Self {
1072            rules: Vec::new(),
1073            tracker: DrawdownTracker::new(initial_equity),
1074            breach_count: 0,
1075        }
1076    }
1077
1078    /// Adds a rule to the monitor (builder pattern).
1079    #[must_use]
1080    pub fn add_rule(mut self, rule: impl RiskRule + 'static) -> Self {
1081        self.rules.push(Box::new(rule));
1082        self
1083    }
1084
1085    /// Updates equity and returns all triggered breaches.
1086    pub fn update(&mut self, equity: Decimal) -> Vec<RiskBreach> {
1087        self.tracker.update(equity);
1088        let dd = self.tracker.current_drawdown_pct();
1089        let breaches: Vec<RiskBreach> = self.rules
1090            .iter()
1091            .filter_map(|r| r.check(equity, dd))
1092            .collect();
1093        self.breach_count += breaches.len();
1094        breaches
1095    }
1096
1097    /// Returns the current drawdown percentage without triggering an update.
1098    pub fn drawdown_pct(&self) -> Decimal {
1099        self.tracker.current_drawdown_pct()
1100    }
1101
1102    /// Returns the current equity value without triggering an update.
1103    pub fn current_equity(&self) -> Decimal {
1104        self.tracker.current_equity()
1105    }
1106
1107    /// Returns the peak equity seen so far.
1108    pub fn peak_equity(&self) -> Decimal {
1109        self.tracker.peak()
1110    }
1111
1112    /// Resets the internal drawdown tracker to `initial_equity`.
1113    pub fn reset(&mut self, initial_equity: Decimal) {
1114        self.tracker.reset(initial_equity);
1115        self.breach_count = 0;
1116    }
1117
1118    /// Returns the number of rules registered with this monitor.
1119    pub fn rule_count(&self) -> usize {
1120        self.rules.len()
1121    }
1122
1123    /// Resets the drawdown peak to the current equity.
1124    ///
1125    /// Delegates to [`DrawdownTracker::reset_peak`]. Useful at session boundaries
1126    /// when you want drawdown measured from the current level, not the all-time high.
1127    pub fn reset_peak(&mut self) {
1128        self.tracker.reset_peak();
1129    }
1130
1131    /// Returns `true` if equity is currently below the recorded peak (i.e. in drawdown).
1132    pub fn is_in_drawdown(&self) -> bool {
1133        self.tracker.current_drawdown_pct() > Decimal::ZERO
1134    }
1135
1136    /// Returns the worst (highest) drawdown percentage seen since construction or last reset.
1137    pub fn worst_drawdown_pct(&self) -> Decimal {
1138        self.tracker.worst_drawdown_pct()
1139    }
1140
1141    /// Returns the total number of equity updates processed since construction or last reset.
1142    pub fn equity_history_len(&self) -> usize {
1143        self.tracker.update_count()
1144    }
1145
1146    /// Returns the number of consecutive equity updates since the last peak (drawdown duration).
1147    pub fn drawdown_duration(&self) -> usize {
1148        self.tracker.drawdown_duration()
1149    }
1150
1151    /// Returns the total number of rule breaches triggered since construction or last reset.
1152    pub fn breach_count(&self) -> usize {
1153        self.breach_count
1154    }
1155
1156    /// Returns the maximum drawdown percentage seen since construction or last reset.
1157    ///
1158    /// Alias for [`worst_drawdown_pct`](Self::worst_drawdown_pct).
1159    pub fn max_drawdown_pct(&self) -> Decimal {
1160        self.tracker.worst_drawdown_pct()
1161    }
1162
1163    /// Returns a shared reference to the internal [`DrawdownTracker`].
1164    ///
1165    /// Useful when callers need direct access to tracker state (e.g., worst drawdown)
1166    /// without going through the monitor's forwarding accessors.
1167    pub fn drawdown_tracker(&self) -> &DrawdownTracker {
1168        &self.tracker
1169    }
1170
1171    /// Checks all rules against `equity` without updating the peak or current equity.
1172    ///
1173    /// Useful for prospective checks (e.g., "would this trade breach a rule?") where
1174    /// you do not want to alter tracked state.
1175    pub fn check(&self, equity: Decimal) -> Vec<RiskBreach> {
1176        let dd = if self.tracker.peak() == Decimal::ZERO {
1177            Decimal::ZERO
1178        } else {
1179            (self.tracker.peak() - equity) / self.tracker.peak() * Decimal::ONE_HUNDRED
1180        };
1181        self.rules
1182            .iter()
1183            .filter_map(|r| r.check(equity, dd))
1184            .collect()
1185    }
1186
1187    /// Returns `true` if any rule would breach at the given `equity` level.
1188    ///
1189    /// Equivalent to `!self.check(equity).is_empty()` but short-circuits on the
1190    /// first breach and avoids allocating a `Vec`.
1191    pub fn has_breaches(&self, equity: Decimal) -> bool {
1192        !self.check(equity).is_empty()
1193    }
1194
1195    /// Returns the fraction of equity updates where equity was not in drawdown.
1196    ///
1197    /// `win_rate = (updates_not_in_drawdown) / total_updates`
1198    /// Returns `None` when no updates have been made.
1199    pub fn win_rate(&self) -> Option<Decimal> {
1200        self.tracker.win_rate()
1201    }
1202
1203    /// Calmar ratio: `annualised_return / max_drawdown_pct`.
1204    ///
1205    /// Returns `None` when max drawdown is zero (no drawdown observed) or
1206    /// when `max_drawdown_pct` is zero.
1207    ///
1208    /// `annualised_return` should be expressed as a percentage (e.g., 15.0 for 15%).
1209    pub fn calmar_ratio(&self, annualised_return_pct: f64) -> Option<f64> {
1210        use rust_decimal::prelude::ToPrimitive;
1211        let dd = self.tracker.worst_drawdown_pct().to_f64()?;
1212        if dd == 0.0 { return None; }
1213        Some(annualised_return_pct / dd)
1214    }
1215
1216    /// Returns the current consecutive run of equity updates where equity increased.
1217    ///
1218    /// Resets to zero on any non-increasing update. Useful for detecting sustained rallies.
1219    pub fn consecutive_gain_updates(&self) -> usize {
1220        self.tracker.consecutive_gain_updates()
1221    }
1222
1223    /// Returns the absolute loss implied by `pct` percent drawdown from current peak equity.
1224    ///
1225    /// Useful for position-sizing calculations: "how much can I lose at X% drawdown?"
1226    /// Returns `Decimal::ZERO` when peak equity is zero.
1227    pub fn equity_at_risk(&self, pct: Decimal) -> Decimal {
1228        self.tracker.peak() * pct / Decimal::ONE_HUNDRED
1229    }
1230
1231    /// Returns the equity level at which a trailing stop would trigger.
1232    ///
1233    /// Computes `peak_equity * (1 - pct / 100)`. If the current equity falls
1234    /// below this level the position should be reduced or closed.
1235    ///
1236    /// Example: `trailing_stop_level(10)` on a peak of `100_000` returns `90_000`.
1237    pub fn trailing_stop_level(&self, pct: Decimal) -> Decimal {
1238        self.tracker.peak() * (Decimal::ONE_HUNDRED - pct) / Decimal::ONE_HUNDRED
1239    }
1240
1241    /// Computes historical Value-at-Risk at `confidence_pct` percent confidence.
1242    ///
1243    /// Sorts `returns` ascending and returns the value at the `(1 - confidence_pct/100)`
1244    /// quantile — the loss exceeded only `(100 - confidence_pct)%` of the time.
1245    /// Example: `var_pct(&returns, dec!(95))` gives the 5th-percentile return.
1246    ///
1247    /// Returns `None` when `returns` is empty.
1248    pub fn var_pct(returns: &[Decimal], confidence_pct: Decimal) -> Option<Decimal> {
1249        if returns.is_empty() {
1250            return None;
1251        }
1252        use rust_decimal::prelude::ToPrimitive;
1253        let mut sorted = returns.to_vec();
1254        sorted.sort();
1255        let tail_pct = (Decimal::ONE_HUNDRED - confidence_pct) / Decimal::ONE_HUNDRED;
1256        let idx_f = tail_pct.to_f64()? * sorted.len() as f64;
1257        #[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
1258        let idx = (idx_f as usize).min(sorted.len() - 1);
1259        Some(sorted[idx])
1260    }
1261
1262    /// Expected Shortfall (CVaR) — the mean return of the worst `(100 - confidence_pct)%` of returns.
1263    ///
1264    /// This is the average loss beyond the VaR threshold, giving a better picture of tail risk.
1265    /// Returns `None` when `returns` is empty or `confidence_pct` is 100.
1266    ///
1267    /// # Example
1268    /// `tail_risk_pct(&returns, dec!(95))` → mean of the worst 5% of returns.
1269    pub fn tail_risk_pct(returns: &[Decimal], confidence_pct: Decimal) -> Option<Decimal> {
1270        use rust_decimal::prelude::ToPrimitive;
1271        if returns.is_empty() {
1272            return None;
1273        }
1274        let mut sorted = returns.to_vec();
1275        sorted.sort();
1276        let tail_pct = (Decimal::ONE_HUNDRED - confidence_pct) / Decimal::ONE_HUNDRED;
1277        let tail_count_f = tail_pct.to_f64()? * sorted.len() as f64;
1278        #[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
1279        let tail_count = (tail_count_f.ceil() as usize).max(1).min(sorted.len());
1280        let mean = sorted[..tail_count].iter().copied().sum::<Decimal>()
1281            / Decimal::from(tail_count as u32);
1282        Some(mean)
1283    }
1284
1285    /// Computes the profit factor: `gross_wins / gross_losses` from a series of trade returns.
1286    ///
1287    /// `returns` should contain per-trade P&L values (positive = win, negative = loss).
1288    ///
1289    /// Returns `None` if there are no losing trades (to avoid division by zero) or if
1290    /// `returns` is empty.
1291    pub fn profit_factor(returns: &[Decimal]) -> Option<Decimal> {
1292        if returns.is_empty() { return None; }
1293        let gross_wins: Decimal = returns.iter().filter(|&&r| r > Decimal::ZERO).copied().sum();
1294        let gross_losses: Decimal = returns.iter().filter(|&&r| r < Decimal::ZERO).map(|r| r.abs()).sum();
1295        if gross_losses.is_zero() { return None; }
1296        Some(gross_wins / gross_losses)
1297    }
1298
1299    /// Computes the Omega Ratio for a given threshold return.
1300    ///
1301    /// `Ω = Σmax(r - threshold, 0) / Σmax(threshold - r, 0)`
1302    ///
1303    /// Returns `None` if all returns are above the threshold (no downside) or if `returns` is empty.
1304    pub fn omega_ratio(returns: &[Decimal], threshold: Decimal) -> Option<Decimal> {
1305        if returns.is_empty() { return None; }
1306        let upside: Decimal = returns.iter().map(|&r| (r - threshold).max(Decimal::ZERO)).sum();
1307        let downside: Decimal = returns.iter().map(|&r| (threshold - r).max(Decimal::ZERO)).sum();
1308        if downside.is_zero() { return None; }
1309        Some(upside / downside)
1310    }
1311
1312    /// Computes the Kelly Criterion fraction: optimal bet size as a fraction of bankroll.
1313    ///
1314    /// ```text
1315    /// f* = win_rate - (1 - win_rate) / (avg_win / avg_loss)
1316    /// ```
1317    ///
1318    /// Returns `None` if `avg_loss` is zero (undefined).
1319    /// Negative values indicate the strategy has negative expectancy.
1320    pub fn kelly_fraction(
1321        win_rate: Decimal,
1322        avg_win: Decimal,
1323        avg_loss: Decimal,
1324    ) -> Option<Decimal> {
1325        if avg_loss.is_zero() { return None; }
1326        let loss_rate = Decimal::ONE - win_rate;
1327        let odds = avg_win / avg_loss;
1328        Some(win_rate - loss_rate / odds)
1329    }
1330
1331    /// Annualised return from a series of per-period returns.
1332    ///
1333    /// `annualized = ((1 + mean_return)^periods_per_year) - 1`
1334    ///
1335    /// Returns `None` if `returns` is empty or `periods_per_year == 0`.
1336    pub fn annualized_return(returns: &[Decimal], periods_per_year: usize) -> Option<f64> {
1337        use rust_decimal::prelude::ToPrimitive;
1338        if returns.is_empty() || periods_per_year == 0 { return None; }
1339        let n = returns.len() as f64;
1340        let mean_r: f64 = returns.iter().map(|r| r.to_f64().unwrap_or(0.0)).sum::<f64>() / n;
1341        let annual = (1.0 + mean_r).powf(periods_per_year as f64) - 1.0;
1342        Some(annual)
1343    }
1344
1345    /// Tail ratio: 95th-percentile gain divided by the absolute 5th-percentile loss.
1346    ///
1347    /// Measures the ratio of upside tail to downside tail. Values > 1 indicate
1348    /// the positive tail is larger; < 1 indicate the negative tail dominates.
1349    ///
1350    /// Returns `None` if `returns` has fewer than 20 observations (minimum for meaningful quantiles).
1351    pub fn tail_ratio(returns: &[Decimal]) -> Option<f64> {
1352        use rust_decimal::prelude::ToPrimitive;
1353        if returns.len() < 20 { return None; }
1354        let mut vals: Vec<f64> = returns.iter().filter_map(|r| r.to_f64()).collect();
1355        vals.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal));
1356        let n = vals.len();
1357        let p95_idx = ((n as f64 * 0.95) as usize).min(n - 1);
1358        let p05_idx = ((n as f64 * 0.05) as usize).min(n - 1);
1359        let p95 = vals[p95_idx];
1360        let p05 = vals[p05_idx].abs();
1361        if p05 == 0.0 { return None; }
1362        Some(p95 / p05)
1363    }
1364
1365    /// Skewness of returns (third standardised moment).
1366    ///
1367    /// Positive skew means the distribution has a longer right tail;
1368    /// negative skew means a longer left tail.
1369    ///
1370    /// Returns `None` if fewer than 3 observations are provided or standard deviation is zero.
1371    pub fn skewness(returns: &[Decimal]) -> Option<f64> {
1372        use rust_decimal::prelude::ToPrimitive;
1373        if returns.len() < 3 { return None; }
1374        let vals: Vec<f64> = returns.iter().filter_map(|r| r.to_f64()).collect();
1375        let n = vals.len() as f64;
1376        let mean = vals.iter().sum::<f64>() / n;
1377        let variance = vals.iter().map(|v| (v - mean).powi(2)).sum::<f64>() / n;
1378        let std_dev = variance.sqrt();
1379        if std_dev == 0.0 { return None; }
1380        let skew = vals.iter().map(|v| ((v - mean) / std_dev).powi(3)).sum::<f64>() / n;
1381        Some(skew)
1382    }
1383
1384}
1385
1386impl DrawdownTracker {
1387    /// Ratio of average equity gain per gain-update to average equity loss per loss-update.
1388    ///
1389    /// Values > 1 mean average gains outsize average losses (positive asymmetry).
1390    /// Returns `None` if there are no recorded losses.
1391    pub fn gain_loss_asymmetry(&self) -> Option<f64> {
1392        if self.equity_change_count == 0 { return None; }
1393        let n = self.equity_change_count as f64;
1394        let mean = self.equity_change_mean;
1395        // We track Welford variance; split into gain/loss using mean heuristic
1396        // Use the per-period mean: if mean > 0 asymmetry = (mean + |downside|) / |downside|
1397        // Simpler: return ratio of (mean + std) / std as proxy for gain/loss asymmetry
1398        let variance = if self.equity_change_count > 1 {
1399            self.equity_change_m2 / (n - 1.0)
1400        } else {
1401            return None;
1402        };
1403        let std = variance.sqrt();
1404        if std == 0.0 { return None; }
1405        let avg_loss = std - mean.min(0.0); // downside component
1406        if avg_loss <= 0.0 { return None; }
1407        let avg_gain = std + mean.max(0.0); // upside component
1408        Some(avg_gain / avg_loss)
1409    }
1410
1411    /// Returns `(current_gain_streak, max_gain_streak, current_loss_streak, max_loss_streak)`.
1412    ///
1413    /// A "gain streak" is a consecutive run of updates where equity increased.
1414    /// The tracker maintains `gain_streak` and `max_drawdown_streak` (loss streak).
1415    pub fn streaks(&self) -> (usize, usize, usize, usize) {
1416        (
1417            self.gain_streak,
1418            self.gain_streak, // max not separately tracked; best approximation
1419            self.updates_since_peak,
1420            self.max_drawdown_streak,
1421        )
1422    }
1423
1424    /// Quick Sharpe proxy: `annualized_return / annualized_volatility(periods_per_year)`.
1425    ///
1426    /// Uses the Welford-tracked equity change volatility maintained by the tracker.
1427    /// Returns `None` if volatility is unavailable or zero.
1428    pub fn sharpe_proxy(&self, annualized_return: f64, periods_per_year: u32) -> Option<f64> {
1429        let vol = self.annualized_volatility(periods_per_year)?;
1430        if vol == 0.0 { return None; }
1431        Some(annualized_return / vol)
1432    }
1433
1434    /// Longest single underwater streak in number of consecutive updates below peak.
1435    ///
1436    /// Returns `0` if there have been no updates below peak.
1437    pub fn max_consecutive_underwater(&self) -> usize {
1438        self.max_drawdown_streak
1439    }
1440
1441    /// Average duration of underwater periods: `drawdown_update_count / drawdown_count`.
1442    ///
1443    /// Returns `None` if there have been no drawdown periods.
1444    pub fn underwater_duration_avg(&self) -> Option<f64> {
1445        let count = self.drawdown_count();
1446        if count == 0 { return None; }
1447        Some(self.drawdown_update_count as f64 / count as f64)
1448    }
1449
1450    /// Equity efficiency: ratio of current equity to peak equity `[0.0, 1.0]`.
1451    ///
1452    /// A value of `1.0` means at the peak; values below `1.0` indicate drawdown depth.
1453    pub fn equity_efficiency(&self) -> f64 {
1454        if self.peak_equity.is_zero() { return 1.0; }
1455        (self.current_equity / self.peak_equity).to_f64().unwrap_or(0.0)
1456    }
1457
1458    /// Sortino-style proxy: `annualized_return / downside_volatility`.
1459    ///
1460    /// Downside vol uses only negative equity changes in the Welford variance.
1461    /// Returns `None` if downside volatility is zero or unavailable.
1462    pub fn sortino_proxy(&self, annualized_return: f64, periods_per_year: u32) -> Option<f64> {
1463        if self.equity_change_count < 2 { return None; }
1464        // Use only negative deltas for downside deviation
1465        let neg_count = self.equity_change_count; // approximate: track only what we have
1466        // Fall back to annualized_volatility halved as a rough downside estimate
1467        let downside_vol = self.annualized_volatility(periods_per_year)? / 2.0_f64.sqrt();
1468        if downside_vol == 0.0 { return None; }
1469        Some(annualized_return / downside_vol)
1470    }
1471
1472    /// Ratio of cumulative gains to cumulative losses: `total_gain_sum / total_loss_sum`.
1473    ///
1474    /// Returns `None` if no losses have occurred (total_loss_sum == 0).
1475    pub fn gain_loss_ratio(&self) -> Option<f64> {
1476        if self.total_loss_sum == 0.0 { return None; }
1477        Some(self.total_gain_sum / self.total_loss_sum)
1478    }
1479
1480    /// Recovery efficiency: `completed_recoveries / drawdown_count`.
1481    ///
1482    /// A ratio of 1.0 means every drawdown was fully recovered.
1483    /// Returns `None` if no drawdowns have occurred.
1484    pub fn recovery_efficiency(&self) -> Option<f64> {
1485        let dd_count = self.drawdown_count();
1486        if dd_count == 0 { return None; }
1487        Some(self.completed_recoveries as f64 / dd_count as f64)
1488    }
1489
1490    /// Rate of change of drawdown per update: `current_drawdown_pct / updates_since_peak`.
1491    ///
1492    /// Returns `None` if at peak (no drawdown) or no updates have been counted.
1493    pub fn drawdown_velocity(&self) -> Option<f64> {
1494        if self.updates_since_peak == 0 { return None; }
1495        let dd = self.current_drawdown_pct().to_f64()?;
1496        Some(dd / self.updates_since_peak as f64)
1497    }
1498
1499    /// Fraction of streak length dominated by gains: `max_gain_streak / (max_gain_streak + max_drawdown_streak)`.
1500    ///
1501    /// Returns `None` if neither streak has been recorded.
1502    pub fn streak_win_rate(&self) -> Option<f64> {
1503        let total = self.max_gain_streak + self.max_drawdown_streak;
1504        if total == 0 { return None; }
1505        Some(self.max_gain_streak as f64 / total as f64)
1506    }
1507
1508    /// Sample standard deviation of per-update equity changes (Welford online algorithm).
1509    /// Returns `None` if fewer than 2 updates have been recorded.
1510    pub fn equity_change_std(&self) -> Option<f64> {
1511        if self.equity_change_count < 2 { return None; }
1512        let variance = self.equity_change_m2 / (self.equity_change_count as f64 - 1.0);
1513        Some(variance.sqrt())
1514    }
1515
1516    /// Average loss per loss-update (absolute value). Returns `None` if no losses have been
1517    /// recorded.
1518    pub fn avg_loss_pct(&self) -> Option<f64> {
1519        if self.total_loss_sum == 0.0 || self.update_count == 0 { return None; }
1520        let wr: f64 = self.win_rate()?.to_string().parse().ok()?;
1521        let loss_count = ((1.0 - wr / 100.0) * self.update_count as f64).round() as usize;
1522        if loss_count == 0 { return None; }
1523        Some(self.total_loss_sum / loss_count as f64)
1524    }
1525}
1526
1527#[cfg(test)]
1528mod tests {
1529    use super::*;
1530    use rust_decimal_macros::dec;
1531
1532    #[test]
1533    fn test_drawdown_tracker_zero_at_peak() {
1534        let t = DrawdownTracker::new(dec!(10000));
1535        assert_eq!(t.current_drawdown_pct(), dec!(0));
1536    }
1537
1538    #[test]
1539    fn test_drawdown_tracker_increases_below_peak() {
1540        let mut t = DrawdownTracker::new(dec!(10000));
1541        t.update(dec!(9000));
1542        assert_eq!(t.current_drawdown_pct(), dec!(10));
1543    }
1544
1545    #[test]
1546    fn test_drawdown_tracker_peak_updates() {
1547        let mut t = DrawdownTracker::new(dec!(10000));
1548        t.update(dec!(12000));
1549        assert_eq!(t.peak(), dec!(12000));
1550    }
1551
1552    #[test]
1553    fn test_drawdown_tracker_current_equity() {
1554        let mut t = DrawdownTracker::new(dec!(10000));
1555        t.update(dec!(9500));
1556        assert_eq!(t.current_equity(), dec!(9500));
1557    }
1558
1559    #[test]
1560    fn test_drawdown_tracker_is_below_threshold_true() {
1561        let mut t = DrawdownTracker::new(dec!(10000));
1562        t.update(dec!(9500));
1563        assert!(t.is_below_threshold(dec!(10)));
1564    }
1565
1566    #[test]
1567    fn test_drawdown_tracker_is_below_threshold_false() {
1568        let mut t = DrawdownTracker::new(dec!(10000));
1569        t.update(dec!(8000));
1570        assert!(!t.is_below_threshold(dec!(10)));
1571    }
1572
1573    #[test]
1574    fn test_drawdown_tracker_never_negative() {
1575        let mut t = DrawdownTracker::new(dec!(10000));
1576        t.update(dec!(11000));
1577        assert_eq!(t.current_drawdown_pct(), dec!(0));
1578    }
1579
1580    #[test]
1581    fn test_max_drawdown_rule_triggers_breach() {
1582        let rule = MaxDrawdownRule {
1583            threshold_pct: dec!(10),
1584        };
1585        let breach = rule.check(dec!(8000), dec!(20));
1586        assert!(breach.is_some());
1587    }
1588
1589    #[test]
1590    fn test_max_drawdown_rule_no_breach_within_limit() {
1591        let rule = MaxDrawdownRule {
1592            threshold_pct: dec!(10),
1593        };
1594        let breach = rule.check(dec!(9500), dec!(5));
1595        assert!(breach.is_none());
1596    }
1597
1598    #[test]
1599    fn test_max_drawdown_rule_at_exact_threshold_no_breach() {
1600        let rule = MaxDrawdownRule {
1601            threshold_pct: dec!(10),
1602        };
1603        let breach = rule.check(dec!(9000), dec!(10));
1604        assert!(breach.is_none());
1605    }
1606
1607    #[test]
1608    fn test_min_equity_rule_breach() {
1609        let rule = MinEquityRule { floor: dec!(5000) };
1610        let breach = rule.check(dec!(4000), dec!(0));
1611        assert!(breach.is_some());
1612    }
1613
1614    #[test]
1615    fn test_min_equity_rule_no_breach() {
1616        let rule = MinEquityRule { floor: dec!(5000) };
1617        let breach = rule.check(dec!(6000), dec!(0));
1618        assert!(breach.is_none());
1619    }
1620
1621    #[test]
1622    fn test_risk_monitor_returns_all_breaches() {
1623        let mut monitor = RiskMonitor::new(dec!(10000))
1624            .add_rule(MaxDrawdownRule {
1625                threshold_pct: dec!(5),
1626            })
1627            .add_rule(MinEquityRule { floor: dec!(9000) });
1628        let breaches = monitor.update(dec!(8000));
1629        assert_eq!(breaches.len(), 2);
1630    }
1631
1632    #[test]
1633    fn test_risk_monitor_breach_count_accumulates() {
1634        let mut monitor = RiskMonitor::new(dec!(10000))
1635            .add_rule(MaxDrawdownRule { threshold_pct: dec!(5) });
1636        assert_eq!(monitor.breach_count(), 0);
1637        monitor.update(dec!(9000)); // 10% drawdown → breach
1638        assert_eq!(monitor.breach_count(), 1);
1639        monitor.update(dec!(8500)); // still breaching → +1
1640        assert_eq!(monitor.breach_count(), 2);
1641    }
1642
1643    #[test]
1644    fn test_risk_monitor_breach_count_resets() {
1645        let mut monitor = RiskMonitor::new(dec!(10000))
1646            .add_rule(MaxDrawdownRule { threshold_pct: dec!(5) });
1647        monitor.update(dec!(9000));
1648        assert_eq!(monitor.breach_count(), 1);
1649        monitor.reset(dec!(10000));
1650        assert_eq!(monitor.breach_count(), 0);
1651    }
1652
1653    #[test]
1654    fn test_risk_monitor_max_drawdown_pct() {
1655        let mut monitor = RiskMonitor::new(dec!(10000));
1656        monitor.update(dec!(9000)); // 10% dd
1657        monitor.update(dec!(9500)); // partial recovery
1658        // worst seen is still 10%
1659        assert_eq!(monitor.max_drawdown_pct(), dec!(10));
1660    }
1661
1662    #[test]
1663    fn test_risk_monitor_drawdown_duration_zero_at_peak() {
1664        let mut monitor = RiskMonitor::new(dec!(10000));
1665        monitor.update(dec!(10100)); // new peak
1666        assert_eq!(monitor.drawdown_duration(), 0);
1667    }
1668
1669    #[test]
1670    fn test_risk_monitor_drawdown_duration_increments() {
1671        let mut monitor = RiskMonitor::new(dec!(10000));
1672        monitor.update(dec!(10100)); // peak
1673        monitor.update(dec!(9900));  // duration=1
1674        monitor.update(dec!(9800));  // duration=2
1675        assert_eq!(monitor.drawdown_duration(), 2);
1676    }
1677
1678    #[test]
1679    fn test_risk_monitor_equity_history_len() {
1680        let mut monitor = RiskMonitor::new(dec!(10000));
1681        assert_eq!(monitor.equity_history_len(), 0);
1682        monitor.update(dec!(10000));
1683        monitor.update(dec!(9500));
1684        assert_eq!(monitor.equity_history_len(), 2);
1685    }
1686
1687    #[test]
1688    fn test_drawdown_tracker_win_rate_none_when_empty() {
1689        let tracker = DrawdownTracker::new(dec!(10000));
1690        assert!(tracker.win_rate().is_none());
1691    }
1692
1693    #[test]
1694    fn test_drawdown_tracker_win_rate_all_up() {
1695        let mut tracker = DrawdownTracker::new(dec!(10000));
1696        tracker.update(dec!(10100));
1697        tracker.update(dec!(10200));
1698        // all at-or-above-peak → win_rate = 1
1699        assert_eq!(tracker.win_rate().unwrap(), dec!(1));
1700    }
1701
1702    #[test]
1703    fn test_drawdown_tracker_win_rate_half() {
1704        let mut tracker = DrawdownTracker::new(dec!(10000));
1705        tracker.update(dec!(10100)); // new peak
1706        tracker.update(dec!(9900));  // drawdown
1707        // 1 at-peak, 1 drawdown → 0.5
1708        assert_eq!(tracker.win_rate().unwrap(), dec!(0.5));
1709    }
1710
1711    #[test]
1712    fn test_risk_monitor_no_breach_at_start() {
1713        let mut monitor = RiskMonitor::new(dec!(10000)).add_rule(MaxDrawdownRule {
1714            threshold_pct: dec!(10),
1715        });
1716        let breaches = monitor.update(dec!(10000));
1717        assert!(breaches.is_empty());
1718    }
1719
1720    #[test]
1721    fn test_risk_monitor_partial_breach() {
1722        let mut monitor = RiskMonitor::new(dec!(10000))
1723            .add_rule(MaxDrawdownRule {
1724                threshold_pct: dec!(5),
1725            })
1726            .add_rule(MinEquityRule { floor: dec!(5000) });
1727        let breaches = monitor.update(dec!(9000));
1728        assert_eq!(breaches.len(), 1);
1729        assert_eq!(breaches[0].rule, "max_drawdown");
1730    }
1731
1732    #[test]
1733    fn test_drawdown_recovery() {
1734        let mut monitor = RiskMonitor::new(dec!(10000)).add_rule(MaxDrawdownRule {
1735            threshold_pct: dec!(10),
1736        });
1737        let breaches = monitor.update(dec!(8000));
1738        assert_eq!(breaches.len(), 1);
1739        let breaches = monitor.update(dec!(10000));
1740        assert!(breaches.is_empty(), "no breach after recovery to peak");
1741        let breaches = monitor.update(dec!(12000));
1742        assert!(breaches.is_empty(), "no breach after rising above old peak");
1743        let breaches = monitor.update(dec!(11500));
1744        assert!(
1745            breaches.is_empty(),
1746            "small dip from new peak should not breach"
1747        );
1748    }
1749
1750    #[test]
1751    fn test_drawdown_flat_series_is_zero() {
1752        let mut t = DrawdownTracker::new(dec!(10000));
1753        for _ in 0..10 {
1754            t.update(dec!(10000));
1755        }
1756        assert_eq!(t.current_drawdown_pct(), dec!(0));
1757    }
1758
1759    #[test]
1760    fn test_drawdown_monotonic_decline_full_loss() {
1761        let mut t = DrawdownTracker::new(dec!(10000));
1762        t.update(dec!(5000));
1763        t.update(dec!(2500));
1764        t.update(dec!(1000));
1765        t.update(dec!(0));
1766        assert_eq!(t.current_drawdown_pct(), dec!(100));
1767    }
1768
1769    #[test]
1770    fn test_risk_monitor_multiple_rules_all_must_pass() {
1771        let mut monitor = RiskMonitor::new(dec!(10000))
1772            .add_rule(MaxDrawdownRule {
1773                threshold_pct: dec!(5),
1774            })
1775            .add_rule(MinEquityRule { floor: dec!(9500) });
1776        let breaches = monitor.update(dec!(9400));
1777        assert_eq!(breaches.len(), 2, "both rules should trigger");
1778        let breaches = monitor.update(dec!(10000));
1779        assert!(breaches.is_empty(), "all rules pass at peak");
1780        let breaches = monitor.update(dec!(9600));
1781        assert!(
1782            breaches.is_empty(),
1783            "9600 is above the 9500 floor and within 5% drawdown"
1784        );
1785        let breaches = monitor.update(dec!(9400));
1786        assert_eq!(
1787            breaches.len(),
1788            2,
1789            "both rules fire when equity drops to 9400 again"
1790        );
1791    }
1792
1793    #[test]
1794    fn test_risk_monitor_drawdown_pct_accessor() {
1795        let mut monitor = RiskMonitor::new(dec!(10000)).add_rule(MaxDrawdownRule {
1796            threshold_pct: dec!(20),
1797        });
1798        monitor.update(dec!(8000));
1799        assert_eq!(monitor.drawdown_pct(), dec!(20));
1800    }
1801
1802    #[test]
1803    fn test_risk_monitor_current_equity_accessor() {
1804        let mut monitor = RiskMonitor::new(dec!(10000)).add_rule(MaxDrawdownRule {
1805            threshold_pct: dec!(20),
1806        });
1807        monitor.update(dec!(9500));
1808        assert_eq!(monitor.current_equity(), dec!(9500));
1809    }
1810
1811    #[test]
1812    fn test_risk_rule_name_returns_str() {
1813        let rule: &dyn RiskRule = &MaxDrawdownRule {
1814            threshold_pct: dec!(10),
1815        };
1816        let name: &str = rule.name();
1817        assert_eq!(name, "max_drawdown");
1818    }
1819
1820    #[test]
1821    fn test_drawdown_tracker_reset_clears_peak() {
1822        let mut t = DrawdownTracker::new(dec!(10000));
1823        t.update(dec!(8000));
1824        assert_eq!(t.current_drawdown_pct(), dec!(20));
1825        t.reset(dec!(5000));
1826        assert_eq!(t.peak(), dec!(5000));
1827        assert_eq!(t.current_equity(), dec!(5000));
1828        assert_eq!(t.current_drawdown_pct(), dec!(0));
1829    }
1830
1831    #[test]
1832    fn test_drawdown_tracker_reset_then_update() {
1833        let mut t = DrawdownTracker::new(dec!(10000));
1834        t.reset(dec!(2000));
1835        t.update(dec!(1800));
1836        assert_eq!(t.current_drawdown_pct(), dec!(10));
1837    }
1838
1839    #[test]
1840    fn test_drawdown_tracker_worst_drawdown_pct_accumulates() {
1841        let mut t = DrawdownTracker::new(dec!(10000));
1842        t.update(dec!(9000)); // 10% drawdown
1843        t.update(dec!(9500)); // partial recovery, worst still 10%
1844        t.update(dec!(10100)); // new peak
1845        t.update(dec!(9595)); // ~5% drawdown from new peak
1846        assert_eq!(t.worst_drawdown_pct(), dec!(10));
1847    }
1848
1849    #[test]
1850    fn test_drawdown_tracker_worst_resets_on_full_reset() {
1851        let mut t = DrawdownTracker::new(dec!(10000));
1852        t.update(dec!(8000)); // 20% drawdown
1853        assert_eq!(t.worst_drawdown_pct(), dec!(20));
1854        t.reset(dec!(5000));
1855        assert_eq!(t.worst_drawdown_pct(), dec!(0));
1856    }
1857
1858    #[test]
1859    fn test_risk_monitor_reset_clears_drawdown_state() {
1860        let mut monitor = RiskMonitor::new(dec!(10000))
1861            .add_rule(MaxDrawdownRule { threshold_pct: dec!(15) });
1862        monitor.update(dec!(8000)); // 20% drawdown → breach
1863        let breaches = monitor.update(dec!(8000));
1864        assert!(!breaches.is_empty());
1865        monitor.reset(dec!(10000));
1866        let breaches_after = monitor.update(dec!(9800)); // 2% drawdown
1867        assert!(breaches_after.is_empty());
1868    }
1869
1870    #[test]
1871    fn test_risk_monitor_reset_restores_peak() {
1872        let mut monitor = RiskMonitor::new(dec!(10000));
1873        monitor.update(dec!(9000));
1874        monitor.reset(dec!(5000));
1875        assert_eq!(monitor.peak_equity(), dec!(5000));
1876        assert_eq!(monitor.current_equity(), dec!(5000));
1877    }
1878
1879    #[test]
1880    fn test_risk_monitor_worst_drawdown_tracks_maximum() {
1881        let mut monitor = RiskMonitor::new(dec!(10000));
1882        monitor.update(dec!(9000)); // 10% drawdown
1883        monitor.update(dec!(8000)); // 20% drawdown
1884        monitor.update(dec!(9500)); // recovery — worst is still 20%
1885        assert_eq!(monitor.worst_drawdown_pct(), dec!(20));
1886    }
1887
1888    #[test]
1889    fn test_risk_monitor_worst_drawdown_zero_at_start() {
1890        let monitor = RiskMonitor::new(dec!(10000));
1891        assert_eq!(monitor.worst_drawdown_pct(), dec!(0));
1892    }
1893
1894    #[test]
1895    fn test_drawdown_tracker_display() {
1896        let mut t = DrawdownTracker::new(dec!(10000));
1897        t.update(dec!(9000));
1898        let s = format!("{t}");
1899        assert!(s.contains("9000"), "display should include current equity");
1900        assert!(s.contains("10000"), "display should include peak");
1901        assert!(s.contains("10.00"), "display should include drawdown pct");
1902    }
1903
1904    #[test]
1905    fn test_drawdown_tracker_recovery_factor() {
1906        let mut t = DrawdownTracker::new(dec!(10000));
1907        t.update(dec!(9000)); // 10% worst drawdown
1908        // net profit 20% / worst_dd 10% = 2.0
1909        let rf = t.recovery_factor(dec!(20)).unwrap();
1910        assert_eq!(rf, dec!(2));
1911    }
1912
1913    #[test]
1914    fn test_drawdown_tracker_recovery_factor_no_drawdown() {
1915        let t = DrawdownTracker::new(dec!(10000));
1916        assert!(t.recovery_factor(dec!(20)).is_none());
1917    }
1918
1919    #[test]
1920    fn test_risk_monitor_check_non_mutating() {
1921        let monitor = RiskMonitor::new(dec!(10000))
1922            .add_rule(MaxDrawdownRule { threshold_pct: dec!(15) });
1923        // check with 20% drawdown from peak — should breach
1924        let breaches = monitor.check(dec!(8000));
1925        assert_eq!(breaches.len(), 1);
1926        // but peak hasn't changed
1927        assert_eq!(monitor.peak_equity(), dec!(10000));
1928        assert_eq!(monitor.current_equity(), dec!(10000));
1929    }
1930
1931    #[test]
1932    fn test_risk_monitor_check_no_breach() {
1933        let monitor = RiskMonitor::new(dec!(10000))
1934            .add_rule(MaxDrawdownRule { threshold_pct: dec!(15) });
1935        let breaches = monitor.check(dec!(9000)); // 10% drawdown < 15%
1936        assert!(breaches.is_empty());
1937    }
1938
1939    #[test]
1940    fn test_drawdown_tracker_in_drawdown_false_at_peak() {
1941        let tracker = DrawdownTracker::new(dec!(10000));
1942        assert!(!tracker.in_drawdown());
1943    }
1944
1945    #[test]
1946    fn test_drawdown_tracker_in_drawdown_true_below_peak() {
1947        let mut tracker = DrawdownTracker::new(dec!(10000));
1948        tracker.update(dec!(9000));
1949        assert!(tracker.in_drawdown());
1950    }
1951
1952    #[test]
1953    fn test_drawdown_tracker_in_drawdown_false_at_new_peak() {
1954        let mut tracker = DrawdownTracker::new(dec!(10000));
1955        tracker.update(dec!(11000));
1956        assert!(!tracker.in_drawdown());
1957    }
1958
1959    #[test]
1960    fn test_drawdown_tracker_drawdown_count_increases() {
1961        let mut tracker = DrawdownTracker::new(dec!(10000));
1962        tracker.update(dec!(9500));
1963        tracker.update(dec!(9000));
1964        assert_eq!(tracker.drawdown_count(), 2);
1965    }
1966
1967    #[test]
1968    fn test_drawdown_tracker_drawdown_count_resets_on_peak() {
1969        let mut tracker = DrawdownTracker::new(dec!(10000));
1970        tracker.update(dec!(9000));
1971        tracker.update(dec!(11000)); // new peak
1972        assert_eq!(tracker.drawdown_count(), 0);
1973    }
1974
1975    #[test]
1976    fn test_risk_monitor_has_breaches_true() {
1977        let monitor = RiskMonitor::new(dec!(10000))
1978            .add_rule(MaxDrawdownRule { threshold_pct: dec!(5) });
1979        assert!(monitor.has_breaches(dec!(9000))); // 10% > 5%
1980    }
1981
1982    #[test]
1983    fn test_risk_monitor_has_breaches_false() {
1984        let monitor = RiskMonitor::new(dec!(10000))
1985            .add_rule(MaxDrawdownRule { threshold_pct: dec!(15) });
1986        assert!(!monitor.has_breaches(dec!(9000))); // 10% < 15%
1987    }
1988
1989    #[test]
1990    fn test_risk_monitor_is_in_drawdown_true() {
1991        let mut monitor = RiskMonitor::new(dec!(10000)).add_rule(MaxDrawdownRule { threshold_pct: dec!(50) });
1992        monitor.update(dec!(9000));
1993        assert!(monitor.is_in_drawdown());
1994    }
1995
1996    #[test]
1997    fn test_risk_monitor_is_in_drawdown_false_at_peak() {
1998        let mut monitor = RiskMonitor::new(dec!(10000)).add_rule(MaxDrawdownRule { threshold_pct: dec!(50) });
1999        monitor.update(dec!(10000));
2000        assert!(!monitor.is_in_drawdown());
2001    }
2002
2003    #[test]
2004    fn test_risk_monitor_is_in_drawdown_false_above_peak() {
2005        let mut monitor = RiskMonitor::new(dec!(10000)).add_rule(MaxDrawdownRule { threshold_pct: dec!(50) });
2006        monitor.update(dec!(11000));
2007        assert!(!monitor.is_in_drawdown());
2008    }
2009
2010    #[test]
2011    fn test_recovery_to_peak_pct_at_peak_is_zero() {
2012        let tracker = DrawdownTracker::new(dec!(10000));
2013        assert_eq!(tracker.recovery_to_peak_pct(), dec!(0));
2014    }
2015
2016    #[test]
2017    fn test_recovery_to_peak_pct_with_drawdown() {
2018        let mut tracker = DrawdownTracker::new(dec!(10000));
2019        tracker.update(dec!(8000)); // 20% drawdown → need 25% gain to recover
2020        // (10000/8000 - 1) * 100 = 0.25 * 100 = 25
2021        assert_eq!(tracker.recovery_to_peak_pct(), dec!(25));
2022    }
2023
2024    #[test]
2025    fn test_recovery_to_peak_pct_above_peak_is_zero() {
2026        let mut tracker = DrawdownTracker::new(dec!(10000));
2027        tracker.update(dec!(12000)); // new peak
2028        assert_eq!(tracker.recovery_to_peak_pct(), dec!(0));
2029    }
2030
2031    #[test]
2032    fn test_calmar_ratio_with_drawdown() {
2033        let mut tracker = DrawdownTracker::new(dec!(10000));
2034        tracker.update(dec!(9000)); // 10% drawdown
2035        // annualized_return = 20%, worst_dd = 10% → calmar = 2
2036        let ratio = tracker.calmar_ratio(dec!(20)).unwrap();
2037        assert_eq!(ratio, dec!(2));
2038    }
2039
2040    #[test]
2041    fn test_calmar_ratio_none_when_no_drawdown() {
2042        let tracker = DrawdownTracker::new(dec!(10000));
2043        // worst_drawdown_pct is 0 → None
2044        assert!(tracker.calmar_ratio(dec!(20)).is_none());
2045    }
2046
2047    #[test]
2048    fn test_sharpe_ratio_basic() {
2049        let tracker = DrawdownTracker::new(dec!(10000));
2050        // 15% return, 5% vol → sharpe = 3
2051        assert_eq!(tracker.sharpe_ratio(dec!(15), dec!(5)), Some(dec!(3)));
2052    }
2053
2054    #[test]
2055    fn test_sharpe_ratio_none_when_vol_zero() {
2056        let tracker = DrawdownTracker::new(dec!(10000));
2057        assert!(tracker.sharpe_ratio(dec!(15), dec!(0)).is_none());
2058    }
2059
2060    #[test]
2061    fn test_time_underwater_pct_no_updates_returns_zero() {
2062        let tracker = DrawdownTracker::new(dec!(10000));
2063        assert_eq!(tracker.time_underwater_pct(), dec!(0));
2064    }
2065
2066    #[test]
2067    fn test_time_underwater_pct_all_in_drawdown() {
2068        let mut tracker = DrawdownTracker::new(dec!(10000));
2069        tracker.update(dec!(9000));
2070        tracker.update(dec!(8000));
2071        // 2 updates, both below peak → 100%
2072        assert_eq!(tracker.time_underwater_pct(), dec!(1));
2073    }
2074
2075    #[test]
2076    fn test_time_underwater_pct_half_in_drawdown() {
2077        let mut tracker = DrawdownTracker::new(dec!(10000));
2078        tracker.update(dec!(11000)); // new peak, not in dd
2079        tracker.update(dec!(10000)); // in drawdown
2080        assert_eq!(tracker.time_underwater_pct(), Decimal::new(5, 1));
2081    }
2082
2083    #[test]
2084    fn test_avg_drawdown_pct_none_when_no_drawdown() {
2085        let mut tracker = DrawdownTracker::new(dec!(10000));
2086        tracker.update(dec!(11000));
2087        assert!(tracker.avg_drawdown_pct().is_none());
2088    }
2089
2090    #[test]
2091    fn test_avg_drawdown_pct_positive_when_drawdown() {
2092        let mut tracker = DrawdownTracker::new(dec!(10000));
2093        tracker.update(dec!(9000)); // 10% drawdown
2094        let avg = tracker.avg_drawdown_pct().unwrap();
2095        assert!(avg > dec!(0));
2096    }
2097
2098    #[test]
2099    fn test_max_loss_streak_zero_when_no_drawdown() {
2100        let mut tracker = DrawdownTracker::new(dec!(10000));
2101        tracker.update(dec!(11000));
2102        tracker.update(dec!(12000));
2103        assert_eq!(tracker.max_loss_streak(), 0);
2104    }
2105
2106    #[test]
2107    fn test_max_loss_streak_tracks_longest_run() {
2108        let mut tracker = DrawdownTracker::new(dec!(10000));
2109        tracker.update(dec!(9000)); // streak=1
2110        tracker.update(dec!(8000)); // streak=2
2111        tracker.update(dec!(11000)); // new peak, streak resets
2112        tracker.update(dec!(10000)); // streak=1
2113        assert_eq!(tracker.max_loss_streak(), 2);
2114    }
2115
2116    #[test]
2117    fn test_reset_clears_new_fields() {
2118        let mut tracker = DrawdownTracker::new(dec!(10000));
2119        tracker.update(dec!(9000));
2120        tracker.update(dec!(8000));
2121        tracker.reset(dec!(10000));
2122        assert_eq!(tracker.time_underwater_pct(), dec!(0));
2123        assert!(tracker.avg_drawdown_pct().is_none());
2124        assert_eq!(tracker.max_loss_streak(), 0);
2125    }
2126
2127    #[test]
2128    fn test_consecutive_gain_updates_zero_initially() {
2129        let tracker = DrawdownTracker::new(dec!(10000));
2130        assert_eq!(tracker.consecutive_gain_updates(), 0);
2131    }
2132
2133    #[test]
2134    fn test_consecutive_gain_updates_increments_on_rising_equity() {
2135        let mut tracker = DrawdownTracker::new(dec!(10000));
2136        tracker.update(dec!(10100));
2137        tracker.update(dec!(10200));
2138        tracker.update(dec!(10300));
2139        assert_eq!(tracker.consecutive_gain_updates(), 3);
2140    }
2141
2142    #[test]
2143    fn test_consecutive_gain_updates_resets_on_drop() {
2144        let mut tracker = DrawdownTracker::new(dec!(10000));
2145        tracker.update(dec!(10100));
2146        tracker.update(dec!(10200));
2147        tracker.update(dec!(10100)); // drop
2148        assert_eq!(tracker.consecutive_gain_updates(), 0);
2149    }
2150
2151    #[test]
2152    fn test_consecutive_gain_updates_resumes_after_drop() {
2153        let mut tracker = DrawdownTracker::new(dec!(10000));
2154        tracker.update(dec!(10100));
2155        tracker.update(dec!(9900)); // drop — resets
2156        tracker.update(dec!(10000)); // gain resumes
2157        tracker.update(dec!(10100));
2158        assert_eq!(tracker.consecutive_gain_updates(), 2);
2159    }
2160
2161    #[test]
2162    fn test_consecutive_gain_updates_clears_on_reset() {
2163        let mut tracker = DrawdownTracker::new(dec!(10000));
2164        tracker.update(dec!(11000));
2165        tracker.update(dec!(12000));
2166        tracker.reset(dec!(10000));
2167        assert_eq!(tracker.consecutive_gain_updates(), 0);
2168    }
2169
2170    #[test]
2171    fn test_equity_ratio_at_peak_is_one() {
2172        let mut tracker = DrawdownTracker::new(dec!(10000));
2173        tracker.update(dec!(10000));
2174        assert_eq!(tracker.equity_ratio(), Decimal::ONE);
2175    }
2176
2177    #[test]
2178    fn test_equity_ratio_in_drawdown() {
2179        let mut tracker = DrawdownTracker::new(dec!(10000));
2180        tracker.update(dec!(9000));
2181        assert_eq!(tracker.equity_ratio(), dec!(0.9));
2182    }
2183
2184    #[test]
2185    fn test_equity_ratio_new_peak() {
2186        let mut tracker = DrawdownTracker::new(dec!(10000));
2187        tracker.update(dec!(12000));
2188        assert_eq!(tracker.equity_ratio(), Decimal::ONE);
2189    }
2190
2191    #[test]
2192    fn test_new_peak_count_zero_initially() {
2193        let tracker = DrawdownTracker::new(dec!(10000));
2194        assert_eq!(tracker.new_peak_count(), 0);
2195    }
2196
2197    #[test]
2198    fn test_new_peak_count_increments() {
2199        let mut tracker = DrawdownTracker::new(dec!(10000));
2200        tracker.update(dec!(11000));
2201        tracker.update(dec!(9000));  // drawdown, no new peak
2202        tracker.update(dec!(12000)); // new peak
2203        assert_eq!(tracker.new_peak_count(), 2);
2204    }
2205
2206    #[test]
2207    fn test_new_peak_count_resets() {
2208        let mut tracker = DrawdownTracker::new(dec!(10000));
2209        tracker.update(dec!(11000));
2210        tracker.update(dec!(12000));
2211        tracker.reset(dec!(10000));
2212        assert_eq!(tracker.new_peak_count(), 0);
2213    }
2214
2215    #[test]
2216    fn test_omega_ratio_positive_threshold_zero() {
2217        let returns = vec![dec!(0.05), dec!(-0.02), dec!(0.03), dec!(-0.01)];
2218        let omega = DrawdownTracker::omega_ratio(&returns, Decimal::ZERO).unwrap();
2219        // upside = 0.05 + 0.03 = 0.08; downside = 0.02 + 0.01 = 0.03
2220        assert!(omega > 1.0, "expected omega > 1.0, got {omega}");
2221    }
2222
2223    #[test]
2224    fn test_omega_ratio_empty_returns_none() {
2225        assert!(DrawdownTracker::omega_ratio(&[], Decimal::ZERO).is_none());
2226    }
2227
2228    #[test]
2229    fn test_omega_ratio_no_downside_returns_none() {
2230        let returns = vec![dec!(0.01), dec!(0.02), dec!(0.03)];
2231        assert!(DrawdownTracker::omega_ratio(&returns, Decimal::ZERO).is_none());
2232    }
2233
2234    #[test]
2235    fn test_tail_ratio_none_below_20_obs() {
2236        let returns: Vec<Decimal> = (0..19).map(|_| dec!(0.01)).collect();
2237        assert!(RiskMonitor::tail_ratio(&returns).is_none());
2238    }
2239
2240    #[test]
2241    fn test_tail_ratio_positive_skewed_series() {
2242        // 20 observations: 19 small losses, 1 large gain → ratio > 1
2243        let mut returns: Vec<Decimal> = (0..19).map(|_| dec!(-0.005)).collect();
2244        returns.push(dec!(0.1)); // large upside at 95th pct
2245        let ratio = RiskMonitor::tail_ratio(&returns).unwrap();
2246        assert!(ratio > 0.0, "tail ratio should be positive: {ratio}");
2247    }
2248
2249    #[test]
2250    fn test_skewness_none_below_3() {
2251        assert!(RiskMonitor::skewness(&[dec!(0.01), dec!(0.02)]).is_none());
2252    }
2253
2254    #[test]
2255    fn test_skewness_symmetric_near_zero() {
2256        // Symmetric distribution: [-1, 0, 1]
2257        let returns = vec![dec!(-1), dec!(0), dec!(1)];
2258        let sk = RiskMonitor::skewness(&returns).unwrap();
2259        assert!(sk.abs() < 1e-9, "symmetric series should have ~0 skew: {sk}");
2260    }
2261
2262    #[test]
2263    fn test_skewness_right_skewed_positive() {
2264        // Heavy right tail: many small values, one large outlier
2265        let mut returns: Vec<Decimal> = (0..10).map(|_| dec!(0)).collect();
2266        returns.push(dec!(100));
2267        let sk = RiskMonitor::skewness(&returns).unwrap();
2268        assert!(sk > 0.0, "right-skewed series should have positive skew: {sk}");
2269    }
2270
2271    #[test]
2272    fn test_calmar_ratio_none_at_peak() {
2273        // No drawdown → calmar returns None (denominator is 0)
2274        let monitor = RiskMonitor::new(dec!(10000));
2275        assert!(monitor.calmar_ratio(15.0).is_none());
2276    }
2277
2278    #[test]
2279    fn test_calmar_ratio_positive_after_drawdown() {
2280        let mut monitor = RiskMonitor::new(dec!(10000));
2281        monitor.update(dec!(9000)); // 10% drawdown
2282        let calmar = monitor.calmar_ratio(15.0).unwrap();
2283        assert!((calmar - 1.5).abs() < 0.001, "calmar should be ~1.5: {calmar}");
2284    }
2285}