Skip to main content

hypercall/liquidator/
partial.rs

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}