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        use rust_decimal::prelude::ToPrimitive;
695        let wr = self.win_rate()?.to_f64()?;
696        let gain_count = (wr / 100.0 * self.update_count as f64).round() as usize;
697        if gain_count == 0 { return None; }
698        Some(self.total_gain_sum / gain_count as f64)
699    }
700
701    /// Returns `true` if the current equity equals the peak (no drawdown).
702    pub fn is_at_peak(&self) -> bool {
703        self.current_equity >= self.peak_equity
704    }
705
706    /// Returns `true` if the current equity is below the initial equity at construction.
707    pub fn below_initial_equity(&self) -> bool {
708        self.current_equity < self.initial_equity
709    }
710
711    /// Net return divided by max drawdown percentage (simplified Calmar-like ratio).
712    ///
713    /// Returns `None` if max drawdown is zero or there are fewer than 2 updates.
714    pub fn return_drawdown_ratio(&self) -> Option<f64> {
715        use rust_decimal::prelude::ToPrimitive;
716        if self.worst_drawdown_pct.is_zero() { return None; }
717        let net_ret = self.net_return_pct()?;
718        let dd = self.worst_drawdown_pct.to_f64()?;
719        if dd == 0.0 { return None; }
720        Some(net_ret / dd)
721    }
722
723    /// Percentage of total updates where equity was unchanged (flat).
724    ///
725    /// Returns `0.0` if no updates have been recorded.
726    pub fn consecutive_flat_pct(&self) -> f64 {
727        if self.update_count == 0 { return 0.0; }
728        self.flat_streak as f64 / self.update_count as f64 * 100.0
729    }
730
731    /// Current consecutive streak length: positive = gains, negative = losses, 0 = flat.
732    pub fn current_streak(&self) -> i64 {
733        if self.gain_streak > 0 {
734            self.gain_streak as i64
735        } else if self.loss_streak_current > 0 {
736            -(self.loss_streak_current as i64)
737        } else {
738            0
739        }
740    }
741
742    /// The single largest equity loss as a percentage of the equity at the time of the loss.
743    ///
744    /// Returns `None` if no loss has been recorded (min_equity_delta >= 0).
745    pub fn max_loss_pct_single(&self) -> Option<f64> {
746        use rust_decimal::prelude::ToPrimitive;
747        if self.min_equity_delta >= 0.0 { return None; }
748        let peak = self.peak_equity.to_f64()?;
749        if peak <= 0.0 { return None; }
750        Some((self.min_equity_delta / peak).abs() * 100.0)
751    }
752
753    /// Win rate divided by loss rate (win probability / loss probability).
754    ///
755    /// Returns `None` if either rate is unavailable or loss rate is zero.
756    pub fn win_loss_ratio(&self) -> Option<f64> {
757        use rust_decimal::prelude::ToPrimitive;
758        let wr = self.win_rate()?.to_f64()?;
759        let lr = self.loss_rate()?;
760        if lr == 0.0 { return None; }
761        Some(wr / (lr * 100.0))
762    }
763
764    /// Max single gain percentage divided by worst drawdown percentage (reward/risk ratio).
765    ///
766    /// Returns `None` if no drawdown or no gain has been recorded.
767    pub fn best_drawdown_recovery(&self) -> Option<f64> {
768        use rust_decimal::prelude::ToPrimitive;
769        if self.worst_drawdown_pct.is_zero() { return None; }
770        let max_gain = self.max_gain_pct();
771        if max_gain <= 0.0 { return None; }
772        let dd = self.worst_drawdown_pct.to_f64()?;
773        if dd == 0.0 { return None; }
774        Some(max_gain / dd)
775    }
776
777    /// Total number of completed drawdown recovery events.
778    pub fn recovery_count(&self) -> usize {
779        self.completed_recoveries
780    }
781
782    /// Ratio of average gain to average loss per update.
783    ///
784    /// Returns `None` if either average is unavailable or average loss is zero.
785    pub fn avg_gain_loss_ratio(&self) -> Option<f64> {
786        let avg_gain = self.avg_gain_pct()?;
787        let lr = self.loss_rate()?;
788        let loss_count = (lr * self.update_count as f64).round() as usize;
789        if loss_count == 0 { return None; }
790        let avg_loss = self.total_loss_sum / loss_count as f64;
791        if avg_loss == 0.0 { return None; }
792        Some(avg_gain / avg_loss)
793    }
794
795    /// Estimated number of updates to recover from the current drawdown.
796    ///
797    /// Based on average gain size and current distance from peak.
798    /// Returns `None` if not in drawdown, no gain history, or average gain is zero.
799    pub fn time_to_recover_est(&self) -> Option<usize> {
800        use rust_decimal::prelude::ToPrimitive;
801        if !self.in_drawdown() { return None; }
802        let avg_gain = self.avg_gain_pct()?;
803        if avg_gain <= 0.0 { return None; }
804        let distance = self.current_drawdown_pct().to_f64()?;
805        Some((distance / avg_gain).ceil() as usize)
806    }
807
808    /// Current distance of equity below the peak in absolute terms.
809    pub fn current_drawdown_absolute(&self) -> Decimal {
810        if self.current_equity >= self.peak_equity {
811            Decimal::ZERO
812        } else {
813            self.peak_equity - self.current_equity
814        }
815    }
816
817    /// Median of a slice of drawdown percentages.
818    ///
819    /// The input need not be sorted. Returns `None` if the slice is empty.
820    pub fn median_drawdown_pct(drawdowns: &[Decimal]) -> Option<Decimal> {
821        if drawdowns.is_empty() { return None; }
822        let mut sorted = drawdowns.to_vec();
823        sorted.sort();
824        let mid = sorted.len() / 2;
825        if sorted.len() % 2 == 1 {
826            Some(sorted[mid])
827        } else {
828            Some((sorted[mid - 1] + sorted[mid]) / Decimal::TWO)
829        }
830    }
831
832    /// Sortino ratio from a slice of period returns.
833    ///
834    /// `sortino = (mean_return - target) / downside_deviation`
835    ///
836    /// where downside deviation is the standard deviation of returns *below* `target`.
837    /// Returns `None` if `returns` is empty or downside deviation is zero.
838    pub fn sortino_ratio(returns: &[Decimal], target: Decimal) -> Option<f64> {
839        if returns.is_empty() {
840            return None;
841        }
842        let n = returns.len() as f64;
843        let target_f = target.to_f64()?;
844        let mean: f64 = returns.iter().filter_map(|r| r.to_f64()).sum::<f64>() / n;
845        let downside_sq_sum: f64 = returns
846            .iter()
847            .filter_map(|r| r.to_f64())
848            .map(|r| {
849                let diff = r - target_f;
850                if diff < 0.0 { diff * diff } else { 0.0 }
851            })
852            .sum();
853        if downside_sq_sum == 0.0 {
854            return None;
855        }
856        let downside_dev = (downside_sq_sum / n).sqrt();
857        if downside_dev == 0.0 {
858            return None;
859        }
860        Some((mean - target_f) / downside_dev)
861    }
862
863    /// Annualised volatility from a slice of period returns.
864    ///
865    /// `volatility = std_dev(returns) * sqrt(periods_per_year)`
866    ///
867    /// Returns `None` if `returns` has fewer than 2 elements.
868    pub fn returns_volatility(returns: &[Decimal], periods_per_year: u32) -> Option<f64> {
869        if returns.len() < 2 {
870            return None;
871        }
872        let n = returns.len() as f64;
873        let mean: f64 = returns.iter()
874            .filter_map(|r| r.to_f64())
875            .sum::<f64>() / n;
876        let variance: f64 = returns.iter()
877            .filter_map(|r| r.to_f64())
878            .map(|r| (r - mean).powi(2))
879            .sum::<f64>() / (n - 1.0);
880        let vol = variance.sqrt() * (periods_per_year as f64).sqrt();
881        Some(vol)
882    }
883
884    /// Omega ratio: sum of returns above `threshold` / abs(sum of returns below `threshold`).
885    ///
886    /// Values > 1 indicate more upside than downside relative to the threshold.
887    /// Returns `None` if `returns` is empty or total downside is zero.
888    pub fn omega_ratio(returns: &[Decimal], threshold: Decimal) -> Option<f64> {
889        if returns.is_empty() {
890            return None;
891        }
892        let threshold_f = threshold.to_f64()?;
893        let upside: f64 = returns
894            .iter()
895            .filter_map(|r| r.to_f64())
896            .map(|r| (r - threshold_f).max(0.0))
897            .sum();
898        let downside: f64 = returns
899            .iter()
900            .filter_map(|r| r.to_f64())
901            .map(|r| (threshold_f - r).max(0.0))
902            .sum();
903        if downside == 0.0 {
904            return None;
905        }
906        Some(upside / downside)
907    }
908
909    /// Information ratio: `(mean(returns) - mean(benchmark)) / std_dev(returns - benchmark)`.
910    ///
911    /// Measures risk-adjusted excess return over a benchmark. Returns `None` if fewer than 2
912    /// matched return pairs exist or tracking error is zero.
913    pub fn information_ratio(returns: &[Decimal], benchmark: &[Decimal]) -> Option<f64> {
914        let n = returns.len().min(benchmark.len());
915        if n < 2 {
916            return None;
917        }
918        let excess: Vec<f64> = returns[..n]
919            .iter()
920            .zip(benchmark[..n].iter())
921            .filter_map(|(r, b)| Some(r.to_f64()? - b.to_f64()?))
922            .collect();
923        if excess.len() < 2 {
924            return None;
925        }
926        let mean_excess = excess.iter().sum::<f64>() / excess.len() as f64;
927        let tracking_variance = excess.iter().map(|e| (e - mean_excess).powi(2)).sum::<f64>()
928            / (excess.len() as f64 - 1.0);
929        let tracking_error = tracking_variance.sqrt();
930        if tracking_error == 0.0 {
931            return None;
932        }
933        Some(mean_excess / tracking_error)
934    }
935
936    /// Annualized volatility of equity changes: `std_dev_of_changes * sqrt(periods_per_year)`.
937    ///
938    /// Returns `None` if fewer than 2 updates have been recorded.
939    pub fn annualized_volatility(&self, periods_per_year: u32) -> Option<f64> {
940        if self.equity_change_count < 2 { return None; }
941        let n = self.equity_change_count as f64;
942        let variance = self.equity_change_m2 / (n - 1.0);
943        Some(variance.sqrt() * (periods_per_year as f64).sqrt())
944    }
945
946    /// Pain ratio: `annualized_return_pct / pain_index`.
947    ///
948    /// A higher ratio indicates better risk-adjusted performance relative to
949    /// sustained drawdown. Returns `None` if the pain index is zero (no drawdowns).
950    pub fn pain_ratio(&self, annualized_return_pct: Decimal) -> Option<Decimal> {
951        let pi = self.pain_index();
952        if pi.is_zero() { return None; }
953        Some(annualized_return_pct / pi)
954    }
955
956    /// Fraction of all updates where equity was at or above the peak (above water).
957    ///
958    /// Complement of [`time_underwater_pct`](Self::time_underwater_pct).
959    /// Returns `Decimal::ONE` when no updates have been processed.
960    pub fn time_above_watermark_pct(&self) -> Decimal {
961        if self.update_count == 0 {
962            return Decimal::ONE;
963        }
964        Decimal::ONE - self.time_underwater_pct()
965    }
966
967    /// Sample standard deviation of per-update equity changes.
968    ///
969    /// Uses the Welford running variance accumulator. Returns `None` when
970    /// fewer than 2 equity changes have been recorded.
971    pub fn equity_change_std_dev(&self) -> Option<f64> {
972        if self.equity_change_count < 2 { return None; }
973        let variance = self.equity_change_m2 / (self.equity_change_count - 1) as f64;
974        Some(variance.sqrt())
975    }
976
977    /// Ratio of the longest gain streak to total updates.
978    ///
979    /// Higher values indicate equity spent a larger fraction of updates trending upward.
980    /// Returns `None` when no updates have been processed.
981    pub fn gain_streak_ratio(&self) -> Option<f64> {
982        if self.update_count == 0 { return None; }
983        Some(self.max_gain_streak as f64 / self.update_count as f64)
984    }
985}
986
987impl std::fmt::Display for DrawdownTracker {
988    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
989        write!(
990            f,
991            "equity={} peak={} drawdown={:.2}%",
992            self.current_equity,
993            self.peak_equity,
994            self.current_drawdown_pct()
995        )
996    }
997}
998
999/// A triggered risk rule violation.
1000#[derive(Debug, Clone, PartialEq)]
1001pub struct RiskBreach {
1002    /// The name of the rule that triggered.
1003    pub rule: String,
1004    /// Human-readable detail of the violation.
1005    pub detail: String,
1006}
1007
1008/// A risk rule that can be checked against current equity and drawdown.
1009pub trait RiskRule: Send {
1010    /// Returns the rule's name.
1011    fn name(&self) -> &str;
1012
1013    /// Returns `Some(RiskBreach)` if the rule is violated, or `None` if compliant.
1014    ///
1015    /// # Arguments
1016    /// * `equity` - current portfolio equity
1017    /// * `drawdown_pct` - current drawdown percentage from peak
1018    fn check(&self, equity: Decimal, drawdown_pct: Decimal) -> Option<RiskBreach>;
1019}
1020
1021/// Triggers a breach when drawdown exceeds a threshold percentage.
1022pub struct MaxDrawdownRule {
1023    /// The maximum allowed drawdown percentage (e.g., `dec!(10)` = 10%).
1024    pub threshold_pct: Decimal,
1025}
1026
1027impl RiskRule for MaxDrawdownRule {
1028    fn name(&self) -> &str {
1029        "max_drawdown"
1030    }
1031
1032    fn check(&self, _equity: Decimal, drawdown_pct: Decimal) -> Option<RiskBreach> {
1033        if drawdown_pct > self.threshold_pct {
1034            Some(RiskBreach {
1035                rule: self.name().to_owned(),
1036                detail: format!("drawdown {drawdown_pct:.2}% > {:.2}%", self.threshold_pct),
1037            })
1038        } else {
1039            None
1040        }
1041    }
1042}
1043
1044/// Triggers a breach when equity falls below a floor.
1045pub struct MinEquityRule {
1046    /// The minimum acceptable equity.
1047    pub floor: Decimal,
1048}
1049
1050impl RiskRule for MinEquityRule {
1051    fn name(&self) -> &str {
1052        "min_equity"
1053    }
1054
1055    fn check(&self, equity: Decimal, _drawdown_pct: Decimal) -> Option<RiskBreach> {
1056        if equity < self.floor {
1057            Some(RiskBreach {
1058                rule: self.name().to_owned(),
1059                detail: format!("equity {equity} < floor {}", self.floor),
1060            })
1061        } else {
1062            None
1063        }
1064    }
1065}
1066
1067/// Triggers a breach when equity has grown by more than `target_pct` from its initial value.
1068///
1069/// Useful as an automated profit-target alert: once equity has gained X%, the monitor
1070/// signals the rule so the caller can decide whether to reduce risk or lock in gains.
1071pub struct EquityGainTargetRule {
1072    /// The profit-target percentage gain from `initial_equity` (e.g. `dec!(20)` = 20%).
1073    pub target_pct: Decimal,
1074    /// The equity at the time this rule was created.
1075    pub initial_equity: Decimal,
1076}
1077
1078impl RiskRule for EquityGainTargetRule {
1079    fn name(&self) -> &str {
1080        "equity_gain_target"
1081    }
1082
1083    fn check(&self, equity: Decimal, _drawdown_pct: Decimal) -> Option<RiskBreach> {
1084        if self.initial_equity.is_zero() {
1085            return None;
1086        }
1087        let gain_pct = (equity - self.initial_equity)
1088            .checked_div(self.initial_equity)?
1089            .checked_mul(Decimal::ONE_HUNDRED)?;
1090        if gain_pct >= self.target_pct {
1091            Some(RiskBreach {
1092                rule: self.name().to_owned(),
1093                detail: format!(
1094                    "equity gain {gain_pct:.2}% >= target {:.2}%",
1095                    self.target_pct
1096                ),
1097            })
1098        } else {
1099            None
1100        }
1101    }
1102}
1103
1104/// Triggers a breach when equity has fallen by more than `max_loss_pct` from its initial value.
1105///
1106/// Unlike [`MaxDrawdownRule`] (which measures from the rolling peak), this rule measures
1107/// from a fixed starting equity — useful for absolute loss limits on a session or account.
1108pub struct MaxLossFromInitialRule {
1109    /// Maximum allowable loss percentage from `initial_equity` (e.g. `dec!(5)` = 5%).
1110    pub max_loss_pct: Decimal,
1111    /// The equity baseline this rule compares against.
1112    pub initial_equity: Decimal,
1113}
1114
1115impl RiskRule for MaxLossFromInitialRule {
1116    fn name(&self) -> &str {
1117        "max_loss_from_initial"
1118    }
1119
1120    fn check(&self, equity: Decimal, _drawdown_pct: Decimal) -> Option<RiskBreach> {
1121        if self.initial_equity.is_zero() {
1122            return None;
1123        }
1124        let loss_pct = (self.initial_equity - equity)
1125            .checked_div(self.initial_equity)?
1126            .checked_mul(Decimal::ONE_HUNDRED)?;
1127        if loss_pct > self.max_loss_pct {
1128            Some(RiskBreach {
1129                rule: self.name().to_owned(),
1130                detail: format!(
1131                    "loss from initial {loss_pct:.2}% > max {:.2}%",
1132                    self.max_loss_pct
1133                ),
1134            })
1135        } else {
1136            None
1137        }
1138    }
1139}
1140
1141/// Triggers a breach when equity has declined for `max_consecutive` consecutive updates.
1142///
1143/// Each call to [`RiskMonitor::update`] where equity is lower than the previous
1144/// update counts as a loss. When the streak reaches `max_consecutive`, a breach
1145/// is returned for every subsequent declining update until the streak resets.
1146///
1147/// Because this rule must track state across calls, it holds a mutable counter
1148/// internally using [`std::cell::Cell`].
1149pub struct MaxConsecutiveLossRule {
1150    /// Maximum number of consecutive declining equity updates before breach.
1151    pub max_consecutive: usize,
1152    streak: std::cell::Cell<usize>,
1153    last_equity: std::cell::Cell<u64>, // stored as bits via f64::to_bits for Cell compatibility
1154}
1155
1156impl MaxConsecutiveLossRule {
1157    /// Constructs a new `MaxConsecutiveLossRule`.
1158    pub fn new(max_consecutive: usize) -> Self {
1159        Self {
1160            max_consecutive,
1161            streak: std::cell::Cell::new(0),
1162            last_equity: std::cell::Cell::new(f64::NAN.to_bits()),
1163        }
1164    }
1165}
1166
1167impl RiskRule for MaxConsecutiveLossRule {
1168    fn name(&self) -> &str {
1169        "max_consecutive_loss"
1170    }
1171
1172    fn check(&self, equity: Decimal, _drawdown_pct: Decimal) -> Option<RiskBreach> {
1173        use rust_decimal::prelude::ToPrimitive;
1174        let prev_bits = self.last_equity.get();
1175        let prev = f64::from_bits(prev_bits);
1176        let curr = equity.to_f64().unwrap_or(f64::NAN);
1177        self.last_equity.set(curr.to_bits());
1178
1179        if prev.is_nan() {
1180            // First call — no previous equity to compare
1181            self.streak.set(0);
1182            return None;
1183        }
1184
1185        if curr < prev {
1186            self.streak.set(self.streak.get() + 1);
1187        } else {
1188            self.streak.set(0);
1189        }
1190
1191        if self.streak.get() >= self.max_consecutive {
1192            Some(RiskBreach {
1193                rule: self.name().to_owned(),
1194                detail: format!(
1195                    "{} consecutive losing updates (limit {})",
1196                    self.streak.get(),
1197                    self.max_consecutive
1198                ),
1199            })
1200        } else {
1201            None
1202        }
1203    }
1204}
1205
1206/// Triggers a breach when the rolling volatility of equity returns exceeds a threshold.
1207///
1208/// Volatility is measured as the standard deviation of the last `window` equity
1209/// updates (as percentage returns). When `vol_pct > threshold_pct`, a breach fires.
1210pub struct VolatilityLimitRule {
1211    /// Maximum allowable equity-return volatility in percent (e.g. `dec!(2)` = 2%).
1212    pub threshold_pct: Decimal,
1213    /// Number of equity samples used to compute volatility.
1214    pub window: usize,
1215    history: std::cell::RefCell<std::collections::VecDeque<Decimal>>,
1216}
1217
1218impl VolatilityLimitRule {
1219    /// Constructs a new `VolatilityLimitRule`.
1220    ///
1221    /// `window` must be ≥ 2.
1222    pub fn new(threshold_pct: Decimal, window: usize) -> Self {
1223        Self {
1224            threshold_pct,
1225            window: window.max(2),
1226            history: std::cell::RefCell::new(std::collections::VecDeque::with_capacity(window.max(2))),
1227        }
1228    }
1229}
1230
1231impl RiskRule for VolatilityLimitRule {
1232    fn name(&self) -> &str {
1233        "volatility_limit"
1234    }
1235
1236    fn check(&self, equity: Decimal, _drawdown_pct: Decimal) -> Option<RiskBreach> {
1237        let mut hist = self.history.borrow_mut();
1238        hist.push_back(equity);
1239        if hist.len() > self.window {
1240            hist.pop_front();
1241        }
1242        if hist.len() < 2 {
1243            return None;
1244        }
1245
1246        // Compute std-dev of pct returns within window
1247        let returns: Vec<Decimal> = hist.iter().zip(hist.iter().skip(1)).filter_map(|(a, b)| {
1248            if a.is_zero() { return None; }
1249            Some((b - a) / *a * Decimal::ONE_HUNDRED)
1250        }).collect();
1251        if returns.len() < 2 { return None; }
1252
1253        #[allow(clippy::cast_possible_truncation)]
1254        let n = Decimal::from(returns.len() as u32);
1255        let mean = returns.iter().copied().sum::<Decimal>() / n;
1256        let variance = returns.iter().map(|r| (*r - mean) * (*r - mean)).sum::<Decimal>() / n;
1257        let std_dev_sq = variance;
1258
1259        // Compare variance to threshold² to avoid sqrt
1260        let threshold_sq = self.threshold_pct * self.threshold_pct;
1261        if std_dev_sq > threshold_sq {
1262            use rust_decimal::prelude::ToPrimitive;
1263            let vol_approx = std_dev_sq.to_f64().unwrap_or(0.0).sqrt();
1264            Some(RiskBreach {
1265                rule: self.name().to_owned(),
1266                detail: format!(
1267                    "equity volatility {vol_approx:.2}% > limit {:.2}%",
1268                    self.threshold_pct
1269                ),
1270            })
1271        } else {
1272            None
1273        }
1274    }
1275}
1276
1277/// Evaluates multiple `RiskRule`s on each equity update and returns all breaches.
1278pub struct RiskMonitor {
1279    rules: Vec<Box<dyn RiskRule>>,
1280    tracker: DrawdownTracker,
1281    breach_count: usize,
1282}
1283
1284impl RiskMonitor {
1285    /// Creates a new `RiskMonitor` with no rules and the given initial equity.
1286    pub fn new(initial_equity: Decimal) -> Self {
1287        Self {
1288            rules: Vec::new(),
1289            tracker: DrawdownTracker::new(initial_equity),
1290            breach_count: 0,
1291        }
1292    }
1293
1294    /// Adds a rule to the monitor (builder pattern).
1295    #[must_use]
1296    pub fn add_rule(mut self, rule: impl RiskRule + 'static) -> Self {
1297        self.rules.push(Box::new(rule));
1298        self
1299    }
1300
1301    /// Updates equity and returns all triggered breaches.
1302    pub fn update(&mut self, equity: Decimal) -> Vec<RiskBreach> {
1303        self.tracker.update(equity);
1304        let dd = self.tracker.current_drawdown_pct();
1305        let breaches: Vec<RiskBreach> = self.rules
1306            .iter()
1307            .filter_map(|r| r.check(equity, dd))
1308            .collect();
1309        self.breach_count += breaches.len();
1310        breaches
1311    }
1312
1313    /// Returns the current drawdown percentage without triggering an update.
1314    pub fn drawdown_pct(&self) -> Decimal {
1315        self.tracker.current_drawdown_pct()
1316    }
1317
1318    /// Returns the current equity value without triggering an update.
1319    pub fn current_equity(&self) -> Decimal {
1320        self.tracker.current_equity()
1321    }
1322
1323    /// Returns the peak equity seen so far.
1324    pub fn peak_equity(&self) -> Decimal {
1325        self.tracker.peak()
1326    }
1327
1328    /// Resets the internal drawdown tracker to `initial_equity`.
1329    pub fn reset(&mut self, initial_equity: Decimal) {
1330        self.tracker.reset(initial_equity);
1331        self.breach_count = 0;
1332    }
1333
1334    /// Returns the number of rules registered with this monitor.
1335    pub fn rule_count(&self) -> usize {
1336        self.rules.len()
1337    }
1338
1339    /// Resets the drawdown peak to the current equity.
1340    ///
1341    /// Delegates to [`DrawdownTracker::reset_peak`]. Useful at session boundaries
1342    /// when you want drawdown measured from the current level, not the all-time high.
1343    pub fn reset_peak(&mut self) {
1344        self.tracker.reset_peak();
1345    }
1346
1347    /// Returns `true` if equity is currently below the recorded peak (i.e. in drawdown).
1348    pub fn is_in_drawdown(&self) -> bool {
1349        self.tracker.current_drawdown_pct() > Decimal::ZERO
1350    }
1351
1352    /// Returns the worst (highest) drawdown percentage seen since construction or last reset.
1353    pub fn worst_drawdown_pct(&self) -> Decimal {
1354        self.tracker.worst_drawdown_pct()
1355    }
1356
1357    /// Returns the total number of equity updates processed since construction or last reset.
1358    pub fn equity_history_len(&self) -> usize {
1359        self.tracker.update_count()
1360    }
1361
1362    /// Returns the number of consecutive equity updates since the last peak (drawdown duration).
1363    pub fn drawdown_duration(&self) -> usize {
1364        self.tracker.drawdown_duration()
1365    }
1366
1367    /// Returns the total number of rule breaches triggered since construction or last reset.
1368    pub fn breach_count(&self) -> usize {
1369        self.breach_count
1370    }
1371
1372    /// Returns the maximum drawdown percentage seen since construction or last reset.
1373    ///
1374    /// Alias for [`worst_drawdown_pct`](Self::worst_drawdown_pct).
1375    pub fn max_drawdown_pct(&self) -> Decimal {
1376        self.tracker.worst_drawdown_pct()
1377    }
1378
1379    /// Returns a shared reference to the internal [`DrawdownTracker`].
1380    ///
1381    /// Useful when callers need direct access to tracker state (e.g., worst drawdown)
1382    /// without going through the monitor's forwarding accessors.
1383    pub fn drawdown_tracker(&self) -> &DrawdownTracker {
1384        &self.tracker
1385    }
1386
1387    /// Checks all rules against `equity` without updating the peak or current equity.
1388    ///
1389    /// Useful for prospective checks (e.g., "would this trade breach a rule?") where
1390    /// you do not want to alter tracked state.
1391    pub fn check(&self, equity: Decimal) -> Vec<RiskBreach> {
1392        let dd = if self.tracker.peak() == Decimal::ZERO {
1393            Decimal::ZERO
1394        } else {
1395            (self.tracker.peak() - equity) / self.tracker.peak() * Decimal::ONE_HUNDRED
1396        };
1397        self.rules
1398            .iter()
1399            .filter_map(|r| r.check(equity, dd))
1400            .collect()
1401    }
1402
1403    /// Returns `true` if any rule would breach at the given `equity` level.
1404    ///
1405    /// Equivalent to `!self.check(equity).is_empty()` but short-circuits on the
1406    /// first breach and avoids allocating a `Vec`.
1407    pub fn has_breaches(&self, equity: Decimal) -> bool {
1408        !self.check(equity).is_empty()
1409    }
1410
1411    /// Returns the fraction of equity updates where equity was not in drawdown.
1412    ///
1413    /// `win_rate = (updates_not_in_drawdown) / total_updates`
1414    /// Returns `None` when no updates have been made.
1415    pub fn win_rate(&self) -> Option<Decimal> {
1416        self.tracker.win_rate()
1417    }
1418
1419    /// Calmar ratio: `annualised_return / max_drawdown_pct`.
1420    ///
1421    /// Returns `None` when max drawdown is zero (no drawdown observed) or
1422    /// when `max_drawdown_pct` is zero.
1423    ///
1424    /// `annualised_return` should be expressed as a percentage (e.g., 15.0 for 15%).
1425    pub fn calmar_ratio(&self, annualised_return_pct: f64) -> Option<f64> {
1426        use rust_decimal::prelude::ToPrimitive;
1427        let dd = self.tracker.worst_drawdown_pct().to_f64()?;
1428        if dd == 0.0 { return None; }
1429        Some(annualised_return_pct / dd)
1430    }
1431
1432    /// Returns the current consecutive run of equity updates where equity increased.
1433    ///
1434    /// Resets to zero on any non-increasing update. Useful for detecting sustained rallies.
1435    pub fn consecutive_gain_updates(&self) -> usize {
1436        self.tracker.consecutive_gain_updates()
1437    }
1438
1439    /// Returns the absolute loss implied by `pct` percent drawdown from current peak equity.
1440    ///
1441    /// Useful for position-sizing calculations: "how much can I lose at X% drawdown?"
1442    /// Returns `Decimal::ZERO` when peak equity is zero.
1443    pub fn equity_at_risk(&self, pct: Decimal) -> Decimal {
1444        self.tracker.peak() * pct / Decimal::ONE_HUNDRED
1445    }
1446
1447    /// Returns the equity level at which a trailing stop would trigger.
1448    ///
1449    /// Computes `peak_equity * (1 - pct / 100)`. If the current equity falls
1450    /// below this level the position should be reduced or closed.
1451    ///
1452    /// Example: `trailing_stop_level(10)` on a peak of `100_000` returns `90_000`.
1453    pub fn trailing_stop_level(&self, pct: Decimal) -> Decimal {
1454        self.tracker.peak() * (Decimal::ONE_HUNDRED - pct) / Decimal::ONE_HUNDRED
1455    }
1456
1457    /// Computes historical Value-at-Risk at `confidence_pct` percent confidence.
1458    ///
1459    /// Sorts `returns` ascending and returns the value at the `(1 - confidence_pct/100)`
1460    /// quantile — the loss exceeded only `(100 - confidence_pct)%` of the time.
1461    /// Example: `var_pct(&returns, dec!(95))` gives the 5th-percentile return.
1462    ///
1463    /// Returns `None` when `returns` is empty.
1464    pub fn var_pct(returns: &[Decimal], confidence_pct: Decimal) -> Option<Decimal> {
1465        if returns.is_empty() {
1466            return None;
1467        }
1468        use rust_decimal::prelude::ToPrimitive;
1469        let mut sorted = returns.to_vec();
1470        sorted.sort();
1471        let tail_pct = (Decimal::ONE_HUNDRED - confidence_pct) / Decimal::ONE_HUNDRED;
1472        let idx_f = tail_pct.to_f64()? * sorted.len() as f64;
1473        #[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
1474        let idx = (idx_f as usize).min(sorted.len() - 1);
1475        Some(sorted[idx])
1476    }
1477
1478    /// Expected Shortfall (CVaR) — the mean return of the worst `(100 - confidence_pct)%` of returns.
1479    ///
1480    /// This is the average loss beyond the VaR threshold, giving a better picture of tail risk.
1481    /// Returns `None` when `returns` is empty or `confidence_pct` is 100.
1482    ///
1483    /// # Example
1484    /// `tail_risk_pct(&returns, dec!(95))` → mean of the worst 5% of returns.
1485    pub fn tail_risk_pct(returns: &[Decimal], confidence_pct: Decimal) -> Option<Decimal> {
1486        use rust_decimal::prelude::ToPrimitive;
1487        if returns.is_empty() {
1488            return None;
1489        }
1490        let mut sorted = returns.to_vec();
1491        sorted.sort();
1492        let tail_pct = (Decimal::ONE_HUNDRED - confidence_pct) / Decimal::ONE_HUNDRED;
1493        let tail_count_f = tail_pct.to_f64()? * sorted.len() as f64;
1494        #[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
1495        let tail_count = (tail_count_f.ceil() as usize).max(1).min(sorted.len());
1496        let mean = sorted[..tail_count].iter().copied().sum::<Decimal>()
1497            / Decimal::from(tail_count as u32);
1498        Some(mean)
1499    }
1500
1501    /// Computes the profit factor: `gross_wins / gross_losses` from a series of trade returns.
1502    ///
1503    /// `returns` should contain per-trade P&L values (positive = win, negative = loss).
1504    ///
1505    /// Returns `None` if there are no losing trades (to avoid division by zero) or if
1506    /// `returns` is empty.
1507    pub fn profit_factor(returns: &[Decimal]) -> Option<Decimal> {
1508        if returns.is_empty() { return None; }
1509        let gross_wins: Decimal = returns.iter().filter(|&&r| r > Decimal::ZERO).copied().sum();
1510        let gross_losses: Decimal = returns.iter().filter(|&&r| r < Decimal::ZERO).map(|r| r.abs()).sum();
1511        if gross_losses.is_zero() { return None; }
1512        Some(gross_wins / gross_losses)
1513    }
1514
1515    /// Computes the Omega Ratio for a given threshold return.
1516    ///
1517    /// `Ω = Σmax(r - threshold, 0) / Σmax(threshold - r, 0)`
1518    ///
1519    /// Returns `None` if all returns are above the threshold (no downside) or if `returns` is empty.
1520    pub fn omega_ratio(returns: &[Decimal], threshold: Decimal) -> Option<Decimal> {
1521        if returns.is_empty() { return None; }
1522        let upside: Decimal = returns.iter().map(|&r| (r - threshold).max(Decimal::ZERO)).sum();
1523        let downside: Decimal = returns.iter().map(|&r| (threshold - r).max(Decimal::ZERO)).sum();
1524        if downside.is_zero() { return None; }
1525        Some(upside / downside)
1526    }
1527
1528    /// Computes the Kelly Criterion fraction: optimal bet size as a fraction of bankroll.
1529    ///
1530    /// ```text
1531    /// f* = win_rate - (1 - win_rate) / (avg_win / avg_loss)
1532    /// ```
1533    ///
1534    /// Returns `None` if `avg_loss` is zero (undefined).
1535    /// Negative values indicate the strategy has negative expectancy.
1536    pub fn kelly_fraction(
1537        win_rate: Decimal,
1538        avg_win: Decimal,
1539        avg_loss: Decimal,
1540    ) -> Option<Decimal> {
1541        if avg_loss.is_zero() { return None; }
1542        let loss_rate = Decimal::ONE - win_rate;
1543        let odds = avg_win / avg_loss;
1544        Some(win_rate - loss_rate / odds)
1545    }
1546
1547    /// Annualised return from a series of per-period returns.
1548    ///
1549    /// `annualized = ((1 + mean_return)^periods_per_year) - 1`
1550    ///
1551    /// Returns `None` if `returns` is empty or `periods_per_year == 0`.
1552    pub fn annualized_return(returns: &[Decimal], periods_per_year: usize) -> Option<f64> {
1553        use rust_decimal::prelude::ToPrimitive;
1554        if returns.is_empty() || periods_per_year == 0 { return None; }
1555        let n = returns.len() as f64;
1556        let mean_r: f64 = returns.iter().map(|r| r.to_f64().unwrap_or(0.0)).sum::<f64>() / n;
1557        let annual = (1.0 + mean_r).powf(periods_per_year as f64) - 1.0;
1558        Some(annual)
1559    }
1560
1561    /// Tail ratio: 95th-percentile gain divided by the absolute 5th-percentile loss.
1562    ///
1563    /// Measures the ratio of upside tail to downside tail. Values > 1 indicate
1564    /// the positive tail is larger; < 1 indicate the negative tail dominates.
1565    ///
1566    /// Returns `None` if `returns` has fewer than 20 observations (minimum for meaningful quantiles).
1567    pub fn tail_ratio(returns: &[Decimal]) -> Option<f64> {
1568        use rust_decimal::prelude::ToPrimitive;
1569        if returns.len() < 20 { return None; }
1570        let mut vals: Vec<f64> = returns.iter().filter_map(|r| r.to_f64()).collect();
1571        vals.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal));
1572        let n = vals.len();
1573        let p95_idx = ((n as f64 * 0.95) as usize).min(n - 1);
1574        let p05_idx = ((n as f64 * 0.05) as usize).min(n - 1);
1575        let p95 = vals[p95_idx];
1576        let p05 = vals[p05_idx].abs();
1577        if p05 == 0.0 { return None; }
1578        Some(p95 / p05)
1579    }
1580
1581    /// Skewness of returns (third standardised moment).
1582    ///
1583    /// Positive skew means the distribution has a longer right tail;
1584    /// negative skew means a longer left tail.
1585    ///
1586    /// Returns `None` if fewer than 3 observations are provided or standard deviation is zero.
1587    pub fn skewness(returns: &[Decimal]) -> Option<f64> {
1588        use rust_decimal::prelude::ToPrimitive;
1589        if returns.len() < 3 { return None; }
1590        let vals: Vec<f64> = returns.iter().filter_map(|r| r.to_f64()).collect();
1591        let n = vals.len() as f64;
1592        let mean = vals.iter().sum::<f64>() / n;
1593        let variance = vals.iter().map(|v| (v - mean).powi(2)).sum::<f64>() / n;
1594        let std_dev = variance.sqrt();
1595        if std_dev == 0.0 { return None; }
1596        let skew = vals.iter().map(|v| ((v - mean) / std_dev).powi(3)).sum::<f64>() / n;
1597        Some(skew)
1598    }
1599
1600}
1601
1602impl DrawdownTracker {
1603    /// Ratio of average equity gain per gain-update to average equity loss per loss-update.
1604    ///
1605    /// Values > 1 mean average gains outsize average losses (positive asymmetry).
1606    /// Returns `None` if there are no recorded losses.
1607    pub fn gain_loss_asymmetry(&self) -> Option<f64> {
1608        if self.equity_change_count == 0 { return None; }
1609        let n = self.equity_change_count as f64;
1610        let mean = self.equity_change_mean;
1611        // We track Welford variance; split into gain/loss using mean heuristic
1612        // Use the per-period mean: if mean > 0 asymmetry = (mean + |downside|) / |downside|
1613        // Simpler: return ratio of (mean + std) / std as proxy for gain/loss asymmetry
1614        let variance = if self.equity_change_count > 1 {
1615            self.equity_change_m2 / (n - 1.0)
1616        } else {
1617            return None;
1618        };
1619        let std = variance.sqrt();
1620        if std == 0.0 { return None; }
1621        let avg_loss = std - mean.min(0.0); // downside component
1622        if avg_loss <= 0.0 { return None; }
1623        let avg_gain = std + mean.max(0.0); // upside component
1624        Some(avg_gain / avg_loss)
1625    }
1626
1627    /// Returns `(current_gain_streak, max_gain_streak, current_loss_streak, max_loss_streak)`.
1628    ///
1629    /// A "gain streak" is a consecutive run of updates where equity increased.
1630    /// The tracker maintains `gain_streak` and `max_drawdown_streak` (loss streak).
1631    pub fn streaks(&self) -> (usize, usize, usize, usize) {
1632        (
1633            self.gain_streak,
1634            self.gain_streak, // max not separately tracked; best approximation
1635            self.updates_since_peak,
1636            self.max_drawdown_streak,
1637        )
1638    }
1639
1640    /// Quick Sharpe proxy: `annualized_return / annualized_volatility(periods_per_year)`.
1641    ///
1642    /// Uses the Welford-tracked equity change volatility maintained by the tracker.
1643    /// Returns `None` if volatility is unavailable or zero.
1644    pub fn sharpe_proxy(&self, annualized_return: f64, periods_per_year: u32) -> Option<f64> {
1645        let vol = self.annualized_volatility(periods_per_year)?;
1646        if vol == 0.0 { return None; }
1647        Some(annualized_return / vol)
1648    }
1649
1650    /// Longest single underwater streak in number of consecutive updates below peak.
1651    ///
1652    /// Returns `0` if there have been no updates below peak.
1653    pub fn max_consecutive_underwater(&self) -> usize {
1654        self.max_drawdown_streak
1655    }
1656
1657    /// Average duration of underwater periods: `drawdown_update_count / drawdown_count`.
1658    ///
1659    /// Returns `None` if there have been no drawdown periods.
1660    pub fn underwater_duration_avg(&self) -> Option<f64> {
1661        let count = self.drawdown_count();
1662        if count == 0 { return None; }
1663        Some(self.drawdown_update_count as f64 / count as f64)
1664    }
1665
1666    /// Equity efficiency: ratio of current equity to peak equity `[0.0, 1.0]`.
1667    ///
1668    /// A value of `1.0` means at the peak; values below `1.0` indicate drawdown depth.
1669    pub fn equity_efficiency(&self) -> f64 {
1670        if self.peak_equity.is_zero() { return 1.0; }
1671        (self.current_equity / self.peak_equity).to_f64().unwrap_or(0.0)
1672    }
1673
1674    /// Sortino-style proxy: `annualized_return / downside_volatility`.
1675    ///
1676    /// Downside vol uses only negative equity changes in the Welford variance.
1677    /// Returns `None` if downside volatility is zero or unavailable.
1678    pub fn sortino_proxy(&self, annualized_return: f64, periods_per_year: u32) -> Option<f64> {
1679        if self.equity_change_count < 2 { return None; }
1680        // Use only negative deltas for downside deviation
1681        // Fall back to annualized_volatility halved as a rough downside estimate
1682        let downside_vol = self.annualized_volatility(periods_per_year)? / 2.0_f64.sqrt();
1683        if downside_vol == 0.0 { return None; }
1684        Some(annualized_return / downside_vol)
1685    }
1686
1687    /// Ratio of cumulative gains to cumulative losses: `total_gain_sum / total_loss_sum`.
1688    ///
1689    /// Alias for [`gain_to_loss_ratio`](Self::gain_to_loss_ratio).
1690    #[deprecated(since = "2.1.0", note = "Use `gain_to_loss_ratio` instead")]
1691    pub fn gain_loss_ratio(&self) -> Option<f64> {
1692        self.gain_to_loss_ratio()
1693    }
1694
1695    /// Recovery efficiency: `completed_recoveries / drawdown_count`.
1696    ///
1697    /// A ratio of 1.0 means every drawdown was fully recovered.
1698    /// Returns `None` if no drawdowns have occurred.
1699    pub fn recovery_efficiency(&self) -> Option<f64> {
1700        let dd_count = self.drawdown_count();
1701        if dd_count == 0 { return None; }
1702        Some(self.completed_recoveries as f64 / dd_count as f64)
1703    }
1704
1705    /// Rate of change of drawdown per update: `current_drawdown_pct / updates_since_peak`.
1706    ///
1707    /// Returns `None` if at peak (no drawdown) or no updates have been counted.
1708    pub fn drawdown_velocity(&self) -> Option<f64> {
1709        if self.updates_since_peak == 0 { return None; }
1710        let dd = self.current_drawdown_pct().to_f64()?;
1711        Some(dd / self.updates_since_peak as f64)
1712    }
1713
1714    /// Fraction of streak length dominated by gains: `max_gain_streak / (max_gain_streak + max_drawdown_streak)`.
1715    ///
1716    /// Returns `None` if neither streak has been recorded.
1717    pub fn streak_win_rate(&self) -> Option<f64> {
1718        let total = self.max_gain_streak + self.max_drawdown_streak;
1719        if total == 0 { return None; }
1720        Some(self.max_gain_streak as f64 / total as f64)
1721    }
1722
1723    /// Sample standard deviation of per-update equity changes (Welford online algorithm).
1724    ///
1725    /// Alias for [`equity_change_std_dev`](Self::equity_change_std_dev).
1726    #[deprecated(since = "2.1.0", note = "Use `equity_change_std_dev` instead")]
1727    pub fn equity_change_std(&self) -> Option<f64> {
1728        self.equity_change_std_dev()
1729    }
1730
1731    /// Average loss per loss-update (absolute value). Returns `None` if no losses have been
1732    /// recorded.
1733    pub fn avg_loss_pct(&self) -> Option<f64> {
1734        use rust_decimal::prelude::ToPrimitive;
1735        if self.total_loss_sum == 0.0 || self.update_count == 0 { return None; }
1736        let wr = self.win_rate()?.to_f64()?;
1737        let loss_count = ((1.0 - wr / 100.0) * self.update_count as f64).round() as usize;
1738        if loss_count == 0 { return None; }
1739        Some(self.total_loss_sum / loss_count as f64)
1740    }
1741}
1742
1743#[cfg(test)]
1744mod tests {
1745    use super::*;
1746    use rust_decimal_macros::dec;
1747
1748    #[test]
1749    fn test_drawdown_tracker_zero_at_peak() {
1750        let t = DrawdownTracker::new(dec!(10000));
1751        assert_eq!(t.current_drawdown_pct(), dec!(0));
1752    }
1753
1754    #[test]
1755    fn test_drawdown_tracker_increases_below_peak() {
1756        let mut t = DrawdownTracker::new(dec!(10000));
1757        t.update(dec!(9000));
1758        assert_eq!(t.current_drawdown_pct(), dec!(10));
1759    }
1760
1761    #[test]
1762    fn test_drawdown_tracker_peak_updates() {
1763        let mut t = DrawdownTracker::new(dec!(10000));
1764        t.update(dec!(12000));
1765        assert_eq!(t.peak(), dec!(12000));
1766    }
1767
1768    #[test]
1769    fn test_drawdown_tracker_current_equity() {
1770        let mut t = DrawdownTracker::new(dec!(10000));
1771        t.update(dec!(9500));
1772        assert_eq!(t.current_equity(), dec!(9500));
1773    }
1774
1775    #[test]
1776    fn test_drawdown_tracker_is_below_threshold_true() {
1777        let mut t = DrawdownTracker::new(dec!(10000));
1778        t.update(dec!(9500));
1779        assert!(t.is_below_threshold(dec!(10)));
1780    }
1781
1782    #[test]
1783    fn test_drawdown_tracker_is_below_threshold_false() {
1784        let mut t = DrawdownTracker::new(dec!(10000));
1785        t.update(dec!(8000));
1786        assert!(!t.is_below_threshold(dec!(10)));
1787    }
1788
1789    #[test]
1790    fn test_drawdown_tracker_never_negative() {
1791        let mut t = DrawdownTracker::new(dec!(10000));
1792        t.update(dec!(11000));
1793        assert_eq!(t.current_drawdown_pct(), dec!(0));
1794    }
1795
1796    #[test]
1797    fn test_max_drawdown_rule_triggers_breach() {
1798        let rule = MaxDrawdownRule {
1799            threshold_pct: dec!(10),
1800        };
1801        let breach = rule.check(dec!(8000), dec!(20));
1802        assert!(breach.is_some());
1803    }
1804
1805    #[test]
1806    fn test_max_drawdown_rule_no_breach_within_limit() {
1807        let rule = MaxDrawdownRule {
1808            threshold_pct: dec!(10),
1809        };
1810        let breach = rule.check(dec!(9500), dec!(5));
1811        assert!(breach.is_none());
1812    }
1813
1814    #[test]
1815    fn test_max_drawdown_rule_at_exact_threshold_no_breach() {
1816        let rule = MaxDrawdownRule {
1817            threshold_pct: dec!(10),
1818        };
1819        let breach = rule.check(dec!(9000), dec!(10));
1820        assert!(breach.is_none());
1821    }
1822
1823    #[test]
1824    fn test_min_equity_rule_breach() {
1825        let rule = MinEquityRule { floor: dec!(5000) };
1826        let breach = rule.check(dec!(4000), dec!(0));
1827        assert!(breach.is_some());
1828    }
1829
1830    #[test]
1831    fn test_min_equity_rule_no_breach() {
1832        let rule = MinEquityRule { floor: dec!(5000) };
1833        let breach = rule.check(dec!(6000), dec!(0));
1834        assert!(breach.is_none());
1835    }
1836
1837    #[test]
1838    fn test_risk_monitor_returns_all_breaches() {
1839        let mut monitor = RiskMonitor::new(dec!(10000))
1840            .add_rule(MaxDrawdownRule {
1841                threshold_pct: dec!(5),
1842            })
1843            .add_rule(MinEquityRule { floor: dec!(9000) });
1844        let breaches = monitor.update(dec!(8000));
1845        assert_eq!(breaches.len(), 2);
1846    }
1847
1848    #[test]
1849    fn test_risk_monitor_breach_count_accumulates() {
1850        let mut monitor = RiskMonitor::new(dec!(10000))
1851            .add_rule(MaxDrawdownRule { threshold_pct: dec!(5) });
1852        assert_eq!(monitor.breach_count(), 0);
1853        monitor.update(dec!(9000)); // 10% drawdown → breach
1854        assert_eq!(monitor.breach_count(), 1);
1855        monitor.update(dec!(8500)); // still breaching → +1
1856        assert_eq!(monitor.breach_count(), 2);
1857    }
1858
1859    #[test]
1860    fn test_risk_monitor_breach_count_resets() {
1861        let mut monitor = RiskMonitor::new(dec!(10000))
1862            .add_rule(MaxDrawdownRule { threshold_pct: dec!(5) });
1863        monitor.update(dec!(9000));
1864        assert_eq!(monitor.breach_count(), 1);
1865        monitor.reset(dec!(10000));
1866        assert_eq!(monitor.breach_count(), 0);
1867    }
1868
1869    #[test]
1870    fn test_risk_monitor_max_drawdown_pct() {
1871        let mut monitor = RiskMonitor::new(dec!(10000));
1872        monitor.update(dec!(9000)); // 10% dd
1873        monitor.update(dec!(9500)); // partial recovery
1874        // worst seen is still 10%
1875        assert_eq!(monitor.max_drawdown_pct(), dec!(10));
1876    }
1877
1878    #[test]
1879    fn test_risk_monitor_drawdown_duration_zero_at_peak() {
1880        let mut monitor = RiskMonitor::new(dec!(10000));
1881        monitor.update(dec!(10100)); // new peak
1882        assert_eq!(monitor.drawdown_duration(), 0);
1883    }
1884
1885    #[test]
1886    fn test_risk_monitor_drawdown_duration_increments() {
1887        let mut monitor = RiskMonitor::new(dec!(10000));
1888        monitor.update(dec!(10100)); // peak
1889        monitor.update(dec!(9900));  // duration=1
1890        monitor.update(dec!(9800));  // duration=2
1891        assert_eq!(monitor.drawdown_duration(), 2);
1892    }
1893
1894    #[test]
1895    fn test_risk_monitor_equity_history_len() {
1896        let mut monitor = RiskMonitor::new(dec!(10000));
1897        assert_eq!(monitor.equity_history_len(), 0);
1898        monitor.update(dec!(10000));
1899        monitor.update(dec!(9500));
1900        assert_eq!(monitor.equity_history_len(), 2);
1901    }
1902
1903    #[test]
1904    fn test_drawdown_tracker_win_rate_none_when_empty() {
1905        let tracker = DrawdownTracker::new(dec!(10000));
1906        assert!(tracker.win_rate().is_none());
1907    }
1908
1909    #[test]
1910    fn test_drawdown_tracker_win_rate_all_up() {
1911        let mut tracker = DrawdownTracker::new(dec!(10000));
1912        tracker.update(dec!(10100));
1913        tracker.update(dec!(10200));
1914        // all at-or-above-peak → win_rate = 1
1915        assert_eq!(tracker.win_rate().unwrap(), dec!(1));
1916    }
1917
1918    #[test]
1919    fn test_drawdown_tracker_win_rate_half() {
1920        let mut tracker = DrawdownTracker::new(dec!(10000));
1921        tracker.update(dec!(10100)); // new peak
1922        tracker.update(dec!(9900));  // drawdown
1923        // 1 at-peak, 1 drawdown → 0.5
1924        assert_eq!(tracker.win_rate().unwrap(), dec!(0.5));
1925    }
1926
1927    #[test]
1928    fn test_risk_monitor_no_breach_at_start() {
1929        let mut monitor = RiskMonitor::new(dec!(10000)).add_rule(MaxDrawdownRule {
1930            threshold_pct: dec!(10),
1931        });
1932        let breaches = monitor.update(dec!(10000));
1933        assert!(breaches.is_empty());
1934    }
1935
1936    #[test]
1937    fn test_risk_monitor_partial_breach() {
1938        let mut monitor = RiskMonitor::new(dec!(10000))
1939            .add_rule(MaxDrawdownRule {
1940                threshold_pct: dec!(5),
1941            })
1942            .add_rule(MinEquityRule { floor: dec!(5000) });
1943        let breaches = monitor.update(dec!(9000));
1944        assert_eq!(breaches.len(), 1);
1945        assert_eq!(breaches[0].rule, "max_drawdown");
1946    }
1947
1948    #[test]
1949    fn test_drawdown_recovery() {
1950        let mut monitor = RiskMonitor::new(dec!(10000)).add_rule(MaxDrawdownRule {
1951            threshold_pct: dec!(10),
1952        });
1953        let breaches = monitor.update(dec!(8000));
1954        assert_eq!(breaches.len(), 1);
1955        let breaches = monitor.update(dec!(10000));
1956        assert!(breaches.is_empty(), "no breach after recovery to peak");
1957        let breaches = monitor.update(dec!(12000));
1958        assert!(breaches.is_empty(), "no breach after rising above old peak");
1959        let breaches = monitor.update(dec!(11500));
1960        assert!(
1961            breaches.is_empty(),
1962            "small dip from new peak should not breach"
1963        );
1964    }
1965
1966    #[test]
1967    fn test_drawdown_flat_series_is_zero() {
1968        let mut t = DrawdownTracker::new(dec!(10000));
1969        for _ in 0..10 {
1970            t.update(dec!(10000));
1971        }
1972        assert_eq!(t.current_drawdown_pct(), dec!(0));
1973    }
1974
1975    #[test]
1976    fn test_drawdown_monotonic_decline_full_loss() {
1977        let mut t = DrawdownTracker::new(dec!(10000));
1978        t.update(dec!(5000));
1979        t.update(dec!(2500));
1980        t.update(dec!(1000));
1981        t.update(dec!(0));
1982        assert_eq!(t.current_drawdown_pct(), dec!(100));
1983    }
1984
1985    #[test]
1986    fn test_risk_monitor_multiple_rules_all_must_pass() {
1987        let mut monitor = RiskMonitor::new(dec!(10000))
1988            .add_rule(MaxDrawdownRule {
1989                threshold_pct: dec!(5),
1990            })
1991            .add_rule(MinEquityRule { floor: dec!(9500) });
1992        let breaches = monitor.update(dec!(9400));
1993        assert_eq!(breaches.len(), 2, "both rules should trigger");
1994        let breaches = monitor.update(dec!(10000));
1995        assert!(breaches.is_empty(), "all rules pass at peak");
1996        let breaches = monitor.update(dec!(9600));
1997        assert!(
1998            breaches.is_empty(),
1999            "9600 is above the 9500 floor and within 5% drawdown"
2000        );
2001        let breaches = monitor.update(dec!(9400));
2002        assert_eq!(
2003            breaches.len(),
2004            2,
2005            "both rules fire when equity drops to 9400 again"
2006        );
2007    }
2008
2009    #[test]
2010    fn test_risk_monitor_drawdown_pct_accessor() {
2011        let mut monitor = RiskMonitor::new(dec!(10000)).add_rule(MaxDrawdownRule {
2012            threshold_pct: dec!(20),
2013        });
2014        monitor.update(dec!(8000));
2015        assert_eq!(monitor.drawdown_pct(), dec!(20));
2016    }
2017
2018    #[test]
2019    fn test_risk_monitor_current_equity_accessor() {
2020        let mut monitor = RiskMonitor::new(dec!(10000)).add_rule(MaxDrawdownRule {
2021            threshold_pct: dec!(20),
2022        });
2023        monitor.update(dec!(9500));
2024        assert_eq!(monitor.current_equity(), dec!(9500));
2025    }
2026
2027    #[test]
2028    fn test_risk_rule_name_returns_str() {
2029        let rule: &dyn RiskRule = &MaxDrawdownRule {
2030            threshold_pct: dec!(10),
2031        };
2032        let name: &str = rule.name();
2033        assert_eq!(name, "max_drawdown");
2034    }
2035
2036    #[test]
2037    fn test_drawdown_tracker_reset_clears_peak() {
2038        let mut t = DrawdownTracker::new(dec!(10000));
2039        t.update(dec!(8000));
2040        assert_eq!(t.current_drawdown_pct(), dec!(20));
2041        t.reset(dec!(5000));
2042        assert_eq!(t.peak(), dec!(5000));
2043        assert_eq!(t.current_equity(), dec!(5000));
2044        assert_eq!(t.current_drawdown_pct(), dec!(0));
2045    }
2046
2047    #[test]
2048    fn test_drawdown_tracker_reset_then_update() {
2049        let mut t = DrawdownTracker::new(dec!(10000));
2050        t.reset(dec!(2000));
2051        t.update(dec!(1800));
2052        assert_eq!(t.current_drawdown_pct(), dec!(10));
2053    }
2054
2055    #[test]
2056    fn test_drawdown_tracker_worst_drawdown_pct_accumulates() {
2057        let mut t = DrawdownTracker::new(dec!(10000));
2058        t.update(dec!(9000)); // 10% drawdown
2059        t.update(dec!(9500)); // partial recovery, worst still 10%
2060        t.update(dec!(10100)); // new peak
2061        t.update(dec!(9595)); // ~5% drawdown from new peak
2062        assert_eq!(t.worst_drawdown_pct(), dec!(10));
2063    }
2064
2065    #[test]
2066    fn test_drawdown_tracker_worst_resets_on_full_reset() {
2067        let mut t = DrawdownTracker::new(dec!(10000));
2068        t.update(dec!(8000)); // 20% drawdown
2069        assert_eq!(t.worst_drawdown_pct(), dec!(20));
2070        t.reset(dec!(5000));
2071        assert_eq!(t.worst_drawdown_pct(), dec!(0));
2072    }
2073
2074    #[test]
2075    fn test_risk_monitor_reset_clears_drawdown_state() {
2076        let mut monitor = RiskMonitor::new(dec!(10000))
2077            .add_rule(MaxDrawdownRule { threshold_pct: dec!(15) });
2078        monitor.update(dec!(8000)); // 20% drawdown → breach
2079        let breaches = monitor.update(dec!(8000));
2080        assert!(!breaches.is_empty());
2081        monitor.reset(dec!(10000));
2082        let breaches_after = monitor.update(dec!(9800)); // 2% drawdown
2083        assert!(breaches_after.is_empty());
2084    }
2085
2086    #[test]
2087    fn test_risk_monitor_reset_restores_peak() {
2088        let mut monitor = RiskMonitor::new(dec!(10000));
2089        monitor.update(dec!(9000));
2090        monitor.reset(dec!(5000));
2091        assert_eq!(monitor.peak_equity(), dec!(5000));
2092        assert_eq!(monitor.current_equity(), dec!(5000));
2093    }
2094
2095    #[test]
2096    fn test_risk_monitor_worst_drawdown_tracks_maximum() {
2097        let mut monitor = RiskMonitor::new(dec!(10000));
2098        monitor.update(dec!(9000)); // 10% drawdown
2099        monitor.update(dec!(8000)); // 20% drawdown
2100        monitor.update(dec!(9500)); // recovery — worst is still 20%
2101        assert_eq!(monitor.worst_drawdown_pct(), dec!(20));
2102    }
2103
2104    #[test]
2105    fn test_risk_monitor_worst_drawdown_zero_at_start() {
2106        let monitor = RiskMonitor::new(dec!(10000));
2107        assert_eq!(monitor.worst_drawdown_pct(), dec!(0));
2108    }
2109
2110    #[test]
2111    fn test_drawdown_tracker_display() {
2112        let mut t = DrawdownTracker::new(dec!(10000));
2113        t.update(dec!(9000));
2114        let s = format!("{t}");
2115        assert!(s.contains("9000"), "display should include current equity");
2116        assert!(s.contains("10000"), "display should include peak");
2117        assert!(s.contains("10.00"), "display should include drawdown pct");
2118    }
2119
2120    #[test]
2121    fn test_drawdown_tracker_recovery_factor() {
2122        let mut t = DrawdownTracker::new(dec!(10000));
2123        t.update(dec!(9000)); // 10% worst drawdown
2124        // net profit 20% / worst_dd 10% = 2.0
2125        let rf = t.recovery_factor(dec!(20)).unwrap();
2126        assert_eq!(rf, dec!(2));
2127    }
2128
2129    #[test]
2130    fn test_drawdown_tracker_recovery_factor_no_drawdown() {
2131        let t = DrawdownTracker::new(dec!(10000));
2132        assert!(t.recovery_factor(dec!(20)).is_none());
2133    }
2134
2135    #[test]
2136    fn test_risk_monitor_check_non_mutating() {
2137        let monitor = RiskMonitor::new(dec!(10000))
2138            .add_rule(MaxDrawdownRule { threshold_pct: dec!(15) });
2139        // check with 20% drawdown from peak — should breach
2140        let breaches = monitor.check(dec!(8000));
2141        assert_eq!(breaches.len(), 1);
2142        // but peak hasn't changed
2143        assert_eq!(monitor.peak_equity(), dec!(10000));
2144        assert_eq!(monitor.current_equity(), dec!(10000));
2145    }
2146
2147    #[test]
2148    fn test_risk_monitor_check_no_breach() {
2149        let monitor = RiskMonitor::new(dec!(10000))
2150            .add_rule(MaxDrawdownRule { threshold_pct: dec!(15) });
2151        let breaches = monitor.check(dec!(9000)); // 10% drawdown < 15%
2152        assert!(breaches.is_empty());
2153    }
2154
2155    #[test]
2156    fn test_drawdown_tracker_in_drawdown_false_at_peak() {
2157        let tracker = DrawdownTracker::new(dec!(10000));
2158        assert!(!tracker.in_drawdown());
2159    }
2160
2161    #[test]
2162    fn test_drawdown_tracker_in_drawdown_true_below_peak() {
2163        let mut tracker = DrawdownTracker::new(dec!(10000));
2164        tracker.update(dec!(9000));
2165        assert!(tracker.in_drawdown());
2166    }
2167
2168    #[test]
2169    fn test_drawdown_tracker_in_drawdown_false_at_new_peak() {
2170        let mut tracker = DrawdownTracker::new(dec!(10000));
2171        tracker.update(dec!(11000));
2172        assert!(!tracker.in_drawdown());
2173    }
2174
2175    #[test]
2176    fn test_drawdown_tracker_drawdown_count_increases() {
2177        let mut tracker = DrawdownTracker::new(dec!(10000));
2178        tracker.update(dec!(9500));
2179        tracker.update(dec!(9000));
2180        assert_eq!(tracker.drawdown_count(), 2);
2181    }
2182
2183    #[test]
2184    fn test_drawdown_tracker_drawdown_count_resets_on_peak() {
2185        let mut tracker = DrawdownTracker::new(dec!(10000));
2186        tracker.update(dec!(9000));
2187        tracker.update(dec!(11000)); // new peak
2188        assert_eq!(tracker.drawdown_count(), 0);
2189    }
2190
2191    #[test]
2192    fn test_risk_monitor_has_breaches_true() {
2193        let monitor = RiskMonitor::new(dec!(10000))
2194            .add_rule(MaxDrawdownRule { threshold_pct: dec!(5) });
2195        assert!(monitor.has_breaches(dec!(9000))); // 10% > 5%
2196    }
2197
2198    #[test]
2199    fn test_risk_monitor_has_breaches_false() {
2200        let monitor = RiskMonitor::new(dec!(10000))
2201            .add_rule(MaxDrawdownRule { threshold_pct: dec!(15) });
2202        assert!(!monitor.has_breaches(dec!(9000))); // 10% < 15%
2203    }
2204
2205    #[test]
2206    fn test_risk_monitor_is_in_drawdown_true() {
2207        let mut monitor = RiskMonitor::new(dec!(10000)).add_rule(MaxDrawdownRule { threshold_pct: dec!(50) });
2208        monitor.update(dec!(9000));
2209        assert!(monitor.is_in_drawdown());
2210    }
2211
2212    #[test]
2213    fn test_risk_monitor_is_in_drawdown_false_at_peak() {
2214        let mut monitor = RiskMonitor::new(dec!(10000)).add_rule(MaxDrawdownRule { threshold_pct: dec!(50) });
2215        monitor.update(dec!(10000));
2216        assert!(!monitor.is_in_drawdown());
2217    }
2218
2219    #[test]
2220    fn test_risk_monitor_is_in_drawdown_false_above_peak() {
2221        let mut monitor = RiskMonitor::new(dec!(10000)).add_rule(MaxDrawdownRule { threshold_pct: dec!(50) });
2222        monitor.update(dec!(11000));
2223        assert!(!monitor.is_in_drawdown());
2224    }
2225
2226    #[test]
2227    fn test_recovery_to_peak_pct_at_peak_is_zero() {
2228        let tracker = DrawdownTracker::new(dec!(10000));
2229        assert_eq!(tracker.recovery_to_peak_pct(), dec!(0));
2230    }
2231
2232    #[test]
2233    fn test_recovery_to_peak_pct_with_drawdown() {
2234        let mut tracker = DrawdownTracker::new(dec!(10000));
2235        tracker.update(dec!(8000)); // 20% drawdown → need 25% gain to recover
2236        // (10000/8000 - 1) * 100 = 0.25 * 100 = 25
2237        assert_eq!(tracker.recovery_to_peak_pct(), dec!(25));
2238    }
2239
2240    #[test]
2241    fn test_recovery_to_peak_pct_above_peak_is_zero() {
2242        let mut tracker = DrawdownTracker::new(dec!(10000));
2243        tracker.update(dec!(12000)); // new peak
2244        assert_eq!(tracker.recovery_to_peak_pct(), dec!(0));
2245    }
2246
2247    #[test]
2248    fn test_calmar_ratio_with_drawdown() {
2249        let mut tracker = DrawdownTracker::new(dec!(10000));
2250        tracker.update(dec!(9000)); // 10% drawdown
2251        // annualized_return = 20%, worst_dd = 10% → calmar = 2
2252        let ratio = tracker.calmar_ratio(dec!(20)).unwrap();
2253        assert_eq!(ratio, dec!(2));
2254    }
2255
2256    #[test]
2257    fn test_calmar_ratio_none_when_no_drawdown() {
2258        let tracker = DrawdownTracker::new(dec!(10000));
2259        // worst_drawdown_pct is 0 → None
2260        assert!(tracker.calmar_ratio(dec!(20)).is_none());
2261    }
2262
2263    #[test]
2264    fn test_sharpe_ratio_basic() {
2265        let tracker = DrawdownTracker::new(dec!(10000));
2266        // 15% return, 5% vol → sharpe = 3
2267        assert_eq!(tracker.sharpe_ratio(dec!(15), dec!(5)), Some(dec!(3)));
2268    }
2269
2270    #[test]
2271    fn test_sharpe_ratio_none_when_vol_zero() {
2272        let tracker = DrawdownTracker::new(dec!(10000));
2273        assert!(tracker.sharpe_ratio(dec!(15), dec!(0)).is_none());
2274    }
2275
2276    #[test]
2277    fn test_time_underwater_pct_no_updates_returns_zero() {
2278        let tracker = DrawdownTracker::new(dec!(10000));
2279        assert_eq!(tracker.time_underwater_pct(), dec!(0));
2280    }
2281
2282    #[test]
2283    fn test_time_underwater_pct_all_in_drawdown() {
2284        let mut tracker = DrawdownTracker::new(dec!(10000));
2285        tracker.update(dec!(9000));
2286        tracker.update(dec!(8000));
2287        // 2 updates, both below peak → 100%
2288        assert_eq!(tracker.time_underwater_pct(), dec!(1));
2289    }
2290
2291    #[test]
2292    fn test_time_underwater_pct_half_in_drawdown() {
2293        let mut tracker = DrawdownTracker::new(dec!(10000));
2294        tracker.update(dec!(11000)); // new peak, not in dd
2295        tracker.update(dec!(10000)); // in drawdown
2296        assert_eq!(tracker.time_underwater_pct(), Decimal::new(5, 1));
2297    }
2298
2299    #[test]
2300    fn test_avg_drawdown_pct_none_when_no_drawdown() {
2301        let mut tracker = DrawdownTracker::new(dec!(10000));
2302        tracker.update(dec!(11000));
2303        assert!(tracker.avg_drawdown_pct().is_none());
2304    }
2305
2306    #[test]
2307    fn test_avg_drawdown_pct_positive_when_drawdown() {
2308        let mut tracker = DrawdownTracker::new(dec!(10000));
2309        tracker.update(dec!(9000)); // 10% drawdown
2310        let avg = tracker.avg_drawdown_pct().unwrap();
2311        assert!(avg > dec!(0));
2312    }
2313
2314    #[test]
2315    fn test_max_loss_streak_zero_when_no_drawdown() {
2316        let mut tracker = DrawdownTracker::new(dec!(10000));
2317        tracker.update(dec!(11000));
2318        tracker.update(dec!(12000));
2319        assert_eq!(tracker.max_loss_streak(), 0);
2320    }
2321
2322    #[test]
2323    fn test_max_loss_streak_tracks_longest_run() {
2324        let mut tracker = DrawdownTracker::new(dec!(10000));
2325        tracker.update(dec!(9000)); // streak=1
2326        tracker.update(dec!(8000)); // streak=2
2327        tracker.update(dec!(11000)); // new peak, streak resets
2328        tracker.update(dec!(10000)); // streak=1
2329        assert_eq!(tracker.max_loss_streak(), 2);
2330    }
2331
2332    #[test]
2333    fn test_reset_clears_new_fields() {
2334        let mut tracker = DrawdownTracker::new(dec!(10000));
2335        tracker.update(dec!(9000));
2336        tracker.update(dec!(8000));
2337        tracker.reset(dec!(10000));
2338        assert_eq!(tracker.time_underwater_pct(), dec!(0));
2339        assert!(tracker.avg_drawdown_pct().is_none());
2340        assert_eq!(tracker.max_loss_streak(), 0);
2341    }
2342
2343    #[test]
2344    fn test_consecutive_gain_updates_zero_initially() {
2345        let tracker = DrawdownTracker::new(dec!(10000));
2346        assert_eq!(tracker.consecutive_gain_updates(), 0);
2347    }
2348
2349    #[test]
2350    fn test_consecutive_gain_updates_increments_on_rising_equity() {
2351        let mut tracker = DrawdownTracker::new(dec!(10000));
2352        tracker.update(dec!(10100));
2353        tracker.update(dec!(10200));
2354        tracker.update(dec!(10300));
2355        assert_eq!(tracker.consecutive_gain_updates(), 3);
2356    }
2357
2358    #[test]
2359    fn test_consecutive_gain_updates_resets_on_drop() {
2360        let mut tracker = DrawdownTracker::new(dec!(10000));
2361        tracker.update(dec!(10100));
2362        tracker.update(dec!(10200));
2363        tracker.update(dec!(10100)); // drop
2364        assert_eq!(tracker.consecutive_gain_updates(), 0);
2365    }
2366
2367    #[test]
2368    fn test_consecutive_gain_updates_resumes_after_drop() {
2369        let mut tracker = DrawdownTracker::new(dec!(10000));
2370        tracker.update(dec!(10100));
2371        tracker.update(dec!(9900)); // drop — resets
2372        tracker.update(dec!(10000)); // gain resumes
2373        tracker.update(dec!(10100));
2374        assert_eq!(tracker.consecutive_gain_updates(), 2);
2375    }
2376
2377    #[test]
2378    fn test_consecutive_gain_updates_clears_on_reset() {
2379        let mut tracker = DrawdownTracker::new(dec!(10000));
2380        tracker.update(dec!(11000));
2381        tracker.update(dec!(12000));
2382        tracker.reset(dec!(10000));
2383        assert_eq!(tracker.consecutive_gain_updates(), 0);
2384    }
2385
2386    #[test]
2387    fn test_equity_ratio_at_peak_is_one() {
2388        let mut tracker = DrawdownTracker::new(dec!(10000));
2389        tracker.update(dec!(10000));
2390        assert_eq!(tracker.equity_ratio(), Decimal::ONE);
2391    }
2392
2393    #[test]
2394    fn test_equity_ratio_in_drawdown() {
2395        let mut tracker = DrawdownTracker::new(dec!(10000));
2396        tracker.update(dec!(9000));
2397        assert_eq!(tracker.equity_ratio(), dec!(0.9));
2398    }
2399
2400    #[test]
2401    fn test_equity_ratio_new_peak() {
2402        let mut tracker = DrawdownTracker::new(dec!(10000));
2403        tracker.update(dec!(12000));
2404        assert_eq!(tracker.equity_ratio(), Decimal::ONE);
2405    }
2406
2407    #[test]
2408    fn test_new_peak_count_zero_initially() {
2409        let tracker = DrawdownTracker::new(dec!(10000));
2410        assert_eq!(tracker.new_peak_count(), 0);
2411    }
2412
2413    #[test]
2414    fn test_new_peak_count_increments() {
2415        let mut tracker = DrawdownTracker::new(dec!(10000));
2416        tracker.update(dec!(11000));
2417        tracker.update(dec!(9000));  // drawdown, no new peak
2418        tracker.update(dec!(12000)); // new peak
2419        assert_eq!(tracker.new_peak_count(), 2);
2420    }
2421
2422    #[test]
2423    fn test_new_peak_count_resets() {
2424        let mut tracker = DrawdownTracker::new(dec!(10000));
2425        tracker.update(dec!(11000));
2426        tracker.update(dec!(12000));
2427        tracker.reset(dec!(10000));
2428        assert_eq!(tracker.new_peak_count(), 0);
2429    }
2430
2431    #[test]
2432    fn test_omega_ratio_positive_threshold_zero() {
2433        let returns = vec![dec!(0.05), dec!(-0.02), dec!(0.03), dec!(-0.01)];
2434        let omega = DrawdownTracker::omega_ratio(&returns, Decimal::ZERO).unwrap();
2435        // upside = 0.05 + 0.03 = 0.08; downside = 0.02 + 0.01 = 0.03
2436        assert!(omega > 1.0, "expected omega > 1.0, got {omega}");
2437    }
2438
2439    #[test]
2440    fn test_omega_ratio_empty_returns_none() {
2441        assert!(DrawdownTracker::omega_ratio(&[], Decimal::ZERO).is_none());
2442    }
2443
2444    #[test]
2445    fn test_omega_ratio_no_downside_returns_none() {
2446        let returns = vec![dec!(0.01), dec!(0.02), dec!(0.03)];
2447        assert!(DrawdownTracker::omega_ratio(&returns, Decimal::ZERO).is_none());
2448    }
2449
2450    #[test]
2451    fn test_tail_ratio_none_below_20_obs() {
2452        let returns: Vec<Decimal> = (0..19).map(|_| dec!(0.01)).collect();
2453        assert!(RiskMonitor::tail_ratio(&returns).is_none());
2454    }
2455
2456    #[test]
2457    fn test_tail_ratio_positive_skewed_series() {
2458        // 20 observations: 19 small losses, 1 large gain → ratio > 1
2459        let mut returns: Vec<Decimal> = (0..19).map(|_| dec!(-0.005)).collect();
2460        returns.push(dec!(0.1)); // large upside at 95th pct
2461        let ratio = RiskMonitor::tail_ratio(&returns).unwrap();
2462        assert!(ratio > 0.0, "tail ratio should be positive: {ratio}");
2463    }
2464
2465    #[test]
2466    fn test_skewness_none_below_3() {
2467        assert!(RiskMonitor::skewness(&[dec!(0.01), dec!(0.02)]).is_none());
2468    }
2469
2470    #[test]
2471    fn test_skewness_symmetric_near_zero() {
2472        // Symmetric distribution: [-1, 0, 1]
2473        let returns = vec![dec!(-1), dec!(0), dec!(1)];
2474        let sk = RiskMonitor::skewness(&returns).unwrap();
2475        assert!(sk.abs() < 1e-9, "symmetric series should have ~0 skew: {sk}");
2476    }
2477
2478    #[test]
2479    fn test_skewness_right_skewed_positive() {
2480        // Heavy right tail: many small values, one large outlier
2481        let mut returns: Vec<Decimal> = (0..10).map(|_| dec!(0)).collect();
2482        returns.push(dec!(100));
2483        let sk = RiskMonitor::skewness(&returns).unwrap();
2484        assert!(sk > 0.0, "right-skewed series should have positive skew: {sk}");
2485    }
2486
2487    #[test]
2488    fn test_calmar_ratio_none_at_peak() {
2489        // No drawdown → calmar returns None (denominator is 0)
2490        let monitor = RiskMonitor::new(dec!(10000));
2491        assert!(monitor.calmar_ratio(15.0).is_none());
2492    }
2493
2494    #[test]
2495    fn test_calmar_ratio_positive_after_drawdown() {
2496        let mut monitor = RiskMonitor::new(dec!(10000));
2497        monitor.update(dec!(9000)); // 10% drawdown
2498        let calmar = monitor.calmar_ratio(15.0).unwrap();
2499        assert!((calmar - 1.5).abs() < 0.001, "calmar should be ~1.5: {calmar}");
2500    }
2501}