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 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 pub fn is_at_peak(&self) -> bool {
703 self.current_equity >= self.peak_equity
704 }
705
706 pub fn below_initial_equity(&self) -> bool {
708 self.current_equity < self.initial_equity
709 }
710
711 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 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 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 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 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 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 pub fn recovery_count(&self) -> usize {
779 self.completed_recoveries
780 }
781
782 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 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 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 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 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 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 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 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 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 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 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 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 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#[derive(Debug, Clone, PartialEq)]
1001pub struct RiskBreach {
1002 pub rule: String,
1004 pub detail: String,
1006}
1007
1008pub trait RiskRule: Send {
1010 fn name(&self) -> &str;
1012
1013 fn check(&self, equity: Decimal, drawdown_pct: Decimal) -> Option<RiskBreach>;
1019}
1020
1021pub struct MaxDrawdownRule {
1023 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
1044pub struct MinEquityRule {
1046 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
1067pub struct EquityGainTargetRule {
1072 pub target_pct: Decimal,
1074 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
1104pub struct MaxLossFromInitialRule {
1109 pub max_loss_pct: Decimal,
1111 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
1141pub struct MaxConsecutiveLossRule {
1150 pub max_consecutive: usize,
1152 streak: std::cell::Cell<usize>,
1153 last_equity: std::cell::Cell<u64>, }
1155
1156impl MaxConsecutiveLossRule {
1157 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 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
1206pub struct VolatilityLimitRule {
1211 pub threshold_pct: Decimal,
1213 pub window: usize,
1215 history: std::cell::RefCell<std::collections::VecDeque<Decimal>>,
1216}
1217
1218impl VolatilityLimitRule {
1219 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 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 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
1277pub struct RiskMonitor {
1279 rules: Vec<Box<dyn RiskRule>>,
1280 tracker: DrawdownTracker,
1281 breach_count: usize,
1282}
1283
1284impl RiskMonitor {
1285 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 #[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 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 pub fn drawdown_pct(&self) -> Decimal {
1315 self.tracker.current_drawdown_pct()
1316 }
1317
1318 pub fn current_equity(&self) -> Decimal {
1320 self.tracker.current_equity()
1321 }
1322
1323 pub fn peak_equity(&self) -> Decimal {
1325 self.tracker.peak()
1326 }
1327
1328 pub fn reset(&mut self, initial_equity: Decimal) {
1330 self.tracker.reset(initial_equity);
1331 self.breach_count = 0;
1332 }
1333
1334 pub fn rule_count(&self) -> usize {
1336 self.rules.len()
1337 }
1338
1339 pub fn reset_peak(&mut self) {
1344 self.tracker.reset_peak();
1345 }
1346
1347 pub fn is_in_drawdown(&self) -> bool {
1349 self.tracker.current_drawdown_pct() > Decimal::ZERO
1350 }
1351
1352 pub fn worst_drawdown_pct(&self) -> Decimal {
1354 self.tracker.worst_drawdown_pct()
1355 }
1356
1357 pub fn equity_history_len(&self) -> usize {
1359 self.tracker.update_count()
1360 }
1361
1362 pub fn drawdown_duration(&self) -> usize {
1364 self.tracker.drawdown_duration()
1365 }
1366
1367 pub fn breach_count(&self) -> usize {
1369 self.breach_count
1370 }
1371
1372 pub fn max_drawdown_pct(&self) -> Decimal {
1376 self.tracker.worst_drawdown_pct()
1377 }
1378
1379 pub fn drawdown_tracker(&self) -> &DrawdownTracker {
1384 &self.tracker
1385 }
1386
1387 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 pub fn has_breaches(&self, equity: Decimal) -> bool {
1408 !self.check(equity).is_empty()
1409 }
1410
1411 pub fn win_rate(&self) -> Option<Decimal> {
1416 self.tracker.win_rate()
1417 }
1418
1419 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 pub fn consecutive_gain_updates(&self) -> usize {
1436 self.tracker.consecutive_gain_updates()
1437 }
1438
1439 pub fn equity_at_risk(&self, pct: Decimal) -> Decimal {
1444 self.tracker.peak() * pct / Decimal::ONE_HUNDRED
1445 }
1446
1447 pub fn trailing_stop_level(&self, pct: Decimal) -> Decimal {
1454 self.tracker.peak() * (Decimal::ONE_HUNDRED - pct) / Decimal::ONE_HUNDRED
1455 }
1456
1457 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 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 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 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 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 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 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 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 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 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); if avg_loss <= 0.0 { return None; }
1623 let avg_gain = std + mean.max(0.0); Some(avg_gain / avg_loss)
1625 }
1626
1627 pub fn streaks(&self) -> (usize, usize, usize, usize) {
1632 (
1633 self.gain_streak,
1634 self.gain_streak, self.updates_since_peak,
1636 self.max_drawdown_streak,
1637 )
1638 }
1639
1640 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 pub fn max_consecutive_underwater(&self) -> usize {
1654 self.max_drawdown_streak
1655 }
1656
1657 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 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 pub fn sortino_proxy(&self, annualized_return: f64, periods_per_year: u32) -> Option<f64> {
1679 if self.equity_change_count < 2 { return None; }
1680 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 #[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 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 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 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 #[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 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)); assert_eq!(monitor.breach_count(), 1);
1855 monitor.update(dec!(8500)); 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)); monitor.update(dec!(9500)); 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)); 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)); monitor.update(dec!(9900)); monitor.update(dec!(9800)); 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 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)); tracker.update(dec!(9900)); 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)); t.update(dec!(9500)); t.update(dec!(10100)); t.update(dec!(9595)); 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)); 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)); let breaches = monitor.update(dec!(8000));
2080 assert!(!breaches.is_empty());
2081 monitor.reset(dec!(10000));
2082 let breaches_after = monitor.update(dec!(9800)); 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)); monitor.update(dec!(8000)); monitor.update(dec!(9500)); 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)); 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 let breaches = monitor.check(dec!(8000));
2141 assert_eq!(breaches.len(), 1);
2142 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)); 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)); 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))); }
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))); }
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)); 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)); 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)); 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 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 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 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)); tracker.update(dec!(10000)); 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)); 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)); tracker.update(dec!(8000)); tracker.update(dec!(11000)); tracker.update(dec!(10000)); 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)); 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)); tracker.update(dec!(10000)); 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)); tracker.update(dec!(12000)); 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 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 let mut returns: Vec<Decimal> = (0..19).map(|_| dec!(-0.005)).collect();
2460 returns.push(dec!(0.1)); 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 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 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 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)); let calmar = monitor.calmar_ratio(15.0).unwrap();
2499 assert!((calmar - 1.5).abs() < 0.001, "calmar should be ~1.5: {calmar}");
2500 }
2501}