1pub mod attribution;
15
16pub mod correlation_matrix;
17
18pub mod stress_scenarios;
20
21pub mod stress;
24
25pub mod var;
27
28pub mod var_engine;
30
31pub mod liquidity_risk;
34
35pub mod scenario_engine;
38
39pub mod credit_risk;
41
42use rust_decimal::Decimal;
43use rust_decimal::prelude::ToPrimitive;
44
45#[derive(Debug, Clone)]
47#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
48pub struct DrawdownTracker {
49 peak_equity: Decimal,
50 current_equity: Decimal,
51 worst_drawdown_pct: Decimal,
52 updates_since_peak: usize,
54 update_count: usize,
56 drawdown_update_count: usize,
58 #[cfg_attr(feature = "serde", serde(default))]
60 drawdown_pct_sum: Decimal,
61 #[cfg_attr(feature = "serde", serde(default))]
63 max_drawdown_streak: usize,
64 #[cfg_attr(feature = "serde", serde(default))]
66 gain_streak: usize,
67 #[cfg_attr(feature = "serde", serde(default))]
69 peak_count: usize,
70 #[cfg_attr(feature = "serde", serde(default))]
72 prev_equity: Decimal,
73 #[cfg_attr(feature = "serde", serde(default))]
75 equity_change_mean: f64,
76 #[cfg_attr(feature = "serde", serde(default))]
78 equity_change_m2: f64,
79 #[cfg_attr(feature = "serde", serde(default))]
81 equity_change_count: usize,
82 #[cfg_attr(feature = "serde", serde(default))]
84 min_equity_delta: f64,
85 #[cfg_attr(feature = "serde", serde(default))]
87 max_gain_streak: usize,
88 #[cfg_attr(feature = "serde", serde(default))]
90 total_gain_sum: f64,
91 #[cfg_attr(feature = "serde", serde(default))]
93 total_loss_sum: f64,
94 #[cfg_attr(feature = "serde", serde(default))]
96 completed_recoveries: usize,
97 #[cfg_attr(feature = "serde", serde(default))]
99 total_recovery_updates: usize,
100 #[cfg_attr(feature = "serde", serde(default))]
102 recovery_drawdown_pct_sum: Decimal,
103 #[cfg_attr(feature = "serde", serde(default))]
105 max_gain_delta_pct: f64,
106 #[cfg_attr(feature = "serde", serde(default))]
108 drawdown_episodes: usize,
109 #[cfg_attr(feature = "serde", serde(default))]
111 loss_streak_current: usize,
112 initial_equity: Decimal,
114 #[cfg_attr(feature = "serde", serde(default))]
116 flat_streak: usize,
117}
118
119impl DrawdownTracker {
120 pub fn new(initial_equity: Decimal) -> Self {
122 Self {
123 peak_equity: initial_equity,
124 current_equity: initial_equity,
125 worst_drawdown_pct: Decimal::ZERO,
126 updates_since_peak: 0,
127 update_count: 0,
128 drawdown_update_count: 0,
129 drawdown_pct_sum: Decimal::ZERO,
130 max_drawdown_streak: 0,
131 gain_streak: 0,
132 peak_count: 0,
133 prev_equity: initial_equity,
134 equity_change_mean: 0.0,
135 equity_change_m2: 0.0,
136 equity_change_count: 0,
137 min_equity_delta: 0.0,
138 max_gain_streak: 0,
139 total_gain_sum: 0.0,
140 total_loss_sum: 0.0,
141 completed_recoveries: 0,
142 total_recovery_updates: 0,
143 recovery_drawdown_pct_sum: Decimal::ZERO,
144 max_gain_delta_pct: 0.0,
145 drawdown_episodes: 0,
146 loss_streak_current: 0,
147 initial_equity,
148 flat_streak: 0,
149 }
150 }
151
152 pub fn update(&mut self, equity: Decimal) {
154 if self.update_count > 0 {
156 if let (Some(prev), Some(curr)) = (
157 self.prev_equity.to_f64(),
158 equity.to_f64(),
159 ) {
160 let delta = curr - prev;
161 self.equity_change_count += 1;
162 let n = self.equity_change_count as f64;
163 let old_mean = self.equity_change_mean;
164 self.equity_change_mean += (delta - old_mean) / n;
165 self.equity_change_m2 += (delta - old_mean) * (delta - self.equity_change_mean);
166 if delta < self.min_equity_delta {
167 self.min_equity_delta = delta;
168 }
169 if delta > 0.0 {
170 self.total_gain_sum += delta;
171 if prev > 0.0 {
172 let pct = delta / prev * 100.0;
173 if pct > self.max_gain_delta_pct {
174 self.max_gain_delta_pct = pct;
175 }
176 }
177 } else if delta < 0.0 {
178 self.total_loss_sum += -delta;
179 }
180 }
181 }
182 self.prev_equity = equity;
183
184 self.update_count += 1;
185 if equity > self.current_equity {
186 self.gain_streak += 1;
187 if self.gain_streak > self.max_gain_streak {
188 self.max_gain_streak = self.gain_streak;
189 }
190 self.loss_streak_current = 0;
191 self.flat_streak = 0;
192 } else if equity < self.current_equity {
193 self.gain_streak = 0;
194 self.loss_streak_current += 1;
195 self.flat_streak = 0;
196 } else {
197 self.gain_streak = 0;
198 self.loss_streak_current = 0;
199 self.flat_streak += 1;
200 }
201 if equity > self.peak_equity {
202 if self.updates_since_peak > 0 {
203 self.total_recovery_updates += self.updates_since_peak;
204 self.recovery_drawdown_pct_sum += self.current_drawdown_pct();
205 self.completed_recoveries += 1;
206 }
207 self.peak_equity = equity;
208 self.updates_since_peak = 0;
209 self.peak_count += 1;
210 } else {
211 if equity < self.peak_equity && self.updates_since_peak == 0 {
212 self.drawdown_episodes += 1;
213 }
214 self.updates_since_peak += 1;
215 self.drawdown_update_count += 1;
216 }
217 self.current_equity = equity;
218 let dd = self.current_drawdown_pct();
219 if dd > self.worst_drawdown_pct {
220 self.worst_drawdown_pct = dd;
221 }
222 if !dd.is_zero() {
223 self.drawdown_pct_sum += dd;
224 }
225 if self.updates_since_peak > self.max_drawdown_streak {
226 self.max_drawdown_streak = self.updates_since_peak;
227 }
228 }
229
230 pub fn drawdown_duration(&self) -> usize {
235 self.updates_since_peak
236 }
237
238 pub fn current_drawdown_pct(&self) -> Decimal {
242 if self.peak_equity == Decimal::ZERO {
243 return Decimal::ZERO;
244 }
245 (self.peak_equity - self.current_equity) / self.peak_equity * Decimal::ONE_HUNDRED
246 }
247
248 pub fn peak(&self) -> Decimal {
250 self.peak_equity
251 }
252
253 pub fn current_equity(&self) -> Decimal {
255 self.current_equity
256 }
257
258 pub fn is_below_threshold(&self, max_dd_pct: Decimal) -> bool {
260 self.current_drawdown_pct() <= max_dd_pct
261 }
262
263 pub fn reset_peak(&mut self) {
268 self.peak_equity = self.current_equity;
269 self.updates_since_peak = 0;
270 }
271
272 pub fn worst_drawdown_pct(&self) -> Decimal {
274 self.worst_drawdown_pct
275 }
276
277 pub fn update_count(&self) -> usize {
279 self.update_count
280 }
281
282 pub fn win_rate(&self) -> Option<Decimal> {
288 if self.update_count == 0 {
289 return None;
290 }
291 let at_peak = self.update_count - self.drawdown_update_count;
292 #[allow(clippy::cast_possible_truncation)]
293 Some(Decimal::from(at_peak as u64) / Decimal::from(self.update_count as u64))
294 }
295
296 pub fn underwater_pct(&self) -> Decimal {
302 if self.peak_equity == Decimal::ZERO {
303 return Decimal::ZERO;
304 }
305 let diff = self.peak_equity - self.current_equity;
306 if diff <= Decimal::ZERO {
307 return Decimal::ZERO;
308 }
309 diff / self.peak_equity * Decimal::ONE_HUNDRED
310 }
311
312 pub fn reset(&mut self, initial: Decimal) {
314 self.peak_equity = initial;
315 self.current_equity = initial;
316 self.drawdown_pct_sum = Decimal::ZERO;
317 self.max_drawdown_streak = 0;
318 self.worst_drawdown_pct = Decimal::ZERO;
319 self.updates_since_peak = 0;
320 self.update_count = 0;
321 self.drawdown_update_count = 0;
322 self.gain_streak = 0;
323 self.peak_count = 0;
324 self.prev_equity = initial;
325 self.equity_change_mean = 0.0;
326 self.equity_change_m2 = 0.0;
327 self.equity_change_count = 0;
328 self.min_equity_delta = 0.0;
329 self.max_gain_streak = 0;
330 self.total_gain_sum = 0.0;
331 self.total_loss_sum = 0.0;
332 self.completed_recoveries = 0;
333 self.total_recovery_updates = 0;
334 self.recovery_drawdown_pct_sum = Decimal::ZERO;
335 self.max_gain_delta_pct = 0.0;
336 self.drawdown_episodes = 0;
337 self.loss_streak_current = 0;
338 self.flat_streak = 0;
339 }
340
341 pub fn volatility(&self) -> Option<f64> {
346 if self.equity_change_count < 2 {
347 return None;
348 }
349 let variance = self.equity_change_m2 / (self.equity_change_count - 1) as f64;
350 Some(variance.sqrt())
351 }
352
353 pub fn recovery_factor(&self, net_profit_pct: Decimal) -> Option<Decimal> {
358 if self.worst_drawdown_pct.is_zero() {
359 return None;
360 }
361 Some(net_profit_pct / self.worst_drawdown_pct)
362 }
363
364 pub fn calmar_ratio(&self, annualized_return: Decimal) -> Option<Decimal> {
369 if self.worst_drawdown_pct.is_zero() {
370 return None;
371 }
372 Some(annualized_return / self.worst_drawdown_pct)
373 }
374
375 pub fn in_drawdown(&self) -> bool {
377 self.current_equity < self.peak_equity
378 }
379
380 pub fn update_with_returns(&mut self, equities: &[Decimal]) {
384 for &eq in equities {
385 self.update(eq);
386 }
387 }
388
389 pub fn drawdown_count(&self) -> usize {
394 self.updates_since_peak
395 }
396
397 pub fn sharpe_ratio(
401 &self,
402 annualized_return: Decimal,
403 annualized_vol: Decimal,
404 ) -> Option<Decimal> {
405 if annualized_vol.is_zero() {
406 return None;
407 }
408 Some(annualized_return / annualized_vol)
409 }
410
411 pub fn recovery_to_peak_pct(&self) -> Decimal {
416 if self.current_equity.is_zero() || self.current_equity >= self.peak_equity {
417 return Decimal::ZERO;
418 }
419 (self.peak_equity / self.current_equity - Decimal::ONE) * Decimal::ONE_HUNDRED
420 }
421
422 #[allow(clippy::cast_possible_truncation)]
426 pub fn time_underwater_pct(&self) -> Decimal {
427 if self.update_count == 0 {
428 return Decimal::ZERO;
429 }
430 Decimal::from(self.drawdown_update_count as u64)
431 / Decimal::from(self.update_count as u64)
432 }
433
434 #[allow(clippy::cast_possible_truncation)]
438 pub fn avg_drawdown_pct(&self) -> Option<Decimal> {
439 if self.drawdown_update_count == 0 {
440 return None;
441 }
442 Some(self.drawdown_pct_sum / Decimal::from(self.drawdown_update_count as u64))
443 }
444
445 pub fn max_loss_streak(&self) -> usize {
447 self.max_drawdown_streak.max(self.updates_since_peak)
448 }
449
450 pub fn consecutive_gain_updates(&self) -> usize {
454 self.gain_streak
455 }
456
457 pub fn equity_ratio(&self) -> Decimal {
462 if self.peak_equity.is_zero() {
463 return Decimal::ONE;
464 }
465 self.current_equity / self.peak_equity
466 }
467
468 pub fn new_peak_count(&self) -> usize {
470 self.peak_count
471 }
472
473 #[allow(clippy::cast_possible_truncation)]
480 pub fn pain_index(&self) -> Decimal {
481 if self.update_count == 0 {
482 return Decimal::ZERO;
483 }
484 self.drawdown_pct_sum / Decimal::from(self.update_count as u64)
485 }
486
487 pub fn above_high_water_mark(&self, equity: Decimal) -> bool {
492 equity > self.peak_equity
493 }
494
495 pub fn max_single_loss(&self) -> Option<f64> {
500 if self.equity_change_count == 0 || self.min_equity_delta >= 0.0 {
501 return None;
502 }
503 Some(-self.min_equity_delta)
504 }
505
506 pub fn loss_rate(&self) -> Option<f64> {
514 if self.update_count == 0 {
515 return None;
516 }
517 Some(self.drawdown_update_count as f64 / self.update_count as f64)
518 }
519
520 pub fn consecutive_loss_updates(&self) -> usize {
525 if self.gain_streak > 0 {
529 0
530 } else {
531 self.updates_since_peak
532 }
533 }
534
535 pub fn equity_change_mean(&self) -> Option<f64> {
540 if self.equity_change_count == 0 {
541 return None;
542 }
543 Some(self.equity_change_mean)
544 }
545
546 pub fn stress_test(&self, shock_pct: Decimal) -> Decimal {
553 if self.peak_equity.is_zero() {
554 return shock_pct;
555 }
556 let stressed_equity = self.current_equity
557 * (Decimal::ONE_HUNDRED - shock_pct)
558 / Decimal::ONE_HUNDRED;
559 if stressed_equity >= self.peak_equity {
560 return Decimal::ZERO;
561 }
562 (self.peak_equity - stressed_equity) / self.peak_equity * Decimal::ONE_HUNDRED
563 }
564
565 pub fn max_gain_streak(&self) -> usize {
567 self.max_gain_streak
568 }
569
570 pub fn total_gain_sum(&self) -> f64 {
574 self.total_gain_sum
575 }
576
577 pub fn total_loss_sum(&self) -> f64 {
581 self.total_loss_sum
582 }
583
584 pub fn gain_to_loss_ratio(&self) -> Option<f64> {
586 if self.total_loss_sum == 0.0 { None } else { Some(self.total_gain_sum / self.total_loss_sum) }
587 }
588
589 pub fn expectancy(&self) -> Option<f64> {
593 let n = self.equity_change_count;
594 if n < 2 { return None; }
595 let wr = self.win_rate()?.to_f64()?;
596 let loss_rate = 1.0 - wr;
597 let gain_count = (wr * n as f64).round() as usize;
598 let loss_count = n.saturating_sub(gain_count);
599 let avg_gain = if gain_count > 0 { self.total_gain_sum / gain_count as f64 } else { 0.0 };
600 let avg_loss = if loss_count > 0 { self.total_loss_sum / loss_count as f64 } else { 0.0 };
601 Some(wr * avg_gain - loss_rate * avg_loss)
602 }
603
604 pub fn recovery_speed(&self) -> Option<f64> {
608 if self.completed_recoveries == 0 { return None; }
609 Some(self.total_recovery_updates as f64 / self.completed_recoveries as f64)
610 }
611
612 pub fn peak_hit_count(&self) -> usize {
616 self.peak_count
617 }
618
619 pub fn avg_recovery_drawdown_pct(&self) -> Option<Decimal> {
623 if self.completed_recoveries == 0 { return None; }
624 #[allow(clippy::cast_possible_truncation)]
625 Some(self.recovery_drawdown_pct_sum / Decimal::from(self.completed_recoveries as u32))
626 }
627
628 pub fn max_gain_pct(&self) -> f64 {
632 self.max_gain_delta_pct
633 }
634
635 pub fn avg_drawdown_duration(&self) -> Option<f64> {
639 if self.drawdown_episodes == 0 { return None; }
640 Some(self.drawdown_update_count as f64 / self.drawdown_episodes as f64)
641 }
642
643 pub fn breakeven_equity(&self) -> Decimal {
647 self.peak_equity
648 }
649
650 pub fn loss_streak(&self) -> usize {
654 self.loss_streak_current
655 }
656
657 pub fn net_return_pct(&self) -> Option<f64> {
661 let init = self.initial_equity.to_f64()?;
662 if init == 0.0 { return None; }
663 let curr = self.current_equity.to_f64()?;
664 Some((curr - init) / init * 100.0)
665 }
666
667 pub fn consecutive_flat_count(&self) -> usize {
669 self.flat_streak
670 }
671
672 pub fn total_updates(&self) -> usize {
674 self.update_count
675 }
676
677 pub fn pct_time_in_drawdown(&self) -> f64 {
681 if self.update_count == 0 { return 0.0; }
682 self.drawdown_update_count as f64 / self.update_count as f64 * 100.0
683 }
684
685 pub fn equity_cagr(&self, periods_per_year: usize) -> Option<f64> {
690 if self.update_count < 2 || periods_per_year == 0 { return None; }
691 let init = self.initial_equity.to_f64()?;
692 if init <= 0.0 { return None; }
693 let curr = self.current_equity.to_f64()?;
694 if curr <= 0.0 { return None; }
695 let years = self.update_count as f64 / periods_per_year as f64;
696 Some((curr / init).powf(1.0 / years) - 1.0)
697 }
698
699 pub fn is_recovering(&self) -> bool {
701 self.in_drawdown() && self.gain_streak > 0
702 }
703
704 pub fn drawdown_ratio(&self) -> Decimal {
708 if self.worst_drawdown_pct.is_zero() { return Decimal::ZERO; }
709 self.current_drawdown_pct() / self.worst_drawdown_pct
710 }
711
712 pub fn equity_multiple(&self) -> Decimal {
714 if self.initial_equity.is_zero() { return Decimal::ONE; }
715 self.current_equity / self.initial_equity
716 }
717
718 pub fn avg_gain_pct(&self) -> Option<f64> {
723 use rust_decimal::prelude::ToPrimitive;
724 let wr = self.win_rate()?.to_f64()?;
725 let gain_count = (wr / 100.0 * self.update_count as f64).round() as usize;
726 if gain_count == 0 { return None; }
727 Some(self.total_gain_sum / gain_count as f64)
728 }
729
730 pub fn is_at_peak(&self) -> bool {
732 self.current_equity >= self.peak_equity
733 }
734
735 pub fn below_initial_equity(&self) -> bool {
737 self.current_equity < self.initial_equity
738 }
739
740 pub fn return_drawdown_ratio(&self) -> Option<f64> {
744 use rust_decimal::prelude::ToPrimitive;
745 if self.worst_drawdown_pct.is_zero() { return None; }
746 let net_ret = self.net_return_pct()?;
747 let dd = self.worst_drawdown_pct.to_f64()?;
748 if dd == 0.0 { return None; }
749 Some(net_ret / dd)
750 }
751
752 pub fn consecutive_flat_pct(&self) -> f64 {
756 if self.update_count == 0 { return 0.0; }
757 self.flat_streak as f64 / self.update_count as f64 * 100.0
758 }
759
760 pub fn current_streak(&self) -> i64 {
762 if self.gain_streak > 0 {
763 self.gain_streak as i64
764 } else if self.loss_streak_current > 0 {
765 -(self.loss_streak_current as i64)
766 } else {
767 0
768 }
769 }
770
771 pub fn max_loss_pct_single(&self) -> Option<f64> {
775 use rust_decimal::prelude::ToPrimitive;
776 if self.min_equity_delta >= 0.0 { return None; }
777 let peak = self.peak_equity.to_f64()?;
778 if peak <= 0.0 { return None; }
779 Some((self.min_equity_delta / peak).abs() * 100.0)
780 }
781
782 pub fn win_loss_ratio(&self) -> Option<f64> {
786 use rust_decimal::prelude::ToPrimitive;
787 let wr = self.win_rate()?.to_f64()?;
788 let lr = self.loss_rate()?;
789 if lr == 0.0 { return None; }
790 Some(wr / (lr * 100.0))
791 }
792
793 pub fn best_drawdown_recovery(&self) -> Option<f64> {
797 use rust_decimal::prelude::ToPrimitive;
798 if self.worst_drawdown_pct.is_zero() { return None; }
799 let max_gain = self.max_gain_pct();
800 if max_gain <= 0.0 { return None; }
801 let dd = self.worst_drawdown_pct.to_f64()?;
802 if dd == 0.0 { return None; }
803 Some(max_gain / dd)
804 }
805
806 pub fn recovery_count(&self) -> usize {
808 self.completed_recoveries
809 }
810
811 pub fn avg_gain_loss_ratio(&self) -> Option<f64> {
815 let avg_gain = self.avg_gain_pct()?;
816 let lr = self.loss_rate()?;
817 let loss_count = (lr * self.update_count as f64).round() as usize;
818 if loss_count == 0 { return None; }
819 let avg_loss = self.total_loss_sum / loss_count as f64;
820 if avg_loss == 0.0 { return None; }
821 Some(avg_gain / avg_loss)
822 }
823
824 pub fn time_to_recover_est(&self) -> Option<usize> {
829 use rust_decimal::prelude::ToPrimitive;
830 if !self.in_drawdown() { return None; }
831 let avg_gain = self.avg_gain_pct()?;
832 if avg_gain <= 0.0 { return None; }
833 let distance = self.current_drawdown_pct().to_f64()?;
834 Some((distance / avg_gain).ceil() as usize)
835 }
836
837 pub fn current_drawdown_absolute(&self) -> Decimal {
839 if self.current_equity >= self.peak_equity {
840 Decimal::ZERO
841 } else {
842 self.peak_equity - self.current_equity
843 }
844 }
845
846 pub fn median_drawdown_pct(drawdowns: &[Decimal]) -> Option<Decimal> {
850 if drawdowns.is_empty() { return None; }
851 let mut sorted = drawdowns.to_vec();
852 sorted.sort();
853 let mid = sorted.len() / 2;
854 if sorted.len() % 2 == 1 {
855 Some(sorted[mid])
856 } else {
857 Some((sorted[mid - 1] + sorted[mid]) / Decimal::TWO)
858 }
859 }
860
861 pub fn sortino_ratio(returns: &[Decimal], target: Decimal) -> Option<f64> {
868 if returns.is_empty() {
869 return None;
870 }
871 let n = returns.len() as f64;
872 let target_f = target.to_f64()?;
873 let mean: f64 = returns.iter().filter_map(|r| r.to_f64()).sum::<f64>() / n;
874 let downside_sq_sum: f64 = returns
875 .iter()
876 .filter_map(|r| r.to_f64())
877 .map(|r| {
878 let diff = r - target_f;
879 if diff < 0.0 { diff * diff } else { 0.0 }
880 })
881 .sum();
882 if downside_sq_sum == 0.0 {
883 return None;
884 }
885 let downside_dev = (downside_sq_sum / n).sqrt();
886 if downside_dev == 0.0 {
887 return None;
888 }
889 Some((mean - target_f) / downside_dev)
890 }
891
892 pub fn returns_volatility(returns: &[Decimal], periods_per_year: u32) -> Option<f64> {
898 if returns.len() < 2 {
899 return None;
900 }
901 let n = returns.len() as f64;
902 let mean: f64 = returns.iter()
903 .filter_map(|r| r.to_f64())
904 .sum::<f64>() / n;
905 let variance: f64 = returns.iter()
906 .filter_map(|r| r.to_f64())
907 .map(|r| (r - mean).powi(2))
908 .sum::<f64>() / (n - 1.0);
909 let vol = variance.sqrt() * (periods_per_year as f64).sqrt();
910 Some(vol)
911 }
912
913 pub fn omega_ratio(returns: &[Decimal], threshold: Decimal) -> Option<f64> {
918 if returns.is_empty() {
919 return None;
920 }
921 let threshold_f = threshold.to_f64()?;
922 let upside: f64 = returns
923 .iter()
924 .filter_map(|r| r.to_f64())
925 .map(|r| (r - threshold_f).max(0.0))
926 .sum();
927 let downside: f64 = returns
928 .iter()
929 .filter_map(|r| r.to_f64())
930 .map(|r| (threshold_f - r).max(0.0))
931 .sum();
932 if downside == 0.0 {
933 return None;
934 }
935 Some(upside / downside)
936 }
937
938 pub fn information_ratio(returns: &[Decimal], benchmark: &[Decimal]) -> Option<f64> {
943 let n = returns.len().min(benchmark.len());
944 if n < 2 {
945 return None;
946 }
947 let excess: Vec<f64> = returns[..n]
948 .iter()
949 .zip(benchmark[..n].iter())
950 .filter_map(|(r, b)| Some(r.to_f64()? - b.to_f64()?))
951 .collect();
952 if excess.len() < 2 {
953 return None;
954 }
955 let mean_excess = excess.iter().sum::<f64>() / excess.len() as f64;
956 let tracking_variance = excess.iter().map(|e| (e - mean_excess).powi(2)).sum::<f64>()
957 / (excess.len() as f64 - 1.0);
958 let tracking_error = tracking_variance.sqrt();
959 if tracking_error == 0.0 {
960 return None;
961 }
962 Some(mean_excess / tracking_error)
963 }
964
965 pub fn annualized_volatility(&self, periods_per_year: u32) -> Option<f64> {
969 if self.equity_change_count < 2 { return None; }
970 let n = self.equity_change_count as f64;
971 let variance = self.equity_change_m2 / (n - 1.0);
972 Some(variance.sqrt() * (periods_per_year as f64).sqrt())
973 }
974
975 pub fn pain_ratio(&self, annualized_return_pct: Decimal) -> Option<Decimal> {
980 let pi = self.pain_index();
981 if pi.is_zero() { return None; }
982 Some(annualized_return_pct / pi)
983 }
984
985 pub fn time_above_watermark_pct(&self) -> Decimal {
990 if self.update_count == 0 {
991 return Decimal::ONE;
992 }
993 Decimal::ONE - self.time_underwater_pct()
994 }
995
996 pub fn equity_change_std_dev(&self) -> Option<f64> {
1001 if self.equity_change_count < 2 { return None; }
1002 let variance = self.equity_change_m2 / (self.equity_change_count - 1) as f64;
1003 Some(variance.sqrt())
1004 }
1005
1006 pub fn gain_streak_ratio(&self) -> Option<f64> {
1011 if self.update_count == 0 { return None; }
1012 Some(self.max_gain_streak as f64 / self.update_count as f64)
1013 }
1014}
1015
1016impl std::fmt::Display for DrawdownTracker {
1017 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1018 write!(
1019 f,
1020 "equity={} peak={} drawdown={:.2}%",
1021 self.current_equity,
1022 self.peak_equity,
1023 self.current_drawdown_pct()
1024 )
1025 }
1026}
1027
1028#[derive(Debug, Clone, PartialEq)]
1030pub struct RiskBreach {
1031 pub rule: String,
1033 pub detail: String,
1035}
1036
1037pub trait RiskRule: Send {
1039 fn name(&self) -> &str;
1041
1042 fn check(&self, equity: Decimal, drawdown_pct: Decimal) -> Option<RiskBreach>;
1048}
1049
1050pub struct MaxDrawdownRule {
1052 pub threshold_pct: Decimal,
1054}
1055
1056impl RiskRule for MaxDrawdownRule {
1057 fn name(&self) -> &str {
1058 "max_drawdown"
1059 }
1060
1061 fn check(&self, _equity: Decimal, drawdown_pct: Decimal) -> Option<RiskBreach> {
1062 if drawdown_pct > self.threshold_pct {
1063 Some(RiskBreach {
1064 rule: self.name().to_owned(),
1065 detail: format!("drawdown {drawdown_pct:.2}% > {:.2}%", self.threshold_pct),
1066 })
1067 } else {
1068 None
1069 }
1070 }
1071}
1072
1073pub struct MinEquityRule {
1075 pub floor: Decimal,
1077}
1078
1079impl RiskRule for MinEquityRule {
1080 fn name(&self) -> &str {
1081 "min_equity"
1082 }
1083
1084 fn check(&self, equity: Decimal, _drawdown_pct: Decimal) -> Option<RiskBreach> {
1085 if equity < self.floor {
1086 Some(RiskBreach {
1087 rule: self.name().to_owned(),
1088 detail: format!("equity {equity} < floor {}", self.floor),
1089 })
1090 } else {
1091 None
1092 }
1093 }
1094}
1095
1096pub struct EquityGainTargetRule {
1101 pub target_pct: Decimal,
1103 pub initial_equity: Decimal,
1105}
1106
1107impl RiskRule for EquityGainTargetRule {
1108 fn name(&self) -> &str {
1109 "equity_gain_target"
1110 }
1111
1112 fn check(&self, equity: Decimal, _drawdown_pct: Decimal) -> Option<RiskBreach> {
1113 if self.initial_equity.is_zero() {
1114 return None;
1115 }
1116 let gain_pct = (equity - self.initial_equity)
1117 .checked_div(self.initial_equity)?
1118 .checked_mul(Decimal::ONE_HUNDRED)?;
1119 if gain_pct >= self.target_pct {
1120 Some(RiskBreach {
1121 rule: self.name().to_owned(),
1122 detail: format!(
1123 "equity gain {gain_pct:.2}% >= target {:.2}%",
1124 self.target_pct
1125 ),
1126 })
1127 } else {
1128 None
1129 }
1130 }
1131}
1132
1133pub struct MaxLossFromInitialRule {
1138 pub max_loss_pct: Decimal,
1140 pub initial_equity: Decimal,
1142}
1143
1144impl RiskRule for MaxLossFromInitialRule {
1145 fn name(&self) -> &str {
1146 "max_loss_from_initial"
1147 }
1148
1149 fn check(&self, equity: Decimal, _drawdown_pct: Decimal) -> Option<RiskBreach> {
1150 if self.initial_equity.is_zero() {
1151 return None;
1152 }
1153 let loss_pct = (self.initial_equity - equity)
1154 .checked_div(self.initial_equity)?
1155 .checked_mul(Decimal::ONE_HUNDRED)?;
1156 if loss_pct > self.max_loss_pct {
1157 Some(RiskBreach {
1158 rule: self.name().to_owned(),
1159 detail: format!(
1160 "loss from initial {loss_pct:.2}% > max {:.2}%",
1161 self.max_loss_pct
1162 ),
1163 })
1164 } else {
1165 None
1166 }
1167 }
1168}
1169
1170pub struct MaxConsecutiveLossRule {
1179 pub max_consecutive: usize,
1181 streak: std::cell::Cell<usize>,
1182 last_equity: std::cell::Cell<u64>, }
1184
1185impl MaxConsecutiveLossRule {
1186 pub fn new(max_consecutive: usize) -> Self {
1188 Self {
1189 max_consecutive,
1190 streak: std::cell::Cell::new(0),
1191 last_equity: std::cell::Cell::new(f64::NAN.to_bits()),
1192 }
1193 }
1194}
1195
1196impl RiskRule for MaxConsecutiveLossRule {
1197 fn name(&self) -> &str {
1198 "max_consecutive_loss"
1199 }
1200
1201 fn check(&self, equity: Decimal, _drawdown_pct: Decimal) -> Option<RiskBreach> {
1202 use rust_decimal::prelude::ToPrimitive;
1203 let prev_bits = self.last_equity.get();
1204 let prev = f64::from_bits(prev_bits);
1205 let curr = equity.to_f64().unwrap_or(f64::NAN);
1206 self.last_equity.set(curr.to_bits());
1207
1208 if prev.is_nan() {
1209 self.streak.set(0);
1211 return None;
1212 }
1213
1214 if curr < prev {
1215 self.streak.set(self.streak.get() + 1);
1216 } else {
1217 self.streak.set(0);
1218 }
1219
1220 if self.streak.get() >= self.max_consecutive {
1221 Some(RiskBreach {
1222 rule: self.name().to_owned(),
1223 detail: format!(
1224 "{} consecutive losing updates (limit {})",
1225 self.streak.get(),
1226 self.max_consecutive
1227 ),
1228 })
1229 } else {
1230 None
1231 }
1232 }
1233}
1234
1235pub struct VolatilityLimitRule {
1240 pub threshold_pct: Decimal,
1242 pub window: usize,
1244 history: std::cell::RefCell<std::collections::VecDeque<Decimal>>,
1245}
1246
1247impl VolatilityLimitRule {
1248 pub fn new(threshold_pct: Decimal, window: usize) -> Self {
1252 Self {
1253 threshold_pct,
1254 window: window.max(2),
1255 history: std::cell::RefCell::new(std::collections::VecDeque::with_capacity(window.max(2))),
1256 }
1257 }
1258}
1259
1260impl RiskRule for VolatilityLimitRule {
1261 fn name(&self) -> &str {
1262 "volatility_limit"
1263 }
1264
1265 fn check(&self, equity: Decimal, _drawdown_pct: Decimal) -> Option<RiskBreach> {
1266 let mut hist = self.history.borrow_mut();
1267 hist.push_back(equity);
1268 if hist.len() > self.window {
1269 hist.pop_front();
1270 }
1271 if hist.len() < 2 {
1272 return None;
1273 }
1274
1275 let returns: Vec<Decimal> = hist.iter().zip(hist.iter().skip(1)).filter_map(|(a, b)| {
1277 if a.is_zero() { return None; }
1278 Some((b - a) / *a * Decimal::ONE_HUNDRED)
1279 }).collect();
1280 if returns.len() < 2 { return None; }
1281
1282 #[allow(clippy::cast_possible_truncation)]
1283 let n = Decimal::from(returns.len() as u32);
1284 let mean = returns.iter().copied().sum::<Decimal>() / n;
1285 let variance = returns.iter().map(|r| (*r - mean) * (*r - mean)).sum::<Decimal>() / n;
1286 let std_dev_sq = variance;
1287
1288 let threshold_sq = self.threshold_pct * self.threshold_pct;
1290 if std_dev_sq > threshold_sq {
1291 use rust_decimal::prelude::ToPrimitive;
1292 let vol_approx = std_dev_sq.to_f64().unwrap_or(0.0).sqrt();
1293 Some(RiskBreach {
1294 rule: self.name().to_owned(),
1295 detail: format!(
1296 "equity volatility {vol_approx:.2}% > limit {:.2}%",
1297 self.threshold_pct
1298 ),
1299 })
1300 } else {
1301 None
1302 }
1303 }
1304}
1305
1306pub struct RiskMonitor {
1308 rules: Vec<Box<dyn RiskRule>>,
1309 tracker: DrawdownTracker,
1310 breach_count: usize,
1311}
1312
1313impl RiskMonitor {
1314 pub fn new(initial_equity: Decimal) -> Self {
1316 Self {
1317 rules: Vec::new(),
1318 tracker: DrawdownTracker::new(initial_equity),
1319 breach_count: 0,
1320 }
1321 }
1322
1323 #[must_use]
1325 pub fn add_rule(mut self, rule: impl RiskRule + 'static) -> Self {
1326 self.rules.push(Box::new(rule));
1327 self
1328 }
1329
1330 pub fn update(&mut self, equity: Decimal) -> Vec<RiskBreach> {
1332 self.tracker.update(equity);
1333 let dd = self.tracker.current_drawdown_pct();
1334 let breaches: Vec<RiskBreach> = self.rules
1335 .iter()
1336 .filter_map(|r| r.check(equity, dd))
1337 .collect();
1338 self.breach_count += breaches.len();
1339 breaches
1340 }
1341
1342 pub fn drawdown_pct(&self) -> Decimal {
1344 self.tracker.current_drawdown_pct()
1345 }
1346
1347 pub fn current_equity(&self) -> Decimal {
1349 self.tracker.current_equity()
1350 }
1351
1352 pub fn peak_equity(&self) -> Decimal {
1354 self.tracker.peak()
1355 }
1356
1357 pub fn reset(&mut self, initial_equity: Decimal) {
1359 self.tracker.reset(initial_equity);
1360 self.breach_count = 0;
1361 }
1362
1363 pub fn rule_count(&self) -> usize {
1365 self.rules.len()
1366 }
1367
1368 pub fn reset_peak(&mut self) {
1373 self.tracker.reset_peak();
1374 }
1375
1376 pub fn is_in_drawdown(&self) -> bool {
1378 self.tracker.current_drawdown_pct() > Decimal::ZERO
1379 }
1380
1381 pub fn worst_drawdown_pct(&self) -> Decimal {
1383 self.tracker.worst_drawdown_pct()
1384 }
1385
1386 pub fn equity_history_len(&self) -> usize {
1388 self.tracker.update_count()
1389 }
1390
1391 pub fn drawdown_duration(&self) -> usize {
1393 self.tracker.drawdown_duration()
1394 }
1395
1396 pub fn breach_count(&self) -> usize {
1398 self.breach_count
1399 }
1400
1401 pub fn max_drawdown_pct(&self) -> Decimal {
1405 self.tracker.worst_drawdown_pct()
1406 }
1407
1408 pub fn drawdown_tracker(&self) -> &DrawdownTracker {
1413 &self.tracker
1414 }
1415
1416 pub fn check(&self, equity: Decimal) -> Vec<RiskBreach> {
1421 let dd = if self.tracker.peak() == Decimal::ZERO {
1422 Decimal::ZERO
1423 } else {
1424 (self.tracker.peak() - equity) / self.tracker.peak() * Decimal::ONE_HUNDRED
1425 };
1426 self.rules
1427 .iter()
1428 .filter_map(|r| r.check(equity, dd))
1429 .collect()
1430 }
1431
1432 pub fn has_breaches(&self, equity: Decimal) -> bool {
1437 !self.check(equity).is_empty()
1438 }
1439
1440 pub fn win_rate(&self) -> Option<Decimal> {
1445 self.tracker.win_rate()
1446 }
1447
1448 pub fn calmar_ratio(&self, annualised_return_pct: f64) -> Option<f64> {
1455 use rust_decimal::prelude::ToPrimitive;
1456 let dd = self.tracker.worst_drawdown_pct().to_f64()?;
1457 if dd == 0.0 { return None; }
1458 Some(annualised_return_pct / dd)
1459 }
1460
1461 pub fn consecutive_gain_updates(&self) -> usize {
1465 self.tracker.consecutive_gain_updates()
1466 }
1467
1468 pub fn equity_at_risk(&self, pct: Decimal) -> Decimal {
1473 self.tracker.peak() * pct / Decimal::ONE_HUNDRED
1474 }
1475
1476 pub fn trailing_stop_level(&self, pct: Decimal) -> Decimal {
1483 self.tracker.peak() * (Decimal::ONE_HUNDRED - pct) / Decimal::ONE_HUNDRED
1484 }
1485
1486 pub fn var_pct(returns: &[Decimal], confidence_pct: Decimal) -> Option<Decimal> {
1494 if returns.is_empty() {
1495 return None;
1496 }
1497 use rust_decimal::prelude::ToPrimitive;
1498 let mut sorted = returns.to_vec();
1499 sorted.sort();
1500 let tail_pct = (Decimal::ONE_HUNDRED - confidence_pct) / Decimal::ONE_HUNDRED;
1501 let idx_f = tail_pct.to_f64()? * sorted.len() as f64;
1502 #[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
1503 let idx = (idx_f as usize).min(sorted.len() - 1);
1504 Some(sorted[idx])
1505 }
1506
1507 pub fn tail_risk_pct(returns: &[Decimal], confidence_pct: Decimal) -> Option<Decimal> {
1515 use rust_decimal::prelude::ToPrimitive;
1516 if returns.is_empty() {
1517 return None;
1518 }
1519 let mut sorted = returns.to_vec();
1520 sorted.sort();
1521 let tail_pct = (Decimal::ONE_HUNDRED - confidence_pct) / Decimal::ONE_HUNDRED;
1522 let tail_count_f = tail_pct.to_f64()? * sorted.len() as f64;
1523 #[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
1524 let tail_count = (tail_count_f.ceil() as usize).max(1).min(sorted.len());
1525 let mean = sorted[..tail_count].iter().copied().sum::<Decimal>()
1526 / Decimal::from(tail_count as u32);
1527 Some(mean)
1528 }
1529
1530 pub fn profit_factor(returns: &[Decimal]) -> Option<Decimal> {
1537 if returns.is_empty() { return None; }
1538 let gross_wins: Decimal = returns.iter().filter(|&&r| r > Decimal::ZERO).copied().sum();
1539 let gross_losses: Decimal = returns.iter().filter(|&&r| r < Decimal::ZERO).map(|r| r.abs()).sum();
1540 if gross_losses.is_zero() { return None; }
1541 Some(gross_wins / gross_losses)
1542 }
1543
1544 pub fn omega_ratio(returns: &[Decimal], threshold: Decimal) -> Option<Decimal> {
1550 if returns.is_empty() { return None; }
1551 let upside: Decimal = returns.iter().map(|&r| (r - threshold).max(Decimal::ZERO)).sum();
1552 let downside: Decimal = returns.iter().map(|&r| (threshold - r).max(Decimal::ZERO)).sum();
1553 if downside.is_zero() { return None; }
1554 Some(upside / downside)
1555 }
1556
1557 pub fn kelly_fraction(
1566 win_rate: Decimal,
1567 avg_win: Decimal,
1568 avg_loss: Decimal,
1569 ) -> Option<Decimal> {
1570 if avg_loss.is_zero() { return None; }
1571 let loss_rate = Decimal::ONE - win_rate;
1572 let odds = avg_win / avg_loss;
1573 Some(win_rate - loss_rate / odds)
1574 }
1575
1576 pub fn annualized_return(returns: &[Decimal], periods_per_year: usize) -> Option<f64> {
1582 use rust_decimal::prelude::ToPrimitive;
1583 if returns.is_empty() || periods_per_year == 0 { return None; }
1584 let n = returns.len() as f64;
1585 let mean_r: f64 = returns.iter().map(|r| r.to_f64().unwrap_or(0.0)).sum::<f64>() / n;
1586 let annual = (1.0 + mean_r).powf(periods_per_year as f64) - 1.0;
1587 Some(annual)
1588 }
1589
1590 pub fn tail_ratio(returns: &[Decimal]) -> Option<f64> {
1597 use rust_decimal::prelude::ToPrimitive;
1598 if returns.len() < 20 { return None; }
1599 let mut vals: Vec<f64> = returns.iter().filter_map(|r| r.to_f64()).collect();
1600 vals.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal));
1601 let n = vals.len();
1602 let p95_idx = ((n as f64 * 0.95) as usize).min(n - 1);
1603 let p05_idx = ((n as f64 * 0.05) as usize).min(n - 1);
1604 let p95 = vals[p95_idx];
1605 let p05 = vals[p05_idx].abs();
1606 if p05 == 0.0 { return None; }
1607 Some(p95 / p05)
1608 }
1609
1610 pub fn skewness(returns: &[Decimal]) -> Option<f64> {
1617 use rust_decimal::prelude::ToPrimitive;
1618 if returns.len() < 3 { return None; }
1619 let vals: Vec<f64> = returns.iter().filter_map(|r| r.to_f64()).collect();
1620 let n = vals.len() as f64;
1621 let mean = vals.iter().sum::<f64>() / n;
1622 let variance = vals.iter().map(|v| (v - mean).powi(2)).sum::<f64>() / n;
1623 let std_dev = variance.sqrt();
1624 if std_dev == 0.0 { return None; }
1625 let skew = vals.iter().map(|v| ((v - mean) / std_dev).powi(3)).sum::<f64>() / n;
1626 Some(skew)
1627 }
1628
1629 pub fn attribution_report(
1648 &self,
1649 ledger: &crate::position::PositionLedger,
1650 market_data: attribution::MarketData,
1651 ) -> attribution::AttributionReport {
1652 attribution::RiskAttributor::new(ledger, market_data).compute()
1653 }
1654
1655}
1656
1657impl DrawdownTracker {
1658 pub fn gain_loss_asymmetry(&self) -> Option<f64> {
1663 if self.equity_change_count == 0 { return None; }
1664 let n = self.equity_change_count as f64;
1665 let mean = self.equity_change_mean;
1666 let variance = if self.equity_change_count > 1 {
1670 self.equity_change_m2 / (n - 1.0)
1671 } else {
1672 return None;
1673 };
1674 let std = variance.sqrt();
1675 if std == 0.0 { return None; }
1676 let avg_loss = std - mean.min(0.0); if avg_loss <= 0.0 { return None; }
1678 let avg_gain = std + mean.max(0.0); Some(avg_gain / avg_loss)
1680 }
1681
1682 pub fn streaks(&self) -> (usize, usize, usize, usize) {
1687 (
1688 self.gain_streak,
1689 self.gain_streak, self.updates_since_peak,
1691 self.max_drawdown_streak,
1692 )
1693 }
1694
1695 pub fn sharpe_proxy(&self, annualized_return: f64, periods_per_year: u32) -> Option<f64> {
1700 let vol = self.annualized_volatility(periods_per_year)?;
1701 if vol == 0.0 { return None; }
1702 Some(annualized_return / vol)
1703 }
1704
1705 pub fn max_consecutive_underwater(&self) -> usize {
1709 self.max_drawdown_streak
1710 }
1711
1712 pub fn underwater_duration_avg(&self) -> Option<f64> {
1716 let count = self.drawdown_count();
1717 if count == 0 { return None; }
1718 Some(self.drawdown_update_count as f64 / count as f64)
1719 }
1720
1721 pub fn equity_efficiency(&self) -> f64 {
1725 if self.peak_equity.is_zero() { return 1.0; }
1726 (self.current_equity / self.peak_equity).to_f64().unwrap_or(0.0)
1727 }
1728
1729 pub fn sortino_proxy(&self, annualized_return: f64, periods_per_year: u32) -> Option<f64> {
1734 if self.equity_change_count < 2 { return None; }
1735 let downside_vol = self.annualized_volatility(periods_per_year)? / 2.0_f64.sqrt();
1738 if downside_vol == 0.0 { return None; }
1739 Some(annualized_return / downside_vol)
1740 }
1741
1742 #[deprecated(since = "2.1.0", note = "Use `gain_to_loss_ratio` instead")]
1746 pub fn gain_loss_ratio(&self) -> Option<f64> {
1747 self.gain_to_loss_ratio()
1748 }
1749
1750 pub fn recovery_efficiency(&self) -> Option<f64> {
1755 let dd_count = self.drawdown_count();
1756 if dd_count == 0 { return None; }
1757 Some(self.completed_recoveries as f64 / dd_count as f64)
1758 }
1759
1760 pub fn drawdown_velocity(&self) -> Option<f64> {
1764 if self.updates_since_peak == 0 { return None; }
1765 let dd = self.current_drawdown_pct().to_f64()?;
1766 Some(dd / self.updates_since_peak as f64)
1767 }
1768
1769 pub fn streak_win_rate(&self) -> Option<f64> {
1773 let total = self.max_gain_streak + self.max_drawdown_streak;
1774 if total == 0 { return None; }
1775 Some(self.max_gain_streak as f64 / total as f64)
1776 }
1777
1778 #[deprecated(since = "2.1.0", note = "Use `equity_change_std_dev` instead")]
1782 pub fn equity_change_std(&self) -> Option<f64> {
1783 self.equity_change_std_dev()
1784 }
1785
1786 pub fn avg_loss_pct(&self) -> Option<f64> {
1789 use rust_decimal::prelude::ToPrimitive;
1790 if self.total_loss_sum == 0.0 || self.update_count == 0 { return None; }
1791 let wr = self.win_rate()?.to_f64()?;
1792 let loss_count = ((1.0 - wr / 100.0) * self.update_count as f64).round() as usize;
1793 if loss_count == 0 { return None; }
1794 Some(self.total_loss_sum / loss_count as f64)
1795 }
1796}
1797
1798#[cfg(test)]
1799mod tests {
1800 use super::*;
1801 use rust_decimal_macros::dec;
1802
1803 #[test]
1804 fn test_drawdown_tracker_zero_at_peak() {
1805 let t = DrawdownTracker::new(dec!(10000));
1806 assert_eq!(t.current_drawdown_pct(), dec!(0));
1807 }
1808
1809 #[test]
1810 fn test_drawdown_tracker_increases_below_peak() {
1811 let mut t = DrawdownTracker::new(dec!(10000));
1812 t.update(dec!(9000));
1813 assert_eq!(t.current_drawdown_pct(), dec!(10));
1814 }
1815
1816 #[test]
1817 fn test_drawdown_tracker_peak_updates() {
1818 let mut t = DrawdownTracker::new(dec!(10000));
1819 t.update(dec!(12000));
1820 assert_eq!(t.peak(), dec!(12000));
1821 }
1822
1823 #[test]
1824 fn test_drawdown_tracker_current_equity() {
1825 let mut t = DrawdownTracker::new(dec!(10000));
1826 t.update(dec!(9500));
1827 assert_eq!(t.current_equity(), dec!(9500));
1828 }
1829
1830 #[test]
1831 fn test_drawdown_tracker_is_below_threshold_true() {
1832 let mut t = DrawdownTracker::new(dec!(10000));
1833 t.update(dec!(9500));
1834 assert!(t.is_below_threshold(dec!(10)));
1835 }
1836
1837 #[test]
1838 fn test_drawdown_tracker_is_below_threshold_false() {
1839 let mut t = DrawdownTracker::new(dec!(10000));
1840 t.update(dec!(8000));
1841 assert!(!t.is_below_threshold(dec!(10)));
1842 }
1843
1844 #[test]
1845 fn test_drawdown_tracker_never_negative() {
1846 let mut t = DrawdownTracker::new(dec!(10000));
1847 t.update(dec!(11000));
1848 assert_eq!(t.current_drawdown_pct(), dec!(0));
1849 }
1850
1851 #[test]
1852 fn test_max_drawdown_rule_triggers_breach() {
1853 let rule = MaxDrawdownRule {
1854 threshold_pct: dec!(10),
1855 };
1856 let breach = rule.check(dec!(8000), dec!(20));
1857 assert!(breach.is_some());
1858 }
1859
1860 #[test]
1861 fn test_max_drawdown_rule_no_breach_within_limit() {
1862 let rule = MaxDrawdownRule {
1863 threshold_pct: dec!(10),
1864 };
1865 let breach = rule.check(dec!(9500), dec!(5));
1866 assert!(breach.is_none());
1867 }
1868
1869 #[test]
1870 fn test_max_drawdown_rule_at_exact_threshold_no_breach() {
1871 let rule = MaxDrawdownRule {
1872 threshold_pct: dec!(10),
1873 };
1874 let breach = rule.check(dec!(9000), dec!(10));
1875 assert!(breach.is_none());
1876 }
1877
1878 #[test]
1879 fn test_min_equity_rule_breach() {
1880 let rule = MinEquityRule { floor: dec!(5000) };
1881 let breach = rule.check(dec!(4000), dec!(0));
1882 assert!(breach.is_some());
1883 }
1884
1885 #[test]
1886 fn test_min_equity_rule_no_breach() {
1887 let rule = MinEquityRule { floor: dec!(5000) };
1888 let breach = rule.check(dec!(6000), dec!(0));
1889 assert!(breach.is_none());
1890 }
1891
1892 #[test]
1893 fn test_risk_monitor_returns_all_breaches() {
1894 let mut monitor = RiskMonitor::new(dec!(10000))
1895 .add_rule(MaxDrawdownRule {
1896 threshold_pct: dec!(5),
1897 })
1898 .add_rule(MinEquityRule { floor: dec!(9000) });
1899 let breaches = monitor.update(dec!(8000));
1900 assert_eq!(breaches.len(), 2);
1901 }
1902
1903 #[test]
1904 fn test_risk_monitor_breach_count_accumulates() {
1905 let mut monitor = RiskMonitor::new(dec!(10000))
1906 .add_rule(MaxDrawdownRule { threshold_pct: dec!(5) });
1907 assert_eq!(monitor.breach_count(), 0);
1908 monitor.update(dec!(9000)); assert_eq!(monitor.breach_count(), 1);
1910 monitor.update(dec!(8500)); assert_eq!(monitor.breach_count(), 2);
1912 }
1913
1914 #[test]
1915 fn test_risk_monitor_breach_count_resets() {
1916 let mut monitor = RiskMonitor::new(dec!(10000))
1917 .add_rule(MaxDrawdownRule { threshold_pct: dec!(5) });
1918 monitor.update(dec!(9000));
1919 assert_eq!(monitor.breach_count(), 1);
1920 monitor.reset(dec!(10000));
1921 assert_eq!(monitor.breach_count(), 0);
1922 }
1923
1924 #[test]
1925 fn test_risk_monitor_max_drawdown_pct() {
1926 let mut monitor = RiskMonitor::new(dec!(10000));
1927 monitor.update(dec!(9000)); monitor.update(dec!(9500)); assert_eq!(monitor.max_drawdown_pct(), dec!(10));
1931 }
1932
1933 #[test]
1934 fn test_risk_monitor_drawdown_duration_zero_at_peak() {
1935 let mut monitor = RiskMonitor::new(dec!(10000));
1936 monitor.update(dec!(10100)); assert_eq!(monitor.drawdown_duration(), 0);
1938 }
1939
1940 #[test]
1941 fn test_risk_monitor_drawdown_duration_increments() {
1942 let mut monitor = RiskMonitor::new(dec!(10000));
1943 monitor.update(dec!(10100)); monitor.update(dec!(9900)); monitor.update(dec!(9800)); assert_eq!(monitor.drawdown_duration(), 2);
1947 }
1948
1949 #[test]
1950 fn test_risk_monitor_equity_history_len() {
1951 let mut monitor = RiskMonitor::new(dec!(10000));
1952 assert_eq!(monitor.equity_history_len(), 0);
1953 monitor.update(dec!(10000));
1954 monitor.update(dec!(9500));
1955 assert_eq!(monitor.equity_history_len(), 2);
1956 }
1957
1958 #[test]
1959 fn test_drawdown_tracker_win_rate_none_when_empty() {
1960 let tracker = DrawdownTracker::new(dec!(10000));
1961 assert!(tracker.win_rate().is_none());
1962 }
1963
1964 #[test]
1965 fn test_drawdown_tracker_win_rate_all_up() {
1966 let mut tracker = DrawdownTracker::new(dec!(10000));
1967 tracker.update(dec!(10100));
1968 tracker.update(dec!(10200));
1969 assert_eq!(tracker.win_rate().unwrap(), dec!(1));
1971 }
1972
1973 #[test]
1974 fn test_drawdown_tracker_win_rate_half() {
1975 let mut tracker = DrawdownTracker::new(dec!(10000));
1976 tracker.update(dec!(10100)); tracker.update(dec!(9900)); assert_eq!(tracker.win_rate().unwrap(), dec!(0.5));
1980 }
1981
1982 #[test]
1983 fn test_risk_monitor_no_breach_at_start() {
1984 let mut monitor = RiskMonitor::new(dec!(10000)).add_rule(MaxDrawdownRule {
1985 threshold_pct: dec!(10),
1986 });
1987 let breaches = monitor.update(dec!(10000));
1988 assert!(breaches.is_empty());
1989 }
1990
1991 #[test]
1992 fn test_risk_monitor_partial_breach() {
1993 let mut monitor = RiskMonitor::new(dec!(10000))
1994 .add_rule(MaxDrawdownRule {
1995 threshold_pct: dec!(5),
1996 })
1997 .add_rule(MinEquityRule { floor: dec!(5000) });
1998 let breaches = monitor.update(dec!(9000));
1999 assert_eq!(breaches.len(), 1);
2000 assert_eq!(breaches[0].rule, "max_drawdown");
2001 }
2002
2003 #[test]
2004 fn test_drawdown_recovery() {
2005 let mut monitor = RiskMonitor::new(dec!(10000)).add_rule(MaxDrawdownRule {
2006 threshold_pct: dec!(10),
2007 });
2008 let breaches = monitor.update(dec!(8000));
2009 assert_eq!(breaches.len(), 1);
2010 let breaches = monitor.update(dec!(10000));
2011 assert!(breaches.is_empty(), "no breach after recovery to peak");
2012 let breaches = monitor.update(dec!(12000));
2013 assert!(breaches.is_empty(), "no breach after rising above old peak");
2014 let breaches = monitor.update(dec!(11500));
2015 assert!(
2016 breaches.is_empty(),
2017 "small dip from new peak should not breach"
2018 );
2019 }
2020
2021 #[test]
2022 fn test_drawdown_flat_series_is_zero() {
2023 let mut t = DrawdownTracker::new(dec!(10000));
2024 for _ in 0..10 {
2025 t.update(dec!(10000));
2026 }
2027 assert_eq!(t.current_drawdown_pct(), dec!(0));
2028 }
2029
2030 #[test]
2031 fn test_drawdown_monotonic_decline_full_loss() {
2032 let mut t = DrawdownTracker::new(dec!(10000));
2033 t.update(dec!(5000));
2034 t.update(dec!(2500));
2035 t.update(dec!(1000));
2036 t.update(dec!(0));
2037 assert_eq!(t.current_drawdown_pct(), dec!(100));
2038 }
2039
2040 #[test]
2041 fn test_risk_monitor_multiple_rules_all_must_pass() {
2042 let mut monitor = RiskMonitor::new(dec!(10000))
2043 .add_rule(MaxDrawdownRule {
2044 threshold_pct: dec!(5),
2045 })
2046 .add_rule(MinEquityRule { floor: dec!(9500) });
2047 let breaches = monitor.update(dec!(9400));
2048 assert_eq!(breaches.len(), 2, "both rules should trigger");
2049 let breaches = monitor.update(dec!(10000));
2050 assert!(breaches.is_empty(), "all rules pass at peak");
2051 let breaches = monitor.update(dec!(9600));
2052 assert!(
2053 breaches.is_empty(),
2054 "9600 is above the 9500 floor and within 5% drawdown"
2055 );
2056 let breaches = monitor.update(dec!(9400));
2057 assert_eq!(
2058 breaches.len(),
2059 2,
2060 "both rules fire when equity drops to 9400 again"
2061 );
2062 }
2063
2064 #[test]
2065 fn test_risk_monitor_drawdown_pct_accessor() {
2066 let mut monitor = RiskMonitor::new(dec!(10000)).add_rule(MaxDrawdownRule {
2067 threshold_pct: dec!(20),
2068 });
2069 monitor.update(dec!(8000));
2070 assert_eq!(monitor.drawdown_pct(), dec!(20));
2071 }
2072
2073 #[test]
2074 fn test_risk_monitor_current_equity_accessor() {
2075 let mut monitor = RiskMonitor::new(dec!(10000)).add_rule(MaxDrawdownRule {
2076 threshold_pct: dec!(20),
2077 });
2078 monitor.update(dec!(9500));
2079 assert_eq!(monitor.current_equity(), dec!(9500));
2080 }
2081
2082 #[test]
2083 fn test_risk_rule_name_returns_str() {
2084 let rule: &dyn RiskRule = &MaxDrawdownRule {
2085 threshold_pct: dec!(10),
2086 };
2087 let name: &str = rule.name();
2088 assert_eq!(name, "max_drawdown");
2089 }
2090
2091 #[test]
2092 fn test_drawdown_tracker_reset_clears_peak() {
2093 let mut t = DrawdownTracker::new(dec!(10000));
2094 t.update(dec!(8000));
2095 assert_eq!(t.current_drawdown_pct(), dec!(20));
2096 t.reset(dec!(5000));
2097 assert_eq!(t.peak(), dec!(5000));
2098 assert_eq!(t.current_equity(), dec!(5000));
2099 assert_eq!(t.current_drawdown_pct(), dec!(0));
2100 }
2101
2102 #[test]
2103 fn test_drawdown_tracker_reset_then_update() {
2104 let mut t = DrawdownTracker::new(dec!(10000));
2105 t.reset(dec!(2000));
2106 t.update(dec!(1800));
2107 assert_eq!(t.current_drawdown_pct(), dec!(10));
2108 }
2109
2110 #[test]
2111 fn test_drawdown_tracker_worst_drawdown_pct_accumulates() {
2112 let mut t = DrawdownTracker::new(dec!(10000));
2113 t.update(dec!(9000)); t.update(dec!(9500)); t.update(dec!(10100)); t.update(dec!(9595)); assert_eq!(t.worst_drawdown_pct(), dec!(10));
2118 }
2119
2120 #[test]
2121 fn test_drawdown_tracker_worst_resets_on_full_reset() {
2122 let mut t = DrawdownTracker::new(dec!(10000));
2123 t.update(dec!(8000)); assert_eq!(t.worst_drawdown_pct(), dec!(20));
2125 t.reset(dec!(5000));
2126 assert_eq!(t.worst_drawdown_pct(), dec!(0));
2127 }
2128
2129 #[test]
2130 fn test_risk_monitor_reset_clears_drawdown_state() {
2131 let mut monitor = RiskMonitor::new(dec!(10000))
2132 .add_rule(MaxDrawdownRule { threshold_pct: dec!(15) });
2133 monitor.update(dec!(8000)); let breaches = monitor.update(dec!(8000));
2135 assert!(!breaches.is_empty());
2136 monitor.reset(dec!(10000));
2137 let breaches_after = monitor.update(dec!(9800)); assert!(breaches_after.is_empty());
2139 }
2140
2141 #[test]
2142 fn test_risk_monitor_reset_restores_peak() {
2143 let mut monitor = RiskMonitor::new(dec!(10000));
2144 monitor.update(dec!(9000));
2145 monitor.reset(dec!(5000));
2146 assert_eq!(monitor.peak_equity(), dec!(5000));
2147 assert_eq!(monitor.current_equity(), dec!(5000));
2148 }
2149
2150 #[test]
2151 fn test_risk_monitor_worst_drawdown_tracks_maximum() {
2152 let mut monitor = RiskMonitor::new(dec!(10000));
2153 monitor.update(dec!(9000)); monitor.update(dec!(8000)); monitor.update(dec!(9500)); assert_eq!(monitor.worst_drawdown_pct(), dec!(20));
2157 }
2158
2159 #[test]
2160 fn test_risk_monitor_worst_drawdown_zero_at_start() {
2161 let monitor = RiskMonitor::new(dec!(10000));
2162 assert_eq!(monitor.worst_drawdown_pct(), dec!(0));
2163 }
2164
2165 #[test]
2166 fn test_drawdown_tracker_display() {
2167 let mut t = DrawdownTracker::new(dec!(10000));
2168 t.update(dec!(9000));
2169 let s = format!("{t}");
2170 assert!(s.contains("9000"), "display should include current equity");
2171 assert!(s.contains("10000"), "display should include peak");
2172 assert!(s.contains("10.00"), "display should include drawdown pct");
2173 }
2174
2175 #[test]
2176 fn test_drawdown_tracker_recovery_factor() {
2177 let mut t = DrawdownTracker::new(dec!(10000));
2178 t.update(dec!(9000)); let rf = t.recovery_factor(dec!(20)).unwrap();
2181 assert_eq!(rf, dec!(2));
2182 }
2183
2184 #[test]
2185 fn test_drawdown_tracker_recovery_factor_no_drawdown() {
2186 let t = DrawdownTracker::new(dec!(10000));
2187 assert!(t.recovery_factor(dec!(20)).is_none());
2188 }
2189
2190 #[test]
2191 fn test_risk_monitor_check_non_mutating() {
2192 let monitor = RiskMonitor::new(dec!(10000))
2193 .add_rule(MaxDrawdownRule { threshold_pct: dec!(15) });
2194 let breaches = monitor.check(dec!(8000));
2196 assert_eq!(breaches.len(), 1);
2197 assert_eq!(monitor.peak_equity(), dec!(10000));
2199 assert_eq!(monitor.current_equity(), dec!(10000));
2200 }
2201
2202 #[test]
2203 fn test_risk_monitor_check_no_breach() {
2204 let monitor = RiskMonitor::new(dec!(10000))
2205 .add_rule(MaxDrawdownRule { threshold_pct: dec!(15) });
2206 let breaches = monitor.check(dec!(9000)); assert!(breaches.is_empty());
2208 }
2209
2210 #[test]
2211 fn test_drawdown_tracker_in_drawdown_false_at_peak() {
2212 let tracker = DrawdownTracker::new(dec!(10000));
2213 assert!(!tracker.in_drawdown());
2214 }
2215
2216 #[test]
2217 fn test_drawdown_tracker_in_drawdown_true_below_peak() {
2218 let mut tracker = DrawdownTracker::new(dec!(10000));
2219 tracker.update(dec!(9000));
2220 assert!(tracker.in_drawdown());
2221 }
2222
2223 #[test]
2224 fn test_drawdown_tracker_in_drawdown_false_at_new_peak() {
2225 let mut tracker = DrawdownTracker::new(dec!(10000));
2226 tracker.update(dec!(11000));
2227 assert!(!tracker.in_drawdown());
2228 }
2229
2230 #[test]
2231 fn test_drawdown_tracker_drawdown_count_increases() {
2232 let mut tracker = DrawdownTracker::new(dec!(10000));
2233 tracker.update(dec!(9500));
2234 tracker.update(dec!(9000));
2235 assert_eq!(tracker.drawdown_count(), 2);
2236 }
2237
2238 #[test]
2239 fn test_drawdown_tracker_drawdown_count_resets_on_peak() {
2240 let mut tracker = DrawdownTracker::new(dec!(10000));
2241 tracker.update(dec!(9000));
2242 tracker.update(dec!(11000)); assert_eq!(tracker.drawdown_count(), 0);
2244 }
2245
2246 #[test]
2247 fn test_risk_monitor_has_breaches_true() {
2248 let monitor = RiskMonitor::new(dec!(10000))
2249 .add_rule(MaxDrawdownRule { threshold_pct: dec!(5) });
2250 assert!(monitor.has_breaches(dec!(9000))); }
2252
2253 #[test]
2254 fn test_risk_monitor_has_breaches_false() {
2255 let monitor = RiskMonitor::new(dec!(10000))
2256 .add_rule(MaxDrawdownRule { threshold_pct: dec!(15) });
2257 assert!(!monitor.has_breaches(dec!(9000))); }
2259
2260 #[test]
2261 fn test_risk_monitor_is_in_drawdown_true() {
2262 let mut monitor = RiskMonitor::new(dec!(10000)).add_rule(MaxDrawdownRule { threshold_pct: dec!(50) });
2263 monitor.update(dec!(9000));
2264 assert!(monitor.is_in_drawdown());
2265 }
2266
2267 #[test]
2268 fn test_risk_monitor_is_in_drawdown_false_at_peak() {
2269 let mut monitor = RiskMonitor::new(dec!(10000)).add_rule(MaxDrawdownRule { threshold_pct: dec!(50) });
2270 monitor.update(dec!(10000));
2271 assert!(!monitor.is_in_drawdown());
2272 }
2273
2274 #[test]
2275 fn test_risk_monitor_is_in_drawdown_false_above_peak() {
2276 let mut monitor = RiskMonitor::new(dec!(10000)).add_rule(MaxDrawdownRule { threshold_pct: dec!(50) });
2277 monitor.update(dec!(11000));
2278 assert!(!monitor.is_in_drawdown());
2279 }
2280
2281 #[test]
2282 fn test_recovery_to_peak_pct_at_peak_is_zero() {
2283 let tracker = DrawdownTracker::new(dec!(10000));
2284 assert_eq!(tracker.recovery_to_peak_pct(), dec!(0));
2285 }
2286
2287 #[test]
2288 fn test_recovery_to_peak_pct_with_drawdown() {
2289 let mut tracker = DrawdownTracker::new(dec!(10000));
2290 tracker.update(dec!(8000)); assert_eq!(tracker.recovery_to_peak_pct(), dec!(25));
2293 }
2294
2295 #[test]
2296 fn test_recovery_to_peak_pct_above_peak_is_zero() {
2297 let mut tracker = DrawdownTracker::new(dec!(10000));
2298 tracker.update(dec!(12000)); assert_eq!(tracker.recovery_to_peak_pct(), dec!(0));
2300 }
2301
2302 #[test]
2303 fn test_calmar_ratio_with_drawdown() {
2304 let mut tracker = DrawdownTracker::new(dec!(10000));
2305 tracker.update(dec!(9000)); let ratio = tracker.calmar_ratio(dec!(20)).unwrap();
2308 assert_eq!(ratio, dec!(2));
2309 }
2310
2311 #[test]
2312 fn test_calmar_ratio_none_when_no_drawdown() {
2313 let tracker = DrawdownTracker::new(dec!(10000));
2314 assert!(tracker.calmar_ratio(dec!(20)).is_none());
2316 }
2317
2318 #[test]
2319 fn test_sharpe_ratio_basic() {
2320 let tracker = DrawdownTracker::new(dec!(10000));
2321 assert_eq!(tracker.sharpe_ratio(dec!(15), dec!(5)), Some(dec!(3)));
2323 }
2324
2325 #[test]
2326 fn test_sharpe_ratio_none_when_vol_zero() {
2327 let tracker = DrawdownTracker::new(dec!(10000));
2328 assert!(tracker.sharpe_ratio(dec!(15), dec!(0)).is_none());
2329 }
2330
2331 #[test]
2332 fn test_time_underwater_pct_no_updates_returns_zero() {
2333 let tracker = DrawdownTracker::new(dec!(10000));
2334 assert_eq!(tracker.time_underwater_pct(), dec!(0));
2335 }
2336
2337 #[test]
2338 fn test_time_underwater_pct_all_in_drawdown() {
2339 let mut tracker = DrawdownTracker::new(dec!(10000));
2340 tracker.update(dec!(9000));
2341 tracker.update(dec!(8000));
2342 assert_eq!(tracker.time_underwater_pct(), dec!(1));
2344 }
2345
2346 #[test]
2347 fn test_time_underwater_pct_half_in_drawdown() {
2348 let mut tracker = DrawdownTracker::new(dec!(10000));
2349 tracker.update(dec!(11000)); tracker.update(dec!(10000)); assert_eq!(tracker.time_underwater_pct(), Decimal::new(5, 1));
2352 }
2353
2354 #[test]
2355 fn test_avg_drawdown_pct_none_when_no_drawdown() {
2356 let mut tracker = DrawdownTracker::new(dec!(10000));
2357 tracker.update(dec!(11000));
2358 assert!(tracker.avg_drawdown_pct().is_none());
2359 }
2360
2361 #[test]
2362 fn test_avg_drawdown_pct_positive_when_drawdown() {
2363 let mut tracker = DrawdownTracker::new(dec!(10000));
2364 tracker.update(dec!(9000)); let avg = tracker.avg_drawdown_pct().unwrap();
2366 assert!(avg > dec!(0));
2367 }
2368
2369 #[test]
2370 fn test_max_loss_streak_zero_when_no_drawdown() {
2371 let mut tracker = DrawdownTracker::new(dec!(10000));
2372 tracker.update(dec!(11000));
2373 tracker.update(dec!(12000));
2374 assert_eq!(tracker.max_loss_streak(), 0);
2375 }
2376
2377 #[test]
2378 fn test_max_loss_streak_tracks_longest_run() {
2379 let mut tracker = DrawdownTracker::new(dec!(10000));
2380 tracker.update(dec!(9000)); tracker.update(dec!(8000)); tracker.update(dec!(11000)); tracker.update(dec!(10000)); assert_eq!(tracker.max_loss_streak(), 2);
2385 }
2386
2387 #[test]
2388 fn test_reset_clears_new_fields() {
2389 let mut tracker = DrawdownTracker::new(dec!(10000));
2390 tracker.update(dec!(9000));
2391 tracker.update(dec!(8000));
2392 tracker.reset(dec!(10000));
2393 assert_eq!(tracker.time_underwater_pct(), dec!(0));
2394 assert!(tracker.avg_drawdown_pct().is_none());
2395 assert_eq!(tracker.max_loss_streak(), 0);
2396 }
2397
2398 #[test]
2399 fn test_consecutive_gain_updates_zero_initially() {
2400 let tracker = DrawdownTracker::new(dec!(10000));
2401 assert_eq!(tracker.consecutive_gain_updates(), 0);
2402 }
2403
2404 #[test]
2405 fn test_consecutive_gain_updates_increments_on_rising_equity() {
2406 let mut tracker = DrawdownTracker::new(dec!(10000));
2407 tracker.update(dec!(10100));
2408 tracker.update(dec!(10200));
2409 tracker.update(dec!(10300));
2410 assert_eq!(tracker.consecutive_gain_updates(), 3);
2411 }
2412
2413 #[test]
2414 fn test_consecutive_gain_updates_resets_on_drop() {
2415 let mut tracker = DrawdownTracker::new(dec!(10000));
2416 tracker.update(dec!(10100));
2417 tracker.update(dec!(10200));
2418 tracker.update(dec!(10100)); assert_eq!(tracker.consecutive_gain_updates(), 0);
2420 }
2421
2422 #[test]
2423 fn test_consecutive_gain_updates_resumes_after_drop() {
2424 let mut tracker = DrawdownTracker::new(dec!(10000));
2425 tracker.update(dec!(10100));
2426 tracker.update(dec!(9900)); tracker.update(dec!(10000)); tracker.update(dec!(10100));
2429 assert_eq!(tracker.consecutive_gain_updates(), 2);
2430 }
2431
2432 #[test]
2433 fn test_consecutive_gain_updates_clears_on_reset() {
2434 let mut tracker = DrawdownTracker::new(dec!(10000));
2435 tracker.update(dec!(11000));
2436 tracker.update(dec!(12000));
2437 tracker.reset(dec!(10000));
2438 assert_eq!(tracker.consecutive_gain_updates(), 0);
2439 }
2440
2441 #[test]
2442 fn test_equity_ratio_at_peak_is_one() {
2443 let mut tracker = DrawdownTracker::new(dec!(10000));
2444 tracker.update(dec!(10000));
2445 assert_eq!(tracker.equity_ratio(), Decimal::ONE);
2446 }
2447
2448 #[test]
2449 fn test_equity_ratio_in_drawdown() {
2450 let mut tracker = DrawdownTracker::new(dec!(10000));
2451 tracker.update(dec!(9000));
2452 assert_eq!(tracker.equity_ratio(), dec!(0.9));
2453 }
2454
2455 #[test]
2456 fn test_equity_ratio_new_peak() {
2457 let mut tracker = DrawdownTracker::new(dec!(10000));
2458 tracker.update(dec!(12000));
2459 assert_eq!(tracker.equity_ratio(), Decimal::ONE);
2460 }
2461
2462 #[test]
2463 fn test_new_peak_count_zero_initially() {
2464 let tracker = DrawdownTracker::new(dec!(10000));
2465 assert_eq!(tracker.new_peak_count(), 0);
2466 }
2467
2468 #[test]
2469 fn test_new_peak_count_increments() {
2470 let mut tracker = DrawdownTracker::new(dec!(10000));
2471 tracker.update(dec!(11000));
2472 tracker.update(dec!(9000)); tracker.update(dec!(12000)); assert_eq!(tracker.new_peak_count(), 2);
2475 }
2476
2477 #[test]
2478 fn test_new_peak_count_resets() {
2479 let mut tracker = DrawdownTracker::new(dec!(10000));
2480 tracker.update(dec!(11000));
2481 tracker.update(dec!(12000));
2482 tracker.reset(dec!(10000));
2483 assert_eq!(tracker.new_peak_count(), 0);
2484 }
2485
2486 #[test]
2487 fn test_omega_ratio_positive_threshold_zero() {
2488 let returns = vec![dec!(0.05), dec!(-0.02), dec!(0.03), dec!(-0.01)];
2489 let omega = DrawdownTracker::omega_ratio(&returns, Decimal::ZERO).unwrap();
2490 assert!(omega > 1.0, "expected omega > 1.0, got {omega}");
2492 }
2493
2494 #[test]
2495 fn test_omega_ratio_empty_returns_none() {
2496 assert!(DrawdownTracker::omega_ratio(&[], Decimal::ZERO).is_none());
2497 }
2498
2499 #[test]
2500 fn test_omega_ratio_no_downside_returns_none() {
2501 let returns = vec![dec!(0.01), dec!(0.02), dec!(0.03)];
2502 assert!(DrawdownTracker::omega_ratio(&returns, Decimal::ZERO).is_none());
2503 }
2504
2505 #[test]
2506 fn test_tail_ratio_none_below_20_obs() {
2507 let returns: Vec<Decimal> = (0..19).map(|_| dec!(0.01)).collect();
2508 assert!(RiskMonitor::tail_ratio(&returns).is_none());
2509 }
2510
2511 #[test]
2512 fn test_tail_ratio_positive_skewed_series() {
2513 let mut returns: Vec<Decimal> = (0..19).map(|_| dec!(-0.005)).collect();
2515 returns.push(dec!(0.1)); let ratio = RiskMonitor::tail_ratio(&returns).unwrap();
2517 assert!(ratio > 0.0, "tail ratio should be positive: {ratio}");
2518 }
2519
2520 #[test]
2521 fn test_skewness_none_below_3() {
2522 assert!(RiskMonitor::skewness(&[dec!(0.01), dec!(0.02)]).is_none());
2523 }
2524
2525 #[test]
2526 fn test_skewness_symmetric_near_zero() {
2527 let returns = vec![dec!(-1), dec!(0), dec!(1)];
2529 let sk = RiskMonitor::skewness(&returns).unwrap();
2530 assert!(sk.abs() < 1e-9, "symmetric series should have ~0 skew: {sk}");
2531 }
2532
2533 #[test]
2534 fn test_skewness_right_skewed_positive() {
2535 let mut returns: Vec<Decimal> = (0..10).map(|_| dec!(0)).collect();
2537 returns.push(dec!(100));
2538 let sk = RiskMonitor::skewness(&returns).unwrap();
2539 assert!(sk > 0.0, "right-skewed series should have positive skew: {sk}");
2540 }
2541
2542 #[test]
2543 fn test_calmar_ratio_none_at_peak() {
2544 let monitor = RiskMonitor::new(dec!(10000));
2546 assert!(monitor.calmar_ratio(15.0).is_none());
2547 }
2548
2549 #[test]
2550 fn test_calmar_ratio_positive_after_drawdown() {
2551 let mut monitor = RiskMonitor::new(dec!(10000));
2552 monitor.update(dec!(9000)); let calmar = monitor.calmar_ratio(15.0).unwrap();
2554 assert!((calmar - 1.5).abs() < 0.001, "calmar should be ~1.5: {calmar}");
2555 }
2556}
2557
2558pub struct RiskMetrics;
2573
2574impl RiskMetrics {
2575 pub fn sharpe(returns: &[f64], risk_free: f64, periods_per_year: f64) -> f64 {
2579 if returns.len() < 2 {
2580 return 0.0;
2581 }
2582 let n = returns.len() as f64;
2583 let mean = returns.iter().sum::<f64>() / n;
2584 let excess = mean - risk_free;
2585 let variance = returns.iter().map(|r| (r - mean).powi(2)).sum::<f64>() / (n - 1.0);
2586 let std_dev = variance.sqrt();
2587 if std_dev == 0.0 {
2588 return 0.0;
2589 }
2590 excess / std_dev * periods_per_year.sqrt()
2591 }
2592
2593 pub fn sortino(returns: &[f64], target_return: f64, periods_per_year: f64) -> f64 {
2600 if returns.is_empty() {
2601 return 0.0;
2602 }
2603 let n = returns.len() as f64;
2604 let mean = returns.iter().sum::<f64>() / n;
2605 let downside_sq_sum: f64 = returns
2606 .iter()
2607 .filter(|&&r| r < target_return)
2608 .map(|&r| (r - target_return).powi(2))
2609 .sum();
2610 if downside_sq_sum == 0.0 {
2611 return 0.0;
2612 }
2613 let downside_dev = (downside_sq_sum / n).sqrt();
2614 (mean - target_return) / downside_dev * periods_per_year.sqrt()
2615 }
2616
2617 pub fn calmar(returns: &[f64], periods_per_year: f64) -> f64 {
2623 if returns.is_empty() {
2624 return 0.0;
2625 }
2626 let ann_ret = Self::annualized_return(returns, periods_per_year);
2627 let cum: Vec<f64> = returns
2629 .iter()
2630 .scan(1.0_f64, |wealth, &r| {
2631 *wealth *= 1.0 + r;
2632 Some(*wealth)
2633 })
2634 .collect();
2635 let mdd = Self::max_drawdown(&cum);
2636 if mdd == 0.0 { 0.0 } else { ann_ret / mdd }
2637 }
2638
2639 pub fn max_drawdown(cumulative_returns: &[f64]) -> f64 {
2644 let mut peak = f64::NEG_INFINITY;
2645 let mut max_dd = 0.0_f64;
2646 for &val in cumulative_returns {
2647 if val > peak {
2648 peak = val;
2649 }
2650 if peak > 0.0 {
2651 let dd = (peak - val) / peak;
2652 if dd > max_dd {
2653 max_dd = dd;
2654 }
2655 }
2656 }
2657 max_dd
2658 }
2659
2660 pub fn drawdown_series(cumulative_returns: &[f64]) -> Vec<f64> {
2666 let mut peak = f64::NEG_INFINITY;
2667 cumulative_returns
2668 .iter()
2669 .map(|&val| {
2670 if val > peak {
2671 peak = val;
2672 }
2673 if peak > 0.0 { (peak - val) / peak } else { 0.0 }
2674 })
2675 .collect()
2676 }
2677
2678 pub fn var_historical(returns: &[f64], confidence: f64) -> f64 {
2685 if returns.is_empty() || !(0.0..1.0).contains(&confidence) {
2686 return 0.0;
2687 }
2688 let mut sorted = returns.to_vec();
2689 sorted.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal));
2690 let idx = ((1.0 - confidence) * sorted.len() as f64).floor() as usize;
2691 let idx = idx.min(sorted.len() - 1);
2692 -sorted[idx] }
2694
2695 pub fn cvar_historical(returns: &[f64], confidence: f64) -> f64 {
2702 if returns.is_empty() || !(0.0..1.0).contains(&confidence) {
2703 return 0.0;
2704 }
2705 let mut sorted = returns.to_vec();
2706 sorted.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal));
2707 let cutoff = ((1.0 - confidence) * sorted.len() as f64).ceil() as usize;
2708 let cutoff = cutoff.max(1).min(sorted.len());
2709 let tail = &sorted[..cutoff];
2710 let mean_tail = tail.iter().sum::<f64>() / tail.len() as f64;
2711 -mean_tail }
2713
2714 pub fn omega_ratio(returns: &[f64], threshold: f64) -> f64 {
2721 let gains: f64 = returns.iter().map(|&r| (r - threshold).max(0.0)).sum();
2722 let losses: f64 = returns.iter().map(|&r| (threshold - r).max(0.0)).sum();
2723 if losses == 0.0 {
2724 return f64::INFINITY;
2725 }
2726 gains / losses
2727 }
2728
2729 pub fn beta_alpha(returns: &[f64], benchmark: &[f64], risk_free: f64) -> (f64, f64) {
2737 let n = returns.len().min(benchmark.len());
2738 if n < 2 {
2739 return (0.0, 0.0);
2740 }
2741 let r: Vec<f64> = returns[..n].iter().map(|&x| x - risk_free).collect();
2742 let b: Vec<f64> = benchmark[..n].iter().map(|&x| x - risk_free).collect();
2743 let n_f = n as f64;
2744 let mean_r = r.iter().sum::<f64>() / n_f;
2745 let mean_b = b.iter().sum::<f64>() / n_f;
2746 let cov: f64 = r.iter().zip(b.iter()).map(|(&ri, &bi)| (ri - mean_r) * (bi - mean_b)).sum::<f64>() / (n_f - 1.0);
2747 let var_b: f64 = b.iter().map(|&bi| (bi - mean_b).powi(2)).sum::<f64>() / (n_f - 1.0);
2748 if var_b == 0.0 {
2749 return (0.0, 0.0);
2750 }
2751 let beta = cov / var_b;
2752 let alpha = mean_r - beta * mean_b;
2753 (beta, alpha)
2754 }
2755
2756 pub fn information_ratio(returns: &[f64], benchmark: &[f64]) -> f64 {
2762 let n = returns.len().min(benchmark.len());
2763 if n < 2 {
2764 return 0.0;
2765 }
2766 let excess: Vec<f64> = returns[..n].iter().zip(benchmark[..n].iter()).map(|(&r, &b)| r - b).collect();
2767 let n_f = n as f64;
2768 let mean_ex = excess.iter().sum::<f64>() / n_f;
2769 let var_ex = excess.iter().map(|&e| (e - mean_ex).powi(2)).sum::<f64>() / (n_f - 1.0);
2770 let te = var_ex.sqrt();
2771 if te == 0.0 { 0.0 } else { mean_ex / te }
2772 }
2773
2774 pub fn annualized_return(returns: &[f64], periods_per_year: f64) -> f64 {
2778 if returns.is_empty() {
2779 return 0.0;
2780 }
2781 let n = returns.len() as f64;
2782 let total_growth: f64 = returns.iter().map(|&r| 1.0 + r).product();
2783 if total_growth <= 0.0 {
2784 return -1.0;
2785 }
2786 total_growth.powf(periods_per_year / n) - 1.0
2787 }
2788
2789 pub fn annualized_volatility(returns: &[f64], periods_per_year: f64) -> f64 {
2795 if returns.len() < 2 {
2796 return 0.0;
2797 }
2798 let n = returns.len() as f64;
2799 let mean = returns.iter().sum::<f64>() / n;
2800 let variance = returns.iter().map(|&r| (r - mean).powi(2)).sum::<f64>() / (n - 1.0);
2801 variance.sqrt() * periods_per_year.sqrt()
2802 }
2803}
2804
2805#[cfg(test)]
2808mod risk_metrics_tests {
2809 use super::RiskMetrics;
2810
2811 fn daily_returns() -> Vec<f64> {
2812 vec![0.01, -0.005, 0.02, -0.01, 0.015, 0.0, 0.008, -0.003, 0.012, -0.007]
2813 }
2814
2815 #[test]
2816 fn sharpe_positive_for_positive_excess_returns() {
2817 let rets = daily_returns();
2818 let s = RiskMetrics::sharpe(&rets, 0.0, 252.0);
2819 assert!(s > 0.0, "sharpe should be positive: {s}");
2820 }
2821
2822 #[test]
2823 fn sharpe_empty_returns_zero() {
2824 assert_eq!(RiskMetrics::sharpe(&[], 0.0, 252.0), 0.0);
2825 }
2826
2827 #[test]
2828 fn sortino_positive_for_positive_mean() {
2829 let rets = daily_returns();
2830 let s = RiskMetrics::sortino(&rets, 0.0, 252.0);
2831 assert!(s > 0.0, "sortino should be positive: {s}");
2832 }
2833
2834 #[test]
2835 fn calmar_positive_rising_equity() {
2836 let mut rets: Vec<f64> = (0..50).map(|i| 0.001 * (i as f64 + 1.0)).collect();
2840 rets[10] = -0.02;
2841 let c = RiskMetrics::calmar(&rets, 252.0);
2842 assert!(c > 0.0, "calmar should be positive: {c}");
2843 let no_dd: Vec<f64> = (0..50).map(|i| 0.001 * (i as f64 + 1.0)).collect();
2844 assert_eq!(RiskMetrics::calmar(&no_dd, 252.0), 0.0, "no drawdown returns 0.0");
2845 }
2846
2847 #[test]
2848 fn max_drawdown_known_sequence() {
2849 let cum = vec![1.0, 1.05, 1.1, 0.9, 0.8, 0.95, 1.0];
2851 let mdd = RiskMetrics::max_drawdown(&cum);
2852 assert!((mdd - (1.1 - 0.8) / 1.1).abs() < 1e-9, "mdd={mdd}");
2853 }
2854
2855 #[test]
2856 fn max_drawdown_monotone_rising_is_zero() {
2857 let cum: Vec<f64> = (1..=10).map(|i| i as f64).collect();
2858 assert_eq!(RiskMetrics::max_drawdown(&cum), 0.0);
2859 }
2860
2861 #[test]
2862 fn drawdown_series_length_matches_input() {
2863 let cum = vec![1.0, 1.05, 0.95, 1.02];
2864 let dd = RiskMetrics::drawdown_series(&cum);
2865 assert_eq!(dd.len(), cum.len());
2866 assert_eq!(dd[0], 0.0); }
2868
2869 #[test]
2870 fn var_historical_95_confidence() {
2871 let rets: Vec<f64> = (0..100).map(|i| (i as f64 - 50.0) / 1000.0).collect();
2873 let v = RiskMetrics::var_historical(&rets, 0.95);
2874 assert!(v > 0.0, "VaR should be positive (loss): {v}");
2875 }
2876
2877 #[test]
2878 fn cvar_historical_greater_than_var() {
2879 let rets: Vec<f64> = (0..100).map(|i| (i as f64 - 50.0) / 1000.0).collect();
2880 let var = RiskMetrics::var_historical(&rets, 0.95);
2881 let cvar = RiskMetrics::cvar_historical(&rets, 0.95);
2882 assert!(cvar >= var, "CVaR ({cvar}) should be >= VaR ({var})");
2883 }
2884
2885 #[test]
2886 fn omega_ratio_positive_mean_above_threshold() {
2887 let rets = daily_returns();
2888 let omega = RiskMetrics::omega_ratio(&rets, 0.0);
2889 assert!(omega > 1.0, "omega should be > 1 when mean > threshold: {omega}");
2890 }
2891
2892 #[test]
2893 fn beta_alpha_market_neutral() {
2894 let rets = vec![0.01, -0.005, 0.02, -0.01];
2896 let (beta, alpha) = RiskMetrics::beta_alpha(&rets, &rets, 0.0);
2897 assert!((beta - 1.0).abs() < 1e-9, "beta should be ~1: {beta}");
2898 assert!(alpha.abs() < 1e-9, "alpha should be ~0: {alpha}");
2899 }
2900
2901 #[test]
2902 fn information_ratio_identical_series_zero() {
2903 let rets = daily_returns();
2904 let ir = RiskMetrics::information_ratio(&rets, &rets);
2905 assert_eq!(ir, 0.0, "IR should be 0 when series are identical");
2906 }
2907
2908 #[test]
2909 fn annualized_return_no_gain_loss() {
2910 let rets = vec![0.0; 252];
2911 let ann = RiskMetrics::annualized_return(&rets, 252.0);
2912 assert!(ann.abs() < 1e-9, "zero returns → zero annualized return: {ann}");
2913 }
2914
2915 #[test]
2916 fn annualized_volatility_zero_for_constant_returns() {
2917 let rets = vec![0.01; 100];
2920 assert!(RiskMetrics::annualized_volatility(&rets, 252.0) < 1e-12);
2921 }
2922}