1use rust_decimal::Decimal;
2use rust_decimal_macros::dec;
3
4#[derive(Debug, Clone, PartialEq, Eq)]
5pub struct LiquidationSliceCandidate {
6 pub symbol: String,
7 pub max_close_size: Decimal,
8 pub expected_fill_price: Decimal,
9 pub mm_relief_per_unit: Decimal,
10}
11
12#[derive(Debug, Clone, PartialEq, Eq)]
13pub struct PartialLiquidationOrderPlan {
14 pub symbol: String,
15 pub close_size: Decimal,
16 pub limit_price: Decimal,
17 pub expected_mm_relief: Decimal,
18}
19
20pub fn compute_partial_bonus_bps(
21 start_bps: u32,
22 max_bps: u32,
23 ramp_ms: u64,
24 entered_at: u64,
25 now: u64,
26) -> u32 {
27 if max_bps <= start_bps || ramp_ms == 0 {
28 return max_bps.max(start_bps);
29 }
30
31 let elapsed_ms = now.saturating_sub(entered_at);
32 if elapsed_ms >= ramp_ms {
33 return max_bps;
34 }
35
36 let bonus_range = u64::from(max_bps - start_bps);
37 let ramped = u64::from(start_bps) + (bonus_range * elapsed_ms / ramp_ms);
38 ramped as u32
39}
40
41pub fn partial_buffer_multiplier(buffer_bps: u32) -> Decimal {
42 Decimal::ONE + (Decimal::from(buffer_bps) / dec!(10000))
43}
44
45pub fn target_equity_from_mm(mm_required: Decimal, buffer_bps: u32) -> Decimal {
46 mm_required * partial_buffer_multiplier(buffer_bps)
47}
48
49pub fn required_mm_relief(equity: Decimal, mm_required: Decimal, buffer_bps: u32) -> Decimal {
50 let target_mm_cap = equity / partial_buffer_multiplier(buffer_bps);
51 (mm_required - target_mm_cap).max(Decimal::ZERO)
52}
53
54pub fn is_above_partial_target(equity: Decimal, mm_required: Decimal, buffer_bps: u32) -> bool {
55 equity >= target_equity_from_mm(mm_required, buffer_bps)
56}
57
58pub fn greedy_partial_plan(
59 candidates: &[LiquidationSliceCandidate],
60 required_mm_relief_amount: Decimal,
61) -> Vec<PartialLiquidationOrderPlan> {
62 if required_mm_relief_amount <= Decimal::ZERO {
63 return Vec::new();
64 }
65
66 let mut ordered_candidates: Vec<_> = candidates
67 .iter()
68 .filter(|candidate| {
69 candidate.max_close_size > Decimal::ZERO
70 && candidate.expected_fill_price > Decimal::ZERO
71 && candidate.mm_relief_per_unit > Decimal::ZERO
72 })
73 .cloned()
74 .collect();
75
76 ordered_candidates.sort_by(|left, right| {
77 right
78 .mm_relief_per_unit
79 .cmp(&left.mm_relief_per_unit)
80 .then_with(|| left.symbol.cmp(&right.symbol))
81 });
82
83 let mut remaining_relief = required_mm_relief_amount;
84 let mut plans = Vec::new();
85
86 for candidate in ordered_candidates {
87 if remaining_relief <= Decimal::ZERO {
88 break;
89 }
90
91 let desired_close_size =
92 (remaining_relief / candidate.mm_relief_per_unit).min(candidate.max_close_size);
93 if desired_close_size <= Decimal::ZERO {
94 continue;
95 }
96
97 let expected_mm_relief = desired_close_size * candidate.mm_relief_per_unit;
98 plans.push(PartialLiquidationOrderPlan {
99 symbol: candidate.symbol,
100 close_size: desired_close_size,
101 limit_price: candidate.expected_fill_price,
102 expected_mm_relief,
103 });
104 remaining_relief = (remaining_relief - expected_mm_relief).max(Decimal::ZERO);
105 }
106
107 plans
108}
109
110#[cfg(test)]
111mod tests {
112 use super::*;
113 use rust_decimal_macros::dec;
114
115 #[test]
116 fn test_compute_partial_bonus_bps_ramps_linearly() {
117 assert_eq!(compute_partial_bonus_bps(50, 150, 1_000, 1_000, 1_000), 50);
118 assert_eq!(compute_partial_bonus_bps(50, 150, 1_000, 1_000, 1_500), 100);
119 assert_eq!(compute_partial_bonus_bps(50, 150, 1_000, 1_000, 2_000), 150);
120 assert_eq!(compute_partial_bonus_bps(50, 150, 1_000, 1_000, 3_000), 150);
121 }
122
123 #[test]
124 fn test_required_mm_relief_respects_buffer() {
125 let relief = required_mm_relief(dec!(1000), dec!(1200), 500);
126 assert_eq!(relief, dec!(247.6190476190476190476190476));
127 assert!(is_above_partial_target(dec!(1260), dec!(1200), 500));
128 assert!(!is_above_partial_target(dec!(1259.99), dec!(1200), 500));
129 }
130
131 #[test]
132 fn test_greedy_partial_plan_orders_by_relief_then_symbol() {
133 let plans = greedy_partial_plan(
134 &[
135 LiquidationSliceCandidate {
136 symbol: "BTC-20251231-90000-P".to_string(),
137 max_close_size: dec!(3),
138 expected_fill_price: dec!(210),
139 mm_relief_per_unit: dec!(40),
140 },
141 LiquidationSliceCandidate {
142 symbol: "BTC-20251231-100000-C".to_string(),
143 max_close_size: dec!(5),
144 expected_fill_price: dec!(120),
145 mm_relief_per_unit: dec!(80),
146 },
147 LiquidationSliceCandidate {
148 symbol: "BTC-20251231-80000-P".to_string(),
149 max_close_size: dec!(2),
150 expected_fill_price: dec!(300),
151 mm_relief_per_unit: dec!(80),
152 },
153 ],
154 dec!(480),
155 );
156
157 assert_eq!(plans.len(), 2);
158 assert_eq!(plans[0].symbol, "BTC-20251231-100000-C");
159 assert_eq!(plans[0].close_size, dec!(5));
160 assert_eq!(plans[1].symbol, "BTC-20251231-80000-P");
161 assert_eq!(plans[1].close_size, dec!(1));
162 }
163
164 #[test]
165 fn test_greedy_partial_plan_skips_zero_relief_candidates() {
166 let plans = greedy_partial_plan(
167 &[
168 LiquidationSliceCandidate {
169 symbol: "BTC-20251231-100000-C".to_string(),
170 max_close_size: dec!(3),
171 expected_fill_price: dec!(120),
172 mm_relief_per_unit: Decimal::ZERO,
173 },
174 LiquidationSliceCandidate {
175 symbol: "BTC-20251231-90000-P".to_string(),
176 max_close_size: dec!(2),
177 expected_fill_price: dec!(210),
178 mm_relief_per_unit: dec!(60),
179 },
180 ],
181 dec!(60),
182 );
183
184 assert_eq!(plans.len(), 1);
185 assert_eq!(plans[0].symbol, "BTC-20251231-90000-P");
186 assert_eq!(plans[0].close_size, dec!(1));
187 }
188}