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