1use rust_decimal::Decimal;
15use rust_decimal::prelude::ToPrimitive;
16
17#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
19pub struct DrawdownTracker {
20 peak_equity: Decimal,
21 current_equity: Decimal,
22 worst_drawdown_pct: Decimal,
23 updates_since_peak: usize,
25 update_count: usize,
27 drawdown_update_count: usize,
29 #[serde(default)]
31 drawdown_pct_sum: Decimal,
32 #[serde(default)]
34 max_drawdown_streak: usize,
35 #[serde(default)]
37 gain_streak: usize,
38 #[serde(default)]
40 peak_count: usize,
41 #[serde(default)]
43 prev_equity: Decimal,
44 #[serde(default)]
46 equity_change_mean: f64,
47 #[serde(default)]
49 equity_change_m2: f64,
50 #[serde(default)]
52 equity_change_count: usize,
53 #[serde(default)]
55 min_equity_delta: f64,
56 #[serde(default)]
58 max_gain_streak: usize,
59 #[serde(default)]
61 total_gain_sum: f64,
62 #[serde(default)]
64 total_loss_sum: f64,
65 #[serde(default)]
67 completed_recoveries: usize,
68 #[serde(default)]
70 total_recovery_updates: usize,
71 #[serde(default)]
73 recovery_drawdown_pct_sum: Decimal,
74 #[serde(default)]
76 max_gain_delta_pct: f64,
77 #[serde(default)]
79 drawdown_episodes: usize,
80 #[serde(default)]
82 loss_streak_current: usize,
83 initial_equity: Decimal,
85 #[serde(default)]
87 flat_streak: usize,
88}
89
90impl DrawdownTracker {
91 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 pub fn update(&mut self, equity: Decimal) {
125 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 pub fn drawdown_duration(&self) -> usize {
206 self.updates_since_peak
207 }
208
209 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 pub fn peak(&self) -> Decimal {
221 self.peak_equity
222 }
223
224 pub fn current_equity(&self) -> Decimal {
226 self.current_equity
227 }
228
229 pub fn is_below_threshold(&self, max_dd_pct: Decimal) -> bool {
231 self.current_drawdown_pct() <= max_dd_pct
232 }
233
234 pub fn reset_peak(&mut self) {
239 self.peak_equity = self.current_equity;
240 self.updates_since_peak = 0;
241 }
242
243 pub fn worst_drawdown_pct(&self) -> Decimal {
245 self.worst_drawdown_pct
246 }
247
248 pub fn update_count(&self) -> usize {
250 self.update_count
251 }
252
253 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 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 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 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 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 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 pub fn in_drawdown(&self) -> bool {
348 self.current_equity < self.peak_equity
349 }
350
351 pub fn update_with_returns(&mut self, equities: &[Decimal]) {
355 for &eq in equities {
356 self.update(eq);
357 }
358 }
359
360 pub fn drawdown_count(&self) -> usize {
365 self.updates_since_peak
366 }
367
368 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 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 #[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 #[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 pub fn max_loss_streak(&self) -> usize {
418 self.max_drawdown_streak.max(self.updates_since_peak)
419 }
420
421 pub fn consecutive_gain_updates(&self) -> usize {
425 self.gain_streak
426 }
427
428 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 pub fn new_peak_count(&self) -> usize {
441 self.peak_count
442 }
443
444 #[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 pub fn above_high_water_mark(&self, equity: Decimal) -> bool {
463 equity > self.peak_equity
464 }
465
466 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 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 pub fn consecutive_loss_updates(&self) -> usize {
496 if self.gain_streak > 0 {
500 0
501 } else {
502 self.updates_since_peak
503 }
504 }
505
506 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 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 pub fn max_gain_streak(&self) -> usize {
538 self.max_gain_streak
539 }
540
541 pub fn total_gain_sum(&self) -> f64 {
545 self.total_gain_sum
546 }
547
548 pub fn total_loss_sum(&self) -> f64 {
552 self.total_loss_sum
553 }
554
555 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 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 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 pub fn peak_hit_count(&self) -> usize {
587 self.peak_count
588 }
589
590 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 pub fn max_gain_pct(&self) -> f64 {
603 self.max_gain_delta_pct
604 }
605
606 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 pub fn breakeven_equity(&self) -> Decimal {
618 self.peak_equity
619 }
620
621 pub fn loss_streak(&self) -> usize {
625 self.loss_streak_current
626 }
627
628 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 pub fn consecutive_flat_count(&self) -> usize {
640 self.flat_streak
641 }
642
643 pub fn total_updates(&self) -> usize {
645 self.update_count
646 }
647
648 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 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 pub fn is_recovering(&self) -> bool {
672 self.in_drawdown() && self.gain_streak > 0
673 }
674
675 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 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 pub fn avg_gain_pct(&self) -> Option<f64> {
694 let wr: f64 = self.win_rate()?.to_string().parse().ok()?;
695 let gain_count = (wr / 100.0 * self.update_count as f64).round() as usize;
696 if gain_count == 0 { return None; }
697 Some(self.total_gain_sum / gain_count as f64)
698 }
699
700 pub fn is_at_peak(&self) -> bool {
702 self.current_equity >= self.peak_equity
703 }
704
705 pub fn below_initial_equity(&self) -> bool {
707 self.current_equity < self.initial_equity
708 }
709
710 pub fn return_drawdown_ratio(&self) -> Option<f64> {
714 if self.worst_drawdown_pct.is_zero() { return None; }
715 let net_ret = self.net_return_pct()?;
716 let dd: f64 = self.worst_drawdown_pct.to_string().parse().ok()?;
717 if dd == 0.0 { return None; }
718 Some(net_ret / dd)
719 }
720
721 pub fn consecutive_flat_pct(&self) -> f64 {
725 if self.update_count == 0 { return 0.0; }
726 self.flat_streak as f64 / self.update_count as f64 * 100.0
727 }
728
729 pub fn current_streak(&self) -> i64 {
731 if self.gain_streak > 0 {
732 self.gain_streak as i64
733 } else if self.loss_streak_current > 0 {
734 -(self.loss_streak_current as i64)
735 } else {
736 0
737 }
738 }
739
740 pub fn max_loss_pct_single(&self) -> Option<f64> {
744 if self.min_equity_delta >= 0.0 { return None; }
745 let peak: f64 = self.peak_equity.to_string().parse().ok()?;
746 if peak <= 0.0 { return None; }
747 Some((self.min_equity_delta / peak).abs() * 100.0)
748 }
749
750 pub fn win_loss_ratio(&self) -> Option<f64> {
754 let wr = self.win_rate()?.to_string().parse::<f64>().ok()?;
755 let lr = self.loss_rate()?;
756 if lr == 0.0 { return None; }
757 Some(wr / (lr * 100.0))
758 }
759
760 pub fn best_drawdown_recovery(&self) -> Option<f64> {
764 if self.worst_drawdown_pct.is_zero() { return None; }
765 let max_gain = self.max_gain_pct();
766 if max_gain <= 0.0 { return None; }
767 let dd: f64 = self.worst_drawdown_pct.to_string().parse().ok()?;
768 if dd == 0.0 { return None; }
769 Some(max_gain / dd)
770 }
771
772 pub fn recovery_count(&self) -> usize {
774 self.completed_recoveries
775 }
776
777 pub fn avg_gain_loss_ratio(&self) -> Option<f64> {
781 let avg_gain = self.avg_gain_pct()?;
782 let lr = self.loss_rate()?;
783 let loss_count = (lr * self.update_count as f64).round() as usize;
784 if loss_count == 0 { return None; }
785 let avg_loss = self.total_loss_sum / loss_count as f64;
786 if avg_loss == 0.0 { return None; }
787 Some(avg_gain / avg_loss)
788 }
789
790 pub fn time_to_recover_est(&self) -> Option<usize> {
795 if !self.in_drawdown() { return None; }
796 let avg_gain = self.avg_gain_pct()?;
797 if avg_gain <= 0.0 { return None; }
798 let distance: f64 = self.current_drawdown_pct().to_string().parse().ok()?;
799 Some((distance / avg_gain).ceil() as usize)
800 }
801
802 pub fn current_drawdown_absolute(&self) -> Decimal {
804 if self.current_equity >= self.peak_equity {
805 Decimal::ZERO
806 } else {
807 self.peak_equity - self.current_equity
808 }
809 }
810
811 pub fn median_drawdown_pct(drawdowns: &[Decimal]) -> Option<Decimal> {
815 if drawdowns.is_empty() { return None; }
816 let mut sorted = drawdowns.to_vec();
817 sorted.sort();
818 let mid = sorted.len() / 2;
819 if sorted.len() % 2 == 1 {
820 Some(sorted[mid])
821 } else {
822 Some((sorted[mid - 1] + sorted[mid]) / Decimal::TWO)
823 }
824 }
825
826 pub fn sortino_ratio(returns: &[Decimal], target: Decimal) -> Option<f64> {
833 if returns.is_empty() {
834 return None;
835 }
836 let n = returns.len() as f64;
837 let target_f = target.to_f64()?;
838 let mean: f64 = returns.iter().filter_map(|r| r.to_f64()).sum::<f64>() / n;
839 let downside_sq_sum: f64 = returns
840 .iter()
841 .filter_map(|r| r.to_f64())
842 .map(|r| {
843 let diff = r - target_f;
844 if diff < 0.0 { diff * diff } else { 0.0 }
845 })
846 .sum();
847 if downside_sq_sum == 0.0 {
848 return None;
849 }
850 let downside_dev = (downside_sq_sum / n).sqrt();
851 if downside_dev == 0.0 {
852 return None;
853 }
854 Some((mean - target_f) / downside_dev)
855 }
856
857 pub fn returns_volatility(returns: &[Decimal], periods_per_year: u32) -> Option<f64> {
863 if returns.len() < 2 {
864 return None;
865 }
866 let n = returns.len() as f64;
867 let mean: f64 = returns.iter()
868 .filter_map(|r| r.to_f64())
869 .sum::<f64>() / n;
870 let variance: f64 = returns.iter()
871 .filter_map(|r| r.to_f64())
872 .map(|r| (r - mean).powi(2))
873 .sum::<f64>() / (n - 1.0);
874 let vol = variance.sqrt() * (periods_per_year as f64).sqrt();
875 Some(vol)
876 }
877
878 pub fn omega_ratio(returns: &[Decimal], threshold: Decimal) -> Option<f64> {
883 if returns.is_empty() {
884 return None;
885 }
886 let threshold_f = threshold.to_f64()?;
887 let upside: f64 = returns
888 .iter()
889 .filter_map(|r| r.to_f64())
890 .map(|r| (r - threshold_f).max(0.0))
891 .sum();
892 let downside: f64 = returns
893 .iter()
894 .filter_map(|r| r.to_f64())
895 .map(|r| (threshold_f - r).max(0.0))
896 .sum();
897 if downside == 0.0 {
898 return None;
899 }
900 Some(upside / downside)
901 }
902
903 pub fn information_ratio(returns: &[Decimal], benchmark: &[Decimal]) -> Option<f64> {
908 let n = returns.len().min(benchmark.len());
909 if n < 2 {
910 return None;
911 }
912 let excess: Vec<f64> = returns[..n]
913 .iter()
914 .zip(benchmark[..n].iter())
915 .filter_map(|(r, b)| Some(r.to_f64()? - b.to_f64()?))
916 .collect();
917 if excess.len() < 2 {
918 return None;
919 }
920 let mean_excess = excess.iter().sum::<f64>() / excess.len() as f64;
921 let tracking_variance = excess.iter().map(|e| (e - mean_excess).powi(2)).sum::<f64>()
922 / (excess.len() as f64 - 1.0);
923 let tracking_error = tracking_variance.sqrt();
924 if tracking_error == 0.0 {
925 return None;
926 }
927 Some(mean_excess / tracking_error)
928 }
929
930 pub fn annualized_volatility(&self, periods_per_year: u32) -> Option<f64> {
934 if self.equity_change_count < 2 { return None; }
935 let n = self.equity_change_count as f64;
936 let variance = self.equity_change_m2 / (n - 1.0);
937 Some(variance.sqrt() * (periods_per_year as f64).sqrt())
938 }
939
940 pub fn pain_ratio(&self, annualized_return_pct: Decimal) -> Option<Decimal> {
945 let pi = self.pain_index();
946 if pi.is_zero() { return None; }
947 Some(annualized_return_pct / pi)
948 }
949
950 pub fn time_above_watermark_pct(&self) -> Decimal {
955 if self.update_count == 0 {
956 return Decimal::ONE;
957 }
958 Decimal::ONE - self.time_underwater_pct()
959 }
960
961 pub fn equity_change_std_dev(&self) -> Option<f64> {
966 if self.equity_change_count < 2 { return None; }
967 let variance = self.equity_change_m2 / (self.equity_change_count - 1) as f64;
968 Some(variance.sqrt())
969 }
970
971 pub fn gain_streak_ratio(&self) -> Option<f64> {
976 if self.update_count == 0 { return None; }
977 Some(self.max_gain_streak as f64 / self.update_count as f64)
978 }
979}
980
981impl std::fmt::Display for DrawdownTracker {
982 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
983 write!(
984 f,
985 "equity={} peak={} drawdown={:.2}%",
986 self.current_equity,
987 self.peak_equity,
988 self.current_drawdown_pct()
989 )
990 }
991}
992
993#[derive(Debug, Clone, PartialEq)]
995pub struct RiskBreach {
996 pub rule: String,
998 pub detail: String,
1000}
1001
1002pub trait RiskRule: Send {
1004 fn name(&self) -> &str;
1006
1007 fn check(&self, equity: Decimal, drawdown_pct: Decimal) -> Option<RiskBreach>;
1013}
1014
1015pub struct MaxDrawdownRule {
1017 pub threshold_pct: Decimal,
1019}
1020
1021impl RiskRule for MaxDrawdownRule {
1022 fn name(&self) -> &str {
1023 "max_drawdown"
1024 }
1025
1026 fn check(&self, _equity: Decimal, drawdown_pct: Decimal) -> Option<RiskBreach> {
1027 if drawdown_pct > self.threshold_pct {
1028 Some(RiskBreach {
1029 rule: self.name().to_owned(),
1030 detail: format!("drawdown {drawdown_pct:.2}% > {:.2}%", self.threshold_pct),
1031 })
1032 } else {
1033 None
1034 }
1035 }
1036}
1037
1038pub struct MinEquityRule {
1040 pub floor: Decimal,
1042}
1043
1044impl RiskRule for MinEquityRule {
1045 fn name(&self) -> &str {
1046 "min_equity"
1047 }
1048
1049 fn check(&self, equity: Decimal, _drawdown_pct: Decimal) -> Option<RiskBreach> {
1050 if equity < self.floor {
1051 Some(RiskBreach {
1052 rule: self.name().to_owned(),
1053 detail: format!("equity {equity} < floor {}", self.floor),
1054 })
1055 } else {
1056 None
1057 }
1058 }
1059}
1060
1061pub struct RiskMonitor {
1063 rules: Vec<Box<dyn RiskRule>>,
1064 tracker: DrawdownTracker,
1065 breach_count: usize,
1066}
1067
1068impl RiskMonitor {
1069 pub fn new(initial_equity: Decimal) -> Self {
1071 Self {
1072 rules: Vec::new(),
1073 tracker: DrawdownTracker::new(initial_equity),
1074 breach_count: 0,
1075 }
1076 }
1077
1078 #[must_use]
1080 pub fn add_rule(mut self, rule: impl RiskRule + 'static) -> Self {
1081 self.rules.push(Box::new(rule));
1082 self
1083 }
1084
1085 pub fn update(&mut self, equity: Decimal) -> Vec<RiskBreach> {
1087 self.tracker.update(equity);
1088 let dd = self.tracker.current_drawdown_pct();
1089 let breaches: Vec<RiskBreach> = self.rules
1090 .iter()
1091 .filter_map(|r| r.check(equity, dd))
1092 .collect();
1093 self.breach_count += breaches.len();
1094 breaches
1095 }
1096
1097 pub fn drawdown_pct(&self) -> Decimal {
1099 self.tracker.current_drawdown_pct()
1100 }
1101
1102 pub fn current_equity(&self) -> Decimal {
1104 self.tracker.current_equity()
1105 }
1106
1107 pub fn peak_equity(&self) -> Decimal {
1109 self.tracker.peak()
1110 }
1111
1112 pub fn reset(&mut self, initial_equity: Decimal) {
1114 self.tracker.reset(initial_equity);
1115 self.breach_count = 0;
1116 }
1117
1118 pub fn rule_count(&self) -> usize {
1120 self.rules.len()
1121 }
1122
1123 pub fn reset_peak(&mut self) {
1128 self.tracker.reset_peak();
1129 }
1130
1131 pub fn is_in_drawdown(&self) -> bool {
1133 self.tracker.current_drawdown_pct() > Decimal::ZERO
1134 }
1135
1136 pub fn worst_drawdown_pct(&self) -> Decimal {
1138 self.tracker.worst_drawdown_pct()
1139 }
1140
1141 pub fn equity_history_len(&self) -> usize {
1143 self.tracker.update_count()
1144 }
1145
1146 pub fn drawdown_duration(&self) -> usize {
1148 self.tracker.drawdown_duration()
1149 }
1150
1151 pub fn breach_count(&self) -> usize {
1153 self.breach_count
1154 }
1155
1156 pub fn max_drawdown_pct(&self) -> Decimal {
1160 self.tracker.worst_drawdown_pct()
1161 }
1162
1163 pub fn drawdown_tracker(&self) -> &DrawdownTracker {
1168 &self.tracker
1169 }
1170
1171 pub fn check(&self, equity: Decimal) -> Vec<RiskBreach> {
1176 let dd = if self.tracker.peak() == Decimal::ZERO {
1177 Decimal::ZERO
1178 } else {
1179 (self.tracker.peak() - equity) / self.tracker.peak() * Decimal::ONE_HUNDRED
1180 };
1181 self.rules
1182 .iter()
1183 .filter_map(|r| r.check(equity, dd))
1184 .collect()
1185 }
1186
1187 pub fn has_breaches(&self, equity: Decimal) -> bool {
1192 !self.check(equity).is_empty()
1193 }
1194
1195 pub fn win_rate(&self) -> Option<Decimal> {
1200 self.tracker.win_rate()
1201 }
1202
1203 pub fn calmar_ratio(&self, annualised_return_pct: f64) -> Option<f64> {
1210 use rust_decimal::prelude::ToPrimitive;
1211 let dd = self.tracker.worst_drawdown_pct().to_f64()?;
1212 if dd == 0.0 { return None; }
1213 Some(annualised_return_pct / dd)
1214 }
1215
1216 pub fn consecutive_gain_updates(&self) -> usize {
1220 self.tracker.consecutive_gain_updates()
1221 }
1222
1223 pub fn equity_at_risk(&self, pct: Decimal) -> Decimal {
1228 self.tracker.peak() * pct / Decimal::ONE_HUNDRED
1229 }
1230
1231 pub fn trailing_stop_level(&self, pct: Decimal) -> Decimal {
1238 self.tracker.peak() * (Decimal::ONE_HUNDRED - pct) / Decimal::ONE_HUNDRED
1239 }
1240
1241 pub fn var_pct(returns: &[Decimal], confidence_pct: Decimal) -> Option<Decimal> {
1249 if returns.is_empty() {
1250 return None;
1251 }
1252 use rust_decimal::prelude::ToPrimitive;
1253 let mut sorted = returns.to_vec();
1254 sorted.sort();
1255 let tail_pct = (Decimal::ONE_HUNDRED - confidence_pct) / Decimal::ONE_HUNDRED;
1256 let idx_f = tail_pct.to_f64()? * sorted.len() as f64;
1257 #[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
1258 let idx = (idx_f as usize).min(sorted.len() - 1);
1259 Some(sorted[idx])
1260 }
1261
1262 pub fn tail_risk_pct(returns: &[Decimal], confidence_pct: Decimal) -> Option<Decimal> {
1270 use rust_decimal::prelude::ToPrimitive;
1271 if returns.is_empty() {
1272 return None;
1273 }
1274 let mut sorted = returns.to_vec();
1275 sorted.sort();
1276 let tail_pct = (Decimal::ONE_HUNDRED - confidence_pct) / Decimal::ONE_HUNDRED;
1277 let tail_count_f = tail_pct.to_f64()? * sorted.len() as f64;
1278 #[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
1279 let tail_count = (tail_count_f.ceil() as usize).max(1).min(sorted.len());
1280 let mean = sorted[..tail_count].iter().copied().sum::<Decimal>()
1281 / Decimal::from(tail_count as u32);
1282 Some(mean)
1283 }
1284
1285 pub fn profit_factor(returns: &[Decimal]) -> Option<Decimal> {
1292 if returns.is_empty() { return None; }
1293 let gross_wins: Decimal = returns.iter().filter(|&&r| r > Decimal::ZERO).copied().sum();
1294 let gross_losses: Decimal = returns.iter().filter(|&&r| r < Decimal::ZERO).map(|r| r.abs()).sum();
1295 if gross_losses.is_zero() { return None; }
1296 Some(gross_wins / gross_losses)
1297 }
1298
1299 pub fn omega_ratio(returns: &[Decimal], threshold: Decimal) -> Option<Decimal> {
1305 if returns.is_empty() { return None; }
1306 let upside: Decimal = returns.iter().map(|&r| (r - threshold).max(Decimal::ZERO)).sum();
1307 let downside: Decimal = returns.iter().map(|&r| (threshold - r).max(Decimal::ZERO)).sum();
1308 if downside.is_zero() { return None; }
1309 Some(upside / downside)
1310 }
1311
1312 pub fn kelly_fraction(
1321 win_rate: Decimal,
1322 avg_win: Decimal,
1323 avg_loss: Decimal,
1324 ) -> Option<Decimal> {
1325 if avg_loss.is_zero() { return None; }
1326 let loss_rate = Decimal::ONE - win_rate;
1327 let odds = avg_win / avg_loss;
1328 Some(win_rate - loss_rate / odds)
1329 }
1330
1331 pub fn annualized_return(returns: &[Decimal], periods_per_year: usize) -> Option<f64> {
1337 use rust_decimal::prelude::ToPrimitive;
1338 if returns.is_empty() || periods_per_year == 0 { return None; }
1339 let n = returns.len() as f64;
1340 let mean_r: f64 = returns.iter().map(|r| r.to_f64().unwrap_or(0.0)).sum::<f64>() / n;
1341 let annual = (1.0 + mean_r).powf(periods_per_year as f64) - 1.0;
1342 Some(annual)
1343 }
1344
1345 pub fn tail_ratio(returns: &[Decimal]) -> Option<f64> {
1352 use rust_decimal::prelude::ToPrimitive;
1353 if returns.len() < 20 { return None; }
1354 let mut vals: Vec<f64> = returns.iter().filter_map(|r| r.to_f64()).collect();
1355 vals.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal));
1356 let n = vals.len();
1357 let p95_idx = ((n as f64 * 0.95) as usize).min(n - 1);
1358 let p05_idx = ((n as f64 * 0.05) as usize).min(n - 1);
1359 let p95 = vals[p95_idx];
1360 let p05 = vals[p05_idx].abs();
1361 if p05 == 0.0 { return None; }
1362 Some(p95 / p05)
1363 }
1364
1365 pub fn skewness(returns: &[Decimal]) -> Option<f64> {
1372 use rust_decimal::prelude::ToPrimitive;
1373 if returns.len() < 3 { return None; }
1374 let vals: Vec<f64> = returns.iter().filter_map(|r| r.to_f64()).collect();
1375 let n = vals.len() as f64;
1376 let mean = vals.iter().sum::<f64>() / n;
1377 let variance = vals.iter().map(|v| (v - mean).powi(2)).sum::<f64>() / n;
1378 let std_dev = variance.sqrt();
1379 if std_dev == 0.0 { return None; }
1380 let skew = vals.iter().map(|v| ((v - mean) / std_dev).powi(3)).sum::<f64>() / n;
1381 Some(skew)
1382 }
1383
1384}
1385
1386impl DrawdownTracker {
1387 pub fn gain_loss_asymmetry(&self) -> Option<f64> {
1392 if self.equity_change_count == 0 { return None; }
1393 let n = self.equity_change_count as f64;
1394 let mean = self.equity_change_mean;
1395 let variance = if self.equity_change_count > 1 {
1399 self.equity_change_m2 / (n - 1.0)
1400 } else {
1401 return None;
1402 };
1403 let std = variance.sqrt();
1404 if std == 0.0 { return None; }
1405 let avg_loss = std - mean.min(0.0); if avg_loss <= 0.0 { return None; }
1407 let avg_gain = std + mean.max(0.0); Some(avg_gain / avg_loss)
1409 }
1410
1411 pub fn streaks(&self) -> (usize, usize, usize, usize) {
1416 (
1417 self.gain_streak,
1418 self.gain_streak, self.updates_since_peak,
1420 self.max_drawdown_streak,
1421 )
1422 }
1423
1424 pub fn sharpe_proxy(&self, annualized_return: f64, periods_per_year: u32) -> Option<f64> {
1429 let vol = self.annualized_volatility(periods_per_year)?;
1430 if vol == 0.0 { return None; }
1431 Some(annualized_return / vol)
1432 }
1433
1434 pub fn max_consecutive_underwater(&self) -> usize {
1438 self.max_drawdown_streak
1439 }
1440
1441 pub fn underwater_duration_avg(&self) -> Option<f64> {
1445 let count = self.drawdown_count();
1446 if count == 0 { return None; }
1447 Some(self.drawdown_update_count as f64 / count as f64)
1448 }
1449
1450 pub fn equity_efficiency(&self) -> f64 {
1454 if self.peak_equity.is_zero() { return 1.0; }
1455 (self.current_equity / self.peak_equity).to_f64().unwrap_or(0.0)
1456 }
1457
1458 pub fn sortino_proxy(&self, annualized_return: f64, periods_per_year: u32) -> Option<f64> {
1463 if self.equity_change_count < 2 { return None; }
1464 let neg_count = self.equity_change_count; let downside_vol = self.annualized_volatility(periods_per_year)? / 2.0_f64.sqrt();
1468 if downside_vol == 0.0 { return None; }
1469 Some(annualized_return / downside_vol)
1470 }
1471
1472 pub fn gain_loss_ratio(&self) -> Option<f64> {
1476 if self.total_loss_sum == 0.0 { return None; }
1477 Some(self.total_gain_sum / self.total_loss_sum)
1478 }
1479
1480 pub fn recovery_efficiency(&self) -> Option<f64> {
1485 let dd_count = self.drawdown_count();
1486 if dd_count == 0 { return None; }
1487 Some(self.completed_recoveries as f64 / dd_count as f64)
1488 }
1489
1490 pub fn drawdown_velocity(&self) -> Option<f64> {
1494 if self.updates_since_peak == 0 { return None; }
1495 let dd = self.current_drawdown_pct().to_f64()?;
1496 Some(dd / self.updates_since_peak as f64)
1497 }
1498
1499 pub fn streak_win_rate(&self) -> Option<f64> {
1503 let total = self.max_gain_streak + self.max_drawdown_streak;
1504 if total == 0 { return None; }
1505 Some(self.max_gain_streak as f64 / total as f64)
1506 }
1507
1508 pub fn equity_change_std(&self) -> Option<f64> {
1511 if self.equity_change_count < 2 { return None; }
1512 let variance = self.equity_change_m2 / (self.equity_change_count as f64 - 1.0);
1513 Some(variance.sqrt())
1514 }
1515
1516 pub fn avg_loss_pct(&self) -> Option<f64> {
1519 if self.total_loss_sum == 0.0 || self.update_count == 0 { return None; }
1520 let wr: f64 = self.win_rate()?.to_string().parse().ok()?;
1521 let loss_count = ((1.0 - wr / 100.0) * self.update_count as f64).round() as usize;
1522 if loss_count == 0 { return None; }
1523 Some(self.total_loss_sum / loss_count as f64)
1524 }
1525}
1526
1527#[cfg(test)]
1528mod tests {
1529 use super::*;
1530 use rust_decimal_macros::dec;
1531
1532 #[test]
1533 fn test_drawdown_tracker_zero_at_peak() {
1534 let t = DrawdownTracker::new(dec!(10000));
1535 assert_eq!(t.current_drawdown_pct(), dec!(0));
1536 }
1537
1538 #[test]
1539 fn test_drawdown_tracker_increases_below_peak() {
1540 let mut t = DrawdownTracker::new(dec!(10000));
1541 t.update(dec!(9000));
1542 assert_eq!(t.current_drawdown_pct(), dec!(10));
1543 }
1544
1545 #[test]
1546 fn test_drawdown_tracker_peak_updates() {
1547 let mut t = DrawdownTracker::new(dec!(10000));
1548 t.update(dec!(12000));
1549 assert_eq!(t.peak(), dec!(12000));
1550 }
1551
1552 #[test]
1553 fn test_drawdown_tracker_current_equity() {
1554 let mut t = DrawdownTracker::new(dec!(10000));
1555 t.update(dec!(9500));
1556 assert_eq!(t.current_equity(), dec!(9500));
1557 }
1558
1559 #[test]
1560 fn test_drawdown_tracker_is_below_threshold_true() {
1561 let mut t = DrawdownTracker::new(dec!(10000));
1562 t.update(dec!(9500));
1563 assert!(t.is_below_threshold(dec!(10)));
1564 }
1565
1566 #[test]
1567 fn test_drawdown_tracker_is_below_threshold_false() {
1568 let mut t = DrawdownTracker::new(dec!(10000));
1569 t.update(dec!(8000));
1570 assert!(!t.is_below_threshold(dec!(10)));
1571 }
1572
1573 #[test]
1574 fn test_drawdown_tracker_never_negative() {
1575 let mut t = DrawdownTracker::new(dec!(10000));
1576 t.update(dec!(11000));
1577 assert_eq!(t.current_drawdown_pct(), dec!(0));
1578 }
1579
1580 #[test]
1581 fn test_max_drawdown_rule_triggers_breach() {
1582 let rule = MaxDrawdownRule {
1583 threshold_pct: dec!(10),
1584 };
1585 let breach = rule.check(dec!(8000), dec!(20));
1586 assert!(breach.is_some());
1587 }
1588
1589 #[test]
1590 fn test_max_drawdown_rule_no_breach_within_limit() {
1591 let rule = MaxDrawdownRule {
1592 threshold_pct: dec!(10),
1593 };
1594 let breach = rule.check(dec!(9500), dec!(5));
1595 assert!(breach.is_none());
1596 }
1597
1598 #[test]
1599 fn test_max_drawdown_rule_at_exact_threshold_no_breach() {
1600 let rule = MaxDrawdownRule {
1601 threshold_pct: dec!(10),
1602 };
1603 let breach = rule.check(dec!(9000), dec!(10));
1604 assert!(breach.is_none());
1605 }
1606
1607 #[test]
1608 fn test_min_equity_rule_breach() {
1609 let rule = MinEquityRule { floor: dec!(5000) };
1610 let breach = rule.check(dec!(4000), dec!(0));
1611 assert!(breach.is_some());
1612 }
1613
1614 #[test]
1615 fn test_min_equity_rule_no_breach() {
1616 let rule = MinEquityRule { floor: dec!(5000) };
1617 let breach = rule.check(dec!(6000), dec!(0));
1618 assert!(breach.is_none());
1619 }
1620
1621 #[test]
1622 fn test_risk_monitor_returns_all_breaches() {
1623 let mut monitor = RiskMonitor::new(dec!(10000))
1624 .add_rule(MaxDrawdownRule {
1625 threshold_pct: dec!(5),
1626 })
1627 .add_rule(MinEquityRule { floor: dec!(9000) });
1628 let breaches = monitor.update(dec!(8000));
1629 assert_eq!(breaches.len(), 2);
1630 }
1631
1632 #[test]
1633 fn test_risk_monitor_breach_count_accumulates() {
1634 let mut monitor = RiskMonitor::new(dec!(10000))
1635 .add_rule(MaxDrawdownRule { threshold_pct: dec!(5) });
1636 assert_eq!(monitor.breach_count(), 0);
1637 monitor.update(dec!(9000)); assert_eq!(monitor.breach_count(), 1);
1639 monitor.update(dec!(8500)); assert_eq!(monitor.breach_count(), 2);
1641 }
1642
1643 #[test]
1644 fn test_risk_monitor_breach_count_resets() {
1645 let mut monitor = RiskMonitor::new(dec!(10000))
1646 .add_rule(MaxDrawdownRule { threshold_pct: dec!(5) });
1647 monitor.update(dec!(9000));
1648 assert_eq!(monitor.breach_count(), 1);
1649 monitor.reset(dec!(10000));
1650 assert_eq!(monitor.breach_count(), 0);
1651 }
1652
1653 #[test]
1654 fn test_risk_monitor_max_drawdown_pct() {
1655 let mut monitor = RiskMonitor::new(dec!(10000));
1656 monitor.update(dec!(9000)); monitor.update(dec!(9500)); assert_eq!(monitor.max_drawdown_pct(), dec!(10));
1660 }
1661
1662 #[test]
1663 fn test_risk_monitor_drawdown_duration_zero_at_peak() {
1664 let mut monitor = RiskMonitor::new(dec!(10000));
1665 monitor.update(dec!(10100)); assert_eq!(monitor.drawdown_duration(), 0);
1667 }
1668
1669 #[test]
1670 fn test_risk_monitor_drawdown_duration_increments() {
1671 let mut monitor = RiskMonitor::new(dec!(10000));
1672 monitor.update(dec!(10100)); monitor.update(dec!(9900)); monitor.update(dec!(9800)); assert_eq!(monitor.drawdown_duration(), 2);
1676 }
1677
1678 #[test]
1679 fn test_risk_monitor_equity_history_len() {
1680 let mut monitor = RiskMonitor::new(dec!(10000));
1681 assert_eq!(monitor.equity_history_len(), 0);
1682 monitor.update(dec!(10000));
1683 monitor.update(dec!(9500));
1684 assert_eq!(monitor.equity_history_len(), 2);
1685 }
1686
1687 #[test]
1688 fn test_drawdown_tracker_win_rate_none_when_empty() {
1689 let tracker = DrawdownTracker::new(dec!(10000));
1690 assert!(tracker.win_rate().is_none());
1691 }
1692
1693 #[test]
1694 fn test_drawdown_tracker_win_rate_all_up() {
1695 let mut tracker = DrawdownTracker::new(dec!(10000));
1696 tracker.update(dec!(10100));
1697 tracker.update(dec!(10200));
1698 assert_eq!(tracker.win_rate().unwrap(), dec!(1));
1700 }
1701
1702 #[test]
1703 fn test_drawdown_tracker_win_rate_half() {
1704 let mut tracker = DrawdownTracker::new(dec!(10000));
1705 tracker.update(dec!(10100)); tracker.update(dec!(9900)); assert_eq!(tracker.win_rate().unwrap(), dec!(0.5));
1709 }
1710
1711 #[test]
1712 fn test_risk_monitor_no_breach_at_start() {
1713 let mut monitor = RiskMonitor::new(dec!(10000)).add_rule(MaxDrawdownRule {
1714 threshold_pct: dec!(10),
1715 });
1716 let breaches = monitor.update(dec!(10000));
1717 assert!(breaches.is_empty());
1718 }
1719
1720 #[test]
1721 fn test_risk_monitor_partial_breach() {
1722 let mut monitor = RiskMonitor::new(dec!(10000))
1723 .add_rule(MaxDrawdownRule {
1724 threshold_pct: dec!(5),
1725 })
1726 .add_rule(MinEquityRule { floor: dec!(5000) });
1727 let breaches = monitor.update(dec!(9000));
1728 assert_eq!(breaches.len(), 1);
1729 assert_eq!(breaches[0].rule, "max_drawdown");
1730 }
1731
1732 #[test]
1733 fn test_drawdown_recovery() {
1734 let mut monitor = RiskMonitor::new(dec!(10000)).add_rule(MaxDrawdownRule {
1735 threshold_pct: dec!(10),
1736 });
1737 let breaches = monitor.update(dec!(8000));
1738 assert_eq!(breaches.len(), 1);
1739 let breaches = monitor.update(dec!(10000));
1740 assert!(breaches.is_empty(), "no breach after recovery to peak");
1741 let breaches = monitor.update(dec!(12000));
1742 assert!(breaches.is_empty(), "no breach after rising above old peak");
1743 let breaches = monitor.update(dec!(11500));
1744 assert!(
1745 breaches.is_empty(),
1746 "small dip from new peak should not breach"
1747 );
1748 }
1749
1750 #[test]
1751 fn test_drawdown_flat_series_is_zero() {
1752 let mut t = DrawdownTracker::new(dec!(10000));
1753 for _ in 0..10 {
1754 t.update(dec!(10000));
1755 }
1756 assert_eq!(t.current_drawdown_pct(), dec!(0));
1757 }
1758
1759 #[test]
1760 fn test_drawdown_monotonic_decline_full_loss() {
1761 let mut t = DrawdownTracker::new(dec!(10000));
1762 t.update(dec!(5000));
1763 t.update(dec!(2500));
1764 t.update(dec!(1000));
1765 t.update(dec!(0));
1766 assert_eq!(t.current_drawdown_pct(), dec!(100));
1767 }
1768
1769 #[test]
1770 fn test_risk_monitor_multiple_rules_all_must_pass() {
1771 let mut monitor = RiskMonitor::new(dec!(10000))
1772 .add_rule(MaxDrawdownRule {
1773 threshold_pct: dec!(5),
1774 })
1775 .add_rule(MinEquityRule { floor: dec!(9500) });
1776 let breaches = monitor.update(dec!(9400));
1777 assert_eq!(breaches.len(), 2, "both rules should trigger");
1778 let breaches = monitor.update(dec!(10000));
1779 assert!(breaches.is_empty(), "all rules pass at peak");
1780 let breaches = monitor.update(dec!(9600));
1781 assert!(
1782 breaches.is_empty(),
1783 "9600 is above the 9500 floor and within 5% drawdown"
1784 );
1785 let breaches = monitor.update(dec!(9400));
1786 assert_eq!(
1787 breaches.len(),
1788 2,
1789 "both rules fire when equity drops to 9400 again"
1790 );
1791 }
1792
1793 #[test]
1794 fn test_risk_monitor_drawdown_pct_accessor() {
1795 let mut monitor = RiskMonitor::new(dec!(10000)).add_rule(MaxDrawdownRule {
1796 threshold_pct: dec!(20),
1797 });
1798 monitor.update(dec!(8000));
1799 assert_eq!(monitor.drawdown_pct(), dec!(20));
1800 }
1801
1802 #[test]
1803 fn test_risk_monitor_current_equity_accessor() {
1804 let mut monitor = RiskMonitor::new(dec!(10000)).add_rule(MaxDrawdownRule {
1805 threshold_pct: dec!(20),
1806 });
1807 monitor.update(dec!(9500));
1808 assert_eq!(monitor.current_equity(), dec!(9500));
1809 }
1810
1811 #[test]
1812 fn test_risk_rule_name_returns_str() {
1813 let rule: &dyn RiskRule = &MaxDrawdownRule {
1814 threshold_pct: dec!(10),
1815 };
1816 let name: &str = rule.name();
1817 assert_eq!(name, "max_drawdown");
1818 }
1819
1820 #[test]
1821 fn test_drawdown_tracker_reset_clears_peak() {
1822 let mut t = DrawdownTracker::new(dec!(10000));
1823 t.update(dec!(8000));
1824 assert_eq!(t.current_drawdown_pct(), dec!(20));
1825 t.reset(dec!(5000));
1826 assert_eq!(t.peak(), dec!(5000));
1827 assert_eq!(t.current_equity(), dec!(5000));
1828 assert_eq!(t.current_drawdown_pct(), dec!(0));
1829 }
1830
1831 #[test]
1832 fn test_drawdown_tracker_reset_then_update() {
1833 let mut t = DrawdownTracker::new(dec!(10000));
1834 t.reset(dec!(2000));
1835 t.update(dec!(1800));
1836 assert_eq!(t.current_drawdown_pct(), dec!(10));
1837 }
1838
1839 #[test]
1840 fn test_drawdown_tracker_worst_drawdown_pct_accumulates() {
1841 let mut t = DrawdownTracker::new(dec!(10000));
1842 t.update(dec!(9000)); t.update(dec!(9500)); t.update(dec!(10100)); t.update(dec!(9595)); assert_eq!(t.worst_drawdown_pct(), dec!(10));
1847 }
1848
1849 #[test]
1850 fn test_drawdown_tracker_worst_resets_on_full_reset() {
1851 let mut t = DrawdownTracker::new(dec!(10000));
1852 t.update(dec!(8000)); assert_eq!(t.worst_drawdown_pct(), dec!(20));
1854 t.reset(dec!(5000));
1855 assert_eq!(t.worst_drawdown_pct(), dec!(0));
1856 }
1857
1858 #[test]
1859 fn test_risk_monitor_reset_clears_drawdown_state() {
1860 let mut monitor = RiskMonitor::new(dec!(10000))
1861 .add_rule(MaxDrawdownRule { threshold_pct: dec!(15) });
1862 monitor.update(dec!(8000)); let breaches = monitor.update(dec!(8000));
1864 assert!(!breaches.is_empty());
1865 monitor.reset(dec!(10000));
1866 let breaches_after = monitor.update(dec!(9800)); assert!(breaches_after.is_empty());
1868 }
1869
1870 #[test]
1871 fn test_risk_monitor_reset_restores_peak() {
1872 let mut monitor = RiskMonitor::new(dec!(10000));
1873 monitor.update(dec!(9000));
1874 monitor.reset(dec!(5000));
1875 assert_eq!(monitor.peak_equity(), dec!(5000));
1876 assert_eq!(monitor.current_equity(), dec!(5000));
1877 }
1878
1879 #[test]
1880 fn test_risk_monitor_worst_drawdown_tracks_maximum() {
1881 let mut monitor = RiskMonitor::new(dec!(10000));
1882 monitor.update(dec!(9000)); monitor.update(dec!(8000)); monitor.update(dec!(9500)); assert_eq!(monitor.worst_drawdown_pct(), dec!(20));
1886 }
1887
1888 #[test]
1889 fn test_risk_monitor_worst_drawdown_zero_at_start() {
1890 let monitor = RiskMonitor::new(dec!(10000));
1891 assert_eq!(monitor.worst_drawdown_pct(), dec!(0));
1892 }
1893
1894 #[test]
1895 fn test_drawdown_tracker_display() {
1896 let mut t = DrawdownTracker::new(dec!(10000));
1897 t.update(dec!(9000));
1898 let s = format!("{t}");
1899 assert!(s.contains("9000"), "display should include current equity");
1900 assert!(s.contains("10000"), "display should include peak");
1901 assert!(s.contains("10.00"), "display should include drawdown pct");
1902 }
1903
1904 #[test]
1905 fn test_drawdown_tracker_recovery_factor() {
1906 let mut t = DrawdownTracker::new(dec!(10000));
1907 t.update(dec!(9000)); let rf = t.recovery_factor(dec!(20)).unwrap();
1910 assert_eq!(rf, dec!(2));
1911 }
1912
1913 #[test]
1914 fn test_drawdown_tracker_recovery_factor_no_drawdown() {
1915 let t = DrawdownTracker::new(dec!(10000));
1916 assert!(t.recovery_factor(dec!(20)).is_none());
1917 }
1918
1919 #[test]
1920 fn test_risk_monitor_check_non_mutating() {
1921 let monitor = RiskMonitor::new(dec!(10000))
1922 .add_rule(MaxDrawdownRule { threshold_pct: dec!(15) });
1923 let breaches = monitor.check(dec!(8000));
1925 assert_eq!(breaches.len(), 1);
1926 assert_eq!(monitor.peak_equity(), dec!(10000));
1928 assert_eq!(monitor.current_equity(), dec!(10000));
1929 }
1930
1931 #[test]
1932 fn test_risk_monitor_check_no_breach() {
1933 let monitor = RiskMonitor::new(dec!(10000))
1934 .add_rule(MaxDrawdownRule { threshold_pct: dec!(15) });
1935 let breaches = monitor.check(dec!(9000)); assert!(breaches.is_empty());
1937 }
1938
1939 #[test]
1940 fn test_drawdown_tracker_in_drawdown_false_at_peak() {
1941 let tracker = DrawdownTracker::new(dec!(10000));
1942 assert!(!tracker.in_drawdown());
1943 }
1944
1945 #[test]
1946 fn test_drawdown_tracker_in_drawdown_true_below_peak() {
1947 let mut tracker = DrawdownTracker::new(dec!(10000));
1948 tracker.update(dec!(9000));
1949 assert!(tracker.in_drawdown());
1950 }
1951
1952 #[test]
1953 fn test_drawdown_tracker_in_drawdown_false_at_new_peak() {
1954 let mut tracker = DrawdownTracker::new(dec!(10000));
1955 tracker.update(dec!(11000));
1956 assert!(!tracker.in_drawdown());
1957 }
1958
1959 #[test]
1960 fn test_drawdown_tracker_drawdown_count_increases() {
1961 let mut tracker = DrawdownTracker::new(dec!(10000));
1962 tracker.update(dec!(9500));
1963 tracker.update(dec!(9000));
1964 assert_eq!(tracker.drawdown_count(), 2);
1965 }
1966
1967 #[test]
1968 fn test_drawdown_tracker_drawdown_count_resets_on_peak() {
1969 let mut tracker = DrawdownTracker::new(dec!(10000));
1970 tracker.update(dec!(9000));
1971 tracker.update(dec!(11000)); assert_eq!(tracker.drawdown_count(), 0);
1973 }
1974
1975 #[test]
1976 fn test_risk_monitor_has_breaches_true() {
1977 let monitor = RiskMonitor::new(dec!(10000))
1978 .add_rule(MaxDrawdownRule { threshold_pct: dec!(5) });
1979 assert!(monitor.has_breaches(dec!(9000))); }
1981
1982 #[test]
1983 fn test_risk_monitor_has_breaches_false() {
1984 let monitor = RiskMonitor::new(dec!(10000))
1985 .add_rule(MaxDrawdownRule { threshold_pct: dec!(15) });
1986 assert!(!monitor.has_breaches(dec!(9000))); }
1988
1989 #[test]
1990 fn test_risk_monitor_is_in_drawdown_true() {
1991 let mut monitor = RiskMonitor::new(dec!(10000)).add_rule(MaxDrawdownRule { threshold_pct: dec!(50) });
1992 monitor.update(dec!(9000));
1993 assert!(monitor.is_in_drawdown());
1994 }
1995
1996 #[test]
1997 fn test_risk_monitor_is_in_drawdown_false_at_peak() {
1998 let mut monitor = RiskMonitor::new(dec!(10000)).add_rule(MaxDrawdownRule { threshold_pct: dec!(50) });
1999 monitor.update(dec!(10000));
2000 assert!(!monitor.is_in_drawdown());
2001 }
2002
2003 #[test]
2004 fn test_risk_monitor_is_in_drawdown_false_above_peak() {
2005 let mut monitor = RiskMonitor::new(dec!(10000)).add_rule(MaxDrawdownRule { threshold_pct: dec!(50) });
2006 monitor.update(dec!(11000));
2007 assert!(!monitor.is_in_drawdown());
2008 }
2009
2010 #[test]
2011 fn test_recovery_to_peak_pct_at_peak_is_zero() {
2012 let tracker = DrawdownTracker::new(dec!(10000));
2013 assert_eq!(tracker.recovery_to_peak_pct(), dec!(0));
2014 }
2015
2016 #[test]
2017 fn test_recovery_to_peak_pct_with_drawdown() {
2018 let mut tracker = DrawdownTracker::new(dec!(10000));
2019 tracker.update(dec!(8000)); assert_eq!(tracker.recovery_to_peak_pct(), dec!(25));
2022 }
2023
2024 #[test]
2025 fn test_recovery_to_peak_pct_above_peak_is_zero() {
2026 let mut tracker = DrawdownTracker::new(dec!(10000));
2027 tracker.update(dec!(12000)); assert_eq!(tracker.recovery_to_peak_pct(), dec!(0));
2029 }
2030
2031 #[test]
2032 fn test_calmar_ratio_with_drawdown() {
2033 let mut tracker = DrawdownTracker::new(dec!(10000));
2034 tracker.update(dec!(9000)); let ratio = tracker.calmar_ratio(dec!(20)).unwrap();
2037 assert_eq!(ratio, dec!(2));
2038 }
2039
2040 #[test]
2041 fn test_calmar_ratio_none_when_no_drawdown() {
2042 let tracker = DrawdownTracker::new(dec!(10000));
2043 assert!(tracker.calmar_ratio(dec!(20)).is_none());
2045 }
2046
2047 #[test]
2048 fn test_sharpe_ratio_basic() {
2049 let tracker = DrawdownTracker::new(dec!(10000));
2050 assert_eq!(tracker.sharpe_ratio(dec!(15), dec!(5)), Some(dec!(3)));
2052 }
2053
2054 #[test]
2055 fn test_sharpe_ratio_none_when_vol_zero() {
2056 let tracker = DrawdownTracker::new(dec!(10000));
2057 assert!(tracker.sharpe_ratio(dec!(15), dec!(0)).is_none());
2058 }
2059
2060 #[test]
2061 fn test_time_underwater_pct_no_updates_returns_zero() {
2062 let tracker = DrawdownTracker::new(dec!(10000));
2063 assert_eq!(tracker.time_underwater_pct(), dec!(0));
2064 }
2065
2066 #[test]
2067 fn test_time_underwater_pct_all_in_drawdown() {
2068 let mut tracker = DrawdownTracker::new(dec!(10000));
2069 tracker.update(dec!(9000));
2070 tracker.update(dec!(8000));
2071 assert_eq!(tracker.time_underwater_pct(), dec!(1));
2073 }
2074
2075 #[test]
2076 fn test_time_underwater_pct_half_in_drawdown() {
2077 let mut tracker = DrawdownTracker::new(dec!(10000));
2078 tracker.update(dec!(11000)); tracker.update(dec!(10000)); assert_eq!(tracker.time_underwater_pct(), Decimal::new(5, 1));
2081 }
2082
2083 #[test]
2084 fn test_avg_drawdown_pct_none_when_no_drawdown() {
2085 let mut tracker = DrawdownTracker::new(dec!(10000));
2086 tracker.update(dec!(11000));
2087 assert!(tracker.avg_drawdown_pct().is_none());
2088 }
2089
2090 #[test]
2091 fn test_avg_drawdown_pct_positive_when_drawdown() {
2092 let mut tracker = DrawdownTracker::new(dec!(10000));
2093 tracker.update(dec!(9000)); let avg = tracker.avg_drawdown_pct().unwrap();
2095 assert!(avg > dec!(0));
2096 }
2097
2098 #[test]
2099 fn test_max_loss_streak_zero_when_no_drawdown() {
2100 let mut tracker = DrawdownTracker::new(dec!(10000));
2101 tracker.update(dec!(11000));
2102 tracker.update(dec!(12000));
2103 assert_eq!(tracker.max_loss_streak(), 0);
2104 }
2105
2106 #[test]
2107 fn test_max_loss_streak_tracks_longest_run() {
2108 let mut tracker = DrawdownTracker::new(dec!(10000));
2109 tracker.update(dec!(9000)); tracker.update(dec!(8000)); tracker.update(dec!(11000)); tracker.update(dec!(10000)); assert_eq!(tracker.max_loss_streak(), 2);
2114 }
2115
2116 #[test]
2117 fn test_reset_clears_new_fields() {
2118 let mut tracker = DrawdownTracker::new(dec!(10000));
2119 tracker.update(dec!(9000));
2120 tracker.update(dec!(8000));
2121 tracker.reset(dec!(10000));
2122 assert_eq!(tracker.time_underwater_pct(), dec!(0));
2123 assert!(tracker.avg_drawdown_pct().is_none());
2124 assert_eq!(tracker.max_loss_streak(), 0);
2125 }
2126
2127 #[test]
2128 fn test_consecutive_gain_updates_zero_initially() {
2129 let tracker = DrawdownTracker::new(dec!(10000));
2130 assert_eq!(tracker.consecutive_gain_updates(), 0);
2131 }
2132
2133 #[test]
2134 fn test_consecutive_gain_updates_increments_on_rising_equity() {
2135 let mut tracker = DrawdownTracker::new(dec!(10000));
2136 tracker.update(dec!(10100));
2137 tracker.update(dec!(10200));
2138 tracker.update(dec!(10300));
2139 assert_eq!(tracker.consecutive_gain_updates(), 3);
2140 }
2141
2142 #[test]
2143 fn test_consecutive_gain_updates_resets_on_drop() {
2144 let mut tracker = DrawdownTracker::new(dec!(10000));
2145 tracker.update(dec!(10100));
2146 tracker.update(dec!(10200));
2147 tracker.update(dec!(10100)); assert_eq!(tracker.consecutive_gain_updates(), 0);
2149 }
2150
2151 #[test]
2152 fn test_consecutive_gain_updates_resumes_after_drop() {
2153 let mut tracker = DrawdownTracker::new(dec!(10000));
2154 tracker.update(dec!(10100));
2155 tracker.update(dec!(9900)); tracker.update(dec!(10000)); tracker.update(dec!(10100));
2158 assert_eq!(tracker.consecutive_gain_updates(), 2);
2159 }
2160
2161 #[test]
2162 fn test_consecutive_gain_updates_clears_on_reset() {
2163 let mut tracker = DrawdownTracker::new(dec!(10000));
2164 tracker.update(dec!(11000));
2165 tracker.update(dec!(12000));
2166 tracker.reset(dec!(10000));
2167 assert_eq!(tracker.consecutive_gain_updates(), 0);
2168 }
2169
2170 #[test]
2171 fn test_equity_ratio_at_peak_is_one() {
2172 let mut tracker = DrawdownTracker::new(dec!(10000));
2173 tracker.update(dec!(10000));
2174 assert_eq!(tracker.equity_ratio(), Decimal::ONE);
2175 }
2176
2177 #[test]
2178 fn test_equity_ratio_in_drawdown() {
2179 let mut tracker = DrawdownTracker::new(dec!(10000));
2180 tracker.update(dec!(9000));
2181 assert_eq!(tracker.equity_ratio(), dec!(0.9));
2182 }
2183
2184 #[test]
2185 fn test_equity_ratio_new_peak() {
2186 let mut tracker = DrawdownTracker::new(dec!(10000));
2187 tracker.update(dec!(12000));
2188 assert_eq!(tracker.equity_ratio(), Decimal::ONE);
2189 }
2190
2191 #[test]
2192 fn test_new_peak_count_zero_initially() {
2193 let tracker = DrawdownTracker::new(dec!(10000));
2194 assert_eq!(tracker.new_peak_count(), 0);
2195 }
2196
2197 #[test]
2198 fn test_new_peak_count_increments() {
2199 let mut tracker = DrawdownTracker::new(dec!(10000));
2200 tracker.update(dec!(11000));
2201 tracker.update(dec!(9000)); tracker.update(dec!(12000)); assert_eq!(tracker.new_peak_count(), 2);
2204 }
2205
2206 #[test]
2207 fn test_new_peak_count_resets() {
2208 let mut tracker = DrawdownTracker::new(dec!(10000));
2209 tracker.update(dec!(11000));
2210 tracker.update(dec!(12000));
2211 tracker.reset(dec!(10000));
2212 assert_eq!(tracker.new_peak_count(), 0);
2213 }
2214
2215 #[test]
2216 fn test_omega_ratio_positive_threshold_zero() {
2217 let returns = vec![dec!(0.05), dec!(-0.02), dec!(0.03), dec!(-0.01)];
2218 let omega = DrawdownTracker::omega_ratio(&returns, Decimal::ZERO).unwrap();
2219 assert!(omega > 1.0, "expected omega > 1.0, got {omega}");
2221 }
2222
2223 #[test]
2224 fn test_omega_ratio_empty_returns_none() {
2225 assert!(DrawdownTracker::omega_ratio(&[], Decimal::ZERO).is_none());
2226 }
2227
2228 #[test]
2229 fn test_omega_ratio_no_downside_returns_none() {
2230 let returns = vec![dec!(0.01), dec!(0.02), dec!(0.03)];
2231 assert!(DrawdownTracker::omega_ratio(&returns, Decimal::ZERO).is_none());
2232 }
2233
2234 #[test]
2235 fn test_tail_ratio_none_below_20_obs() {
2236 let returns: Vec<Decimal> = (0..19).map(|_| dec!(0.01)).collect();
2237 assert!(RiskMonitor::tail_ratio(&returns).is_none());
2238 }
2239
2240 #[test]
2241 fn test_tail_ratio_positive_skewed_series() {
2242 let mut returns: Vec<Decimal> = (0..19).map(|_| dec!(-0.005)).collect();
2244 returns.push(dec!(0.1)); let ratio = RiskMonitor::tail_ratio(&returns).unwrap();
2246 assert!(ratio > 0.0, "tail ratio should be positive: {ratio}");
2247 }
2248
2249 #[test]
2250 fn test_skewness_none_below_3() {
2251 assert!(RiskMonitor::skewness(&[dec!(0.01), dec!(0.02)]).is_none());
2252 }
2253
2254 #[test]
2255 fn test_skewness_symmetric_near_zero() {
2256 let returns = vec![dec!(-1), dec!(0), dec!(1)];
2258 let sk = RiskMonitor::skewness(&returns).unwrap();
2259 assert!(sk.abs() < 1e-9, "symmetric series should have ~0 skew: {sk}");
2260 }
2261
2262 #[test]
2263 fn test_skewness_right_skewed_positive() {
2264 let mut returns: Vec<Decimal> = (0..10).map(|_| dec!(0)).collect();
2266 returns.push(dec!(100));
2267 let sk = RiskMonitor::skewness(&returns).unwrap();
2268 assert!(sk > 0.0, "right-skewed series should have positive skew: {sk}");
2269 }
2270
2271 #[test]
2272 fn test_calmar_ratio_none_at_peak() {
2273 let monitor = RiskMonitor::new(dec!(10000));
2275 assert!(monitor.calmar_ratio(15.0).is_none());
2276 }
2277
2278 #[test]
2279 fn test_calmar_ratio_positive_after_drawdown() {
2280 let mut monitor = RiskMonitor::new(dec!(10000));
2281 monitor.update(dec!(9000)); let calmar = monitor.calmar_ratio(15.0).unwrap();
2283 assert!((calmar - 1.5).abs() < 0.001, "calmar should be ~1.5: {calmar}");
2284 }
2285}