1use driftbrake_core::{HaltDecision, HaltPolicy, HaltReason, ReconcileHistory};
29
30#[derive(Debug, Clone, Copy, PartialEq)]
37pub struct FastGuardConfig {
38 pub threshold: f64,
39 pub window: usize,
40}
41
42impl Default for FastGuardConfig {
43 fn default() -> Self {
44 Self {
45 threshold: 0.50,
46 window: 3,
47 }
48 }
49}
50
51#[derive(Debug, Clone, Copy, PartialEq)]
57pub struct SlowGuardConfig {
58 pub threshold: f64,
59 pub window: usize,
60}
61
62impl Default for SlowGuardConfig {
63 fn default() -> Self {
64 Self {
65 threshold: 0.70,
66 window: 20,
67 }
68 }
69}
70
71#[derive(Debug, Clone, Copy, PartialEq, Eq)]
73pub struct RevertBurstConfig {
74 pub limit: usize,
75}
76
77impl Default for RevertBurstConfig {
78 fn default() -> Self {
79 Self { limit: 3 }
80 }
81}
82
83#[derive(Debug, Clone, Copy, PartialEq, Eq)]
86pub struct RevertGasConfig {
87 pub budget_limit: u128,
88 pub window: usize,
89}
90
91#[derive(Debug, Clone, Copy, PartialEq)]
94pub struct VolumeWeightedConfig {
95 pub threshold: f64,
96 pub window: usize,
97}
98
99impl Default for VolumeWeightedConfig {
100 fn default() -> Self {
101 Self {
102 threshold: 0.75,
103 window: 20,
104 }
105 }
106}
107
108#[derive(Debug, Clone, Copy, PartialEq, Eq)]
111pub struct DrawdownConfig {
112 pub max_drawdown_limit: i128,
113 pub window: usize,
114}
115
116#[derive(Debug, Clone, Copy, PartialEq)]
124pub struct ReconcilePolicy {
125 fast: FastGuardConfig,
126 slow: SlowGuardConfig,
127 revert_burst: Option<RevertBurstConfig>,
128 revert_gas: Option<RevertGasConfig>,
129 volume_weighted: Option<VolumeWeightedConfig>,
130 drawdown: Option<DrawdownConfig>,
131}
132
133impl ReconcilePolicy {
134 pub fn new(fast: FastGuardConfig, slow: SlowGuardConfig) -> Self {
142 assert!(
143 fast.window > 0,
144 "fast-guard window must be > 0 (see docs/whitepaper.md Section 4.2)"
145 );
146 assert!(
147 slow.window > 0,
148 "slow-guard window must be > 0 (see docs/whitepaper.md Section 4.3)"
149 );
150 debug_assert!(
151 slow.threshold > fast.threshold,
152 "slow-guard threshold ({}) should exceed fast-guard threshold ({}) \
153 for Property 2 (guard independence) to hold as stated — see docs/whitepaper.md",
154 slow.threshold,
155 fast.threshold
156 );
157 Self {
158 fast,
159 slow,
160 revert_burst: None,
161 revert_gas: None,
162 volume_weighted: None,
163 drawdown: None,
164 }
165 }
166
167 pub fn with_revert_burst(mut self, config: RevertBurstConfig) -> Self {
169 assert!(config.limit > 0, "revert-burst limit must be > 0");
170 self.revert_burst = Some(config);
171 self
172 }
173
174 pub fn with_revert_gas(mut self, config: RevertGasConfig) -> Self {
176 assert!(config.window > 0, "revert-gas window must be > 0");
177 self.revert_gas = Some(config);
178 self
179 }
180
181 pub fn with_volume_weighted(mut self, config: VolumeWeightedConfig) -> Self {
183 assert!(config.window > 0, "volume-weighted window must be > 0");
184 self.volume_weighted = Some(config);
185 self
186 }
187
188 pub fn with_drawdown(mut self, config: DrawdownConfig) -> Self {
190 assert!(config.window > 0, "drawdown window must be > 0");
191 self.drawdown = Some(config);
192 self
193 }
194
195 pub fn default_dual_guard() -> Self {
198 Self::new(FastGuardConfig::default(), SlowGuardConfig::default())
199 }
200
201 pub fn institutional_default() -> Self {
204 Self::default_dual_guard()
205 .with_revert_burst(RevertBurstConfig::default())
206 .with_volume_weighted(VolumeWeightedConfig::default())
207 }
208
209 pub fn fast_guard_config(&self) -> FastGuardConfig {
210 self.fast
211 }
212
213 pub fn slow_guard_config(&self) -> SlowGuardConfig {
214 self.slow
215 }
216
217 pub fn revert_burst_config(&self) -> Option<RevertBurstConfig> {
218 self.revert_burst
219 }
220
221 pub fn revert_gas_config(&self) -> Option<RevertGasConfig> {
222 self.revert_gas
223 }
224
225 pub fn volume_weighted_config(&self) -> Option<VolumeWeightedConfig> {
226 self.volume_weighted
227 }
228
229 pub fn drawdown_config(&self) -> Option<DrawdownConfig> {
230 self.drawdown
231 }
232}
233
234impl Default for ReconcilePolicy {
235 fn default() -> Self {
236 Self::default_dual_guard()
237 }
238}
239
240impl HaltPolicy for ReconcilePolicy {
241 fn evaluate(&mut self, history: &ReconcileHistory) -> HaltDecision {
242 if let Some(burst) = self.revert_burst {
244 let consecutive = history.consecutive_reverts();
245 if consecutive >= burst.limit {
246 return HaltDecision::Halt(HaltReason::RevertBurst {
247 consecutive_reverts: consecutive,
248 limit: burst.limit,
249 });
250 }
251 }
252
253 if let Some(gas_cfg) = self.revert_gas {
255 let burned = history.recent_revert_gas_burned(gas_cfg.window);
256 if burned > gas_cfg.budget_limit {
257 return HaltDecision::Halt(HaltReason::RevertGasBudgetExceeded {
258 gas_cost_burned: burned,
259 budget_limit: gas_cfg.budget_limit,
260 window: gas_cfg.window,
261 });
262 }
263 }
264
265 let fast_window = history.recent_ratios(self.fast.window);
268 if fast_window.len() == self.fast.window
269 && fast_window.iter().all(|ratio| *ratio < self.fast.threshold)
270 {
271 return HaltDecision::Halt(HaltReason::FastGuard {
272 window: fast_window,
273 threshold: self.fast.threshold,
274 });
275 }
276
277 if let Some(dd_cfg) = self.drawdown {
279 let pairs_in_window = history
280 .pairs
281 .iter()
282 .rev()
283 .take(dd_cfg.window)
284 .collect::<Vec<_>>();
285 if pairs_in_window.len() == dd_cfg.window {
286 let net_slippage_loss: i128 = pairs_in_window
287 .iter()
288 .map(|(p, r)| p.0 - r.0)
289 .sum();
290 if net_slippage_loss > dd_cfg.max_drawdown_limit {
291 return HaltDecision::Halt(HaltReason::NetDrawdownExceeded {
292 net_slippage_loss,
293 max_drawdown_limit: dd_cfg.max_drawdown_limit,
294 window: dd_cfg.window,
295 });
296 }
297 }
298 }
299
300 if let Some(vw_cfg) = self.volume_weighted {
302 let valid_pairs = history
303 .pairs
304 .iter()
305 .rev()
306 .filter(|(p, _)| p.0 > 0)
307 .take(vw_cfg.window)
308 .collect::<Vec<_>>();
309 if valid_pairs.len() == vw_cfg.window {
310 let total_predicted: i128 = valid_pairs.iter().map(|(p, _)| p.0).sum();
311 let total_realized: i128 = valid_pairs.iter().map(|(_, r)| r.0).sum();
312 if total_predicted > 0 {
313 let weighted_ratio = total_realized as f64 / total_predicted as f64;
314 if weighted_ratio < vw_cfg.threshold {
315 return HaltDecision::Halt(HaltReason::VolumeWeightedDrift {
316 weighted_ratio,
317 threshold: vw_cfg.threshold,
318 window: vw_cfg.window,
319 });
320 }
321 }
322 }
323 }
324
325 let slow_window = history.recent_ratios(self.slow.window);
328 if slow_window.len() == self.slow.window {
329 let mean_ratio = slow_window.iter().sum::<f64>() / slow_window.len() as f64;
330 if mean_ratio < self.slow.threshold {
331 return HaltDecision::Halt(HaltReason::SlowGuard {
332 mean_ratio,
333 window_size: self.slow.window,
334 threshold: self.slow.threshold,
335 });
336 }
337 }
338
339 HaltDecision::Continue
340 }
341}
342
343#[cfg(test)]
344mod tests {
345 use super::*;
346 use driftbrake_core::{PredictedProfit, RealizedProfit};
347
348 fn history_from_ratios(ratios: &[f64]) -> ReconcileHistory {
352 let mut history = ReconcileHistory::new();
353 for &ratio in ratios {
354 let predicted = 100i128;
355 let realized = (predicted as f64 * ratio).round() as i128;
356 history.append(PredictedProfit(predicted), RealizedProfit(realized));
357 }
358 history
359 }
360
361 #[test]
370 fn ratio_direction_is_realized_over_predicted_and_flags_underperformance_not_overperformance() {
371 let mut policy = ReconcilePolicy::new(
372 FastGuardConfig {
373 threshold: 0.50,
374 window: 1,
375 },
376 SlowGuardConfig {
377 threshold: 0.99, window: usize::MAX,
379 },
380 );
381
382 let underperforming = history_from_ratios(&[0.42]);
385 assert!(matches!(
386 policy.evaluate(&underperforming),
387 HaltDecision::Halt(HaltReason::FastGuard { .. })
388 ));
389
390 let overperforming = history_from_ratios(&[2.38]);
396 assert_eq!(policy.evaluate(&overperforming), HaltDecision::Continue);
397 }
398
399 #[test]
400 fn ratio_direction_worked_example_from_whitepaper_section_4_1() {
401 let mut history = ReconcileHistory::new();
403 history.append(PredictedProfit(100), RealizedProfit(42));
404 assert_eq!(history.recent_ratios(1), vec![0.42]);
405 }
406
407 #[test]
411 fn fast_guard_trips_on_three_consecutive_bad_ratios_after_healthy_history() {
412 let mut policy = ReconcilePolicy::default_dual_guard();
417 let history = history_from_ratios(&[0.9, 0.9, 0.3, 0.2, 0.1]);
418
419 match policy.evaluate(&history) {
420 HaltDecision::Halt(HaltReason::FastGuard { window, threshold }) => {
421 assert_eq!(window, vec![0.3, 0.2, 0.1]);
422 assert_eq!(threshold, 0.50);
423 }
424 other => panic!("expected FastGuard halt, got {other:?}"),
425 }
426 }
427
428 #[test]
429 fn fast_guard_does_not_trip_on_a_single_bad_ratio() {
430 let mut policy = ReconcilePolicy::default_dual_guard();
431 let history = history_from_ratios(&[0.9, 0.9, 0.1]); assert_eq!(policy.evaluate(&history), HaltDecision::Continue);
433 }
434
435 #[test]
436 fn fast_guard_requires_the_bad_run_to_be_the_most_recent_and_unbroken() {
437 let mut policy = ReconcilePolicy::default_dual_guard();
438 let history = history_from_ratios(&[0.1, 0.9, 0.1, 0.2]);
441 assert_eq!(policy.evaluate(&history), HaltDecision::Continue);
442 }
443
444 #[test]
445 fn fast_guard_is_configurable_not_hardcoded() {
446 let mut policy = ReconcilePolicy::new(
447 FastGuardConfig {
448 threshold: 0.80, window: 2,
450 },
451 SlowGuardConfig {
452 threshold: 0.99,
453 window: usize::MAX,
454 },
455 );
456 let history = history_from_ratios(&[0.75, 0.75]);
459 assert!(matches!(
460 policy.evaluate(&history),
461 HaltDecision::Halt(HaltReason::FastGuard { .. })
462 ));
463 }
464
465 #[test]
469 fn slow_guard_trips_on_a_slow_bleed_the_fast_guard_misses() {
470 let mut policy = ReconcilePolicy::default_dual_guard();
475 let ratios: Vec<f64> = (0..20)
476 .map(|i| 0.65 + (i % 8) as f64 * 0.01) .collect();
478 let mean = ratios.iter().sum::<f64>() / ratios.len() as f64;
479 assert!(mean < 0.70, "test fixture's mean ({mean}) must be < 0.70");
480 assert!(
481 ratios.iter().all(|r| *r >= 0.50),
482 "test fixture must never trip the fast guard on its own"
483 );
484
485 let history = history_from_ratios(&ratios);
486 match policy.evaluate(&history) {
487 HaltDecision::Halt(HaltReason::SlowGuard {
488 window_size,
489 threshold,
490 ..
491 }) => {
492 assert_eq!(window_size, 20);
493 assert_eq!(threshold, 0.70);
494 }
495 other => panic!("expected SlowGuard halt, got {other:?}"),
496 }
497 }
498
499 #[test]
500 fn slow_guard_does_not_trip_with_fewer_than_the_full_window() {
501 let mut policy = ReconcilePolicy::default_dual_guard();
502 let history = history_from_ratios(&[0.6; 19]);
505 assert_eq!(policy.evaluate(&history), HaltDecision::Continue);
506 }
507
508 #[test]
512 fn property_2_slow_guard_can_trip_when_fast_guard_would_not() {
513 let mut policy = ReconcilePolicy::default_dual_guard();
514 let ratios: Vec<f64> = (0..20).map(|i| 0.65 + (i % 8) as f64 * 0.01).collect();
515 let history = history_from_ratios(&ratios);
516 assert!(matches!(
517 policy.evaluate(&history),
518 HaltDecision::Halt(HaltReason::SlowGuard { .. })
519 ));
520 }
521
522 #[test]
523 fn property_2_fast_guard_can_trip_when_slow_guard_would_not() {
524 let mut policy = ReconcilePolicy::default_dual_guard();
525 let mut ratios = vec![1.0; 17];
529 ratios.extend([0.1, 0.1, 0.1]);
530 let history = history_from_ratios(&ratios);
531
532 match policy.evaluate(&history) {
533 HaltDecision::Halt(HaltReason::FastGuard { .. }) => {}
534 other => panic!("expected FastGuard halt, got {other:?}"),
535 }
536 }
537
538 #[test]
543 #[should_panic(expected = "fast-guard window must be > 0")]
544 fn rejects_a_zero_fast_guard_window() {
545 ReconcilePolicy::new(
551 FastGuardConfig {
552 threshold: 0.5,
553 window: 0,
554 },
555 SlowGuardConfig::default(),
556 );
557 }
558
559 #[test]
560 #[should_panic(expected = "slow-guard window must be > 0")]
561 fn rejects_a_zero_slow_guard_window() {
562 ReconcilePolicy::new(
563 FastGuardConfig::default(),
564 SlowGuardConfig {
565 threshold: 0.9,
566 window: 0,
567 },
568 );
569 }
570
571 #[test]
575 fn continues_on_empty_history() {
576 let mut policy = ReconcilePolicy::default_dual_guard();
577 let history = ReconcileHistory::new();
578 assert_eq!(policy.evaluate(&history), HaltDecision::Continue);
579 }
580
581 #[test]
582 fn continues_when_history_is_shorter_than_either_window() {
583 let mut policy = ReconcilePolicy::default_dual_guard();
584 let history = history_from_ratios(&[0.1, 0.1]); assert_eq!(policy.evaluate(&history), HaltDecision::Continue);
586 }
587
588 #[test]
594 fn non_positive_predicted_profit_is_excluded_from_both_guards() {
595 let mut policy = ReconcilePolicy::default_dual_guard();
596 let mut history = ReconcileHistory::new();
597 history.append(PredictedProfit(100), RealizedProfit(10));
599 history.append(PredictedProfit(100), RealizedProfit(10));
600 history.append(PredictedProfit(100), RealizedProfit(10));
601 history.append(PredictedProfit(-5), RealizedProfit(999_999));
604
605 assert!(matches!(
606 policy.evaluate(&history),
607 HaltDecision::Halt(HaltReason::FastGuard { .. })
608 ));
609 }
610
611 #[test]
615 fn revert_burst_trips_on_consecutive_reverts() {
616 let mut policy = ReconcilePolicy::default_dual_guard()
617 .with_revert_burst(RevertBurstConfig { limit: 3 });
618 let mut history = ReconcileHistory::new();
619
620 for _ in 0..10 {
622 history.append(PredictedProfit(100), RealizedProfit(100));
623 }
624 assert_eq!(policy.evaluate(&history), HaltDecision::Continue);
625
626 let rev = driftbrake_core::RevertEvent {
627 tx_hash: [0u8; 32],
628 block_number: 1,
629 reason: None,
630 gas_used: 21_000,
631 effective_gas_price: 30_000_000_000,
632 };
633
634 history.record_revert(rev.clone());
635 assert_eq!(policy.evaluate(&history), HaltDecision::Continue);
636 history.record_revert(rev.clone());
637 assert_eq!(policy.evaluate(&history), HaltDecision::Continue);
638 history.record_revert(rev);
639
640 assert_eq!(
641 policy.evaluate(&history),
642 HaltDecision::Halt(HaltReason::RevertBurst {
643 consecutive_reverts: 3,
644 limit: 3,
645 })
646 );
647 }
648
649 #[test]
650 fn confirmed_pair_resets_revert_burst_counter() {
651 let mut policy = ReconcilePolicy::default_dual_guard()
652 .with_revert_burst(RevertBurstConfig { limit: 3 });
653 let mut history = ReconcileHistory::new();
654
655 let rev = driftbrake_core::RevertEvent {
656 tx_hash: [0u8; 32],
657 block_number: 1,
658 reason: None,
659 gas_used: 21_000,
660 effective_gas_price: 30_000_000_000,
661 };
662
663 history.record_revert(rev.clone());
665 history.record_revert(rev.clone());
666 assert_eq!(policy.evaluate(&history), HaltDecision::Continue);
667
668 history.append(PredictedProfit(100), RealizedProfit(100));
669 assert_eq!(policy.evaluate(&history), HaltDecision::Continue);
670
671 history.record_revert(rev.clone());
672 history.record_revert(rev);
673 assert_eq!(policy.evaluate(&history), HaltDecision::Continue);
674 }
675
676 #[test]
677 fn revert_gas_budget_trips_when_burned_gas_exceeds_limit() {
678 let mut policy = ReconcilePolicy::default_dual_guard()
679 .with_revert_gas(RevertGasConfig {
680 budget_limit: 100_000_000,
681 window: 5,
682 });
683 let mut history = ReconcileHistory::new();
684
685 let rev = driftbrake_core::RevertEvent {
686 tx_hash: [0u8; 32],
687 block_number: 1,
688 reason: None,
689 gas_used: 50_000,
690 effective_gas_price: 1_000, };
692
693 history.record_revert(rev.clone());
694 assert_eq!(policy.evaluate(&history), HaltDecision::Continue);
695
696 history.record_revert(rev.clone());
698 assert_eq!(policy.evaluate(&history), HaltDecision::Continue);
699
700 history.record_revert(rev);
702 assert!(matches!(
703 policy.evaluate(&history),
704 HaltDecision::Halt(HaltReason::RevertGasBudgetExceeded {
705 gas_cost_burned: 150_000_000,
706 budget_limit: 100_000_000,
707 window: 5,
708 })
709 ));
710 }
711
712 #[test]
713 fn volume_weighted_guard_trips_on_whale_collapse_even_if_small_trades_win() {
714 let mut policy = ReconcilePolicy::default_dual_guard()
716 .with_volume_weighted(VolumeWeightedConfig {
717 threshold: 0.75,
718 window: 20,
719 });
720 let mut history = ReconcileHistory::new();
721
722 for _ in 0..19 {
724 history.append(PredictedProfit(10), RealizedProfit(12));
725 }
726
727 history.append(PredictedProfit(100_000), RealizedProfit(40_000));
729
730 match policy.evaluate(&history) {
733 HaltDecision::Halt(HaltReason::VolumeWeightedDrift {
734 weighted_ratio,
735 threshold,
736 window,
737 }) => {
738 assert!(weighted_ratio < 0.41);
739 assert_eq!(threshold, 0.75);
740 assert_eq!(window, 20);
741 }
742 other => panic!("expected VolumeWeightedDrift halt, got {other:?}"),
743 }
744 }
745
746 #[test]
747 fn drawdown_guard_trips_on_absolute_capital_deficit() {
748 let mut policy = ReconcilePolicy::default_dual_guard()
749 .with_drawdown(DrawdownConfig {
750 max_drawdown_limit: 50_000,
751 window: 3,
752 });
753 let mut history = ReconcileHistory::new();
754
755 history.append(PredictedProfit(100_000), RealizedProfit(80_000));
757 history.append(PredictedProfit(100_000), RealizedProfit(80_000));
758 assert_eq!(policy.evaluate(&history), HaltDecision::Continue);
759
760 history.append(PredictedProfit(100_000), RealizedProfit(80_000));
761 assert_eq!(
762 policy.evaluate(&history),
763 HaltDecision::Halt(HaltReason::NetDrawdownExceeded {
764 net_slippage_loss: 60_000,
765 max_drawdown_limit: 50_000,
766 window: 3,
767 })
768 );
769 }
770
771 #[test]
772 fn institutional_default_enables_revert_and_volume_weighted_guards() {
773 let policy = ReconcilePolicy::institutional_default();
774 assert!(policy.revert_burst_config().is_some());
775 assert!(policy.volume_weighted_config().is_some());
776 assert_eq!(policy.revert_burst_config().unwrap().limit, 3);
777 assert_eq!(policy.volume_weighted_config().unwrap().threshold, 0.75);
778 }
779}