Skip to main content

hypercall_engine/
margin_admission.rs

1//! Pure margin-admission decisions.
2
3use rust_decimal::Decimal;
4use rust_decimal_macros::dec;
5
6use hypercall_types::WalletAddress;
7
8#[derive(Debug, Clone, PartialEq, Eq)]
9pub enum MarginAdmissionDecision {
10    Accepted,
11    Rejected(String),
12}
13
14#[derive(Debug, Clone)]
15pub struct PortfolioMarginAdmissionInput {
16    pub is_reduce_only: bool,
17    pub available_collateral: Decimal,
18    pub margin_required: Decimal,
19    pub settlement_context: Option<PmAdmissionSettlementContext>,
20}
21
22#[derive(Debug, Clone)]
23pub struct PmAdmissionSettlementContext {
24    pub wallet: WalletAddress,
25    pub underlying: String,
26    pub pool_available_usdc: Decimal,
27    pub pool_target_usdc: Decimal,
28    pub active_timing_bridge_usdc: Decimal,
29    pub active_settlement_debt_usdc: Decimal,
30    pub current_utilization: Option<Decimal>,
31    pub projected_post_order_utilization: Option<Decimal>,
32    pub account_bridge_usdc: Decimal,
33    pub account_debt_usdc: Decimal,
34    pub bridge_overdue: bool,
35    pub facts_as_of_ms: i64,
36    pub policy_version: u32,
37    pub normal_utilization_cap: Decimal,
38    pub crisis_utilization_cap: Decimal,
39}
40
41#[derive(Debug, Clone, Copy)]
42pub struct StandardMarginAdmissionInput {
43    pub is_reduce_only: bool,
44    pub is_closing_position: bool,
45    pub is_premium_debiting_buy: bool,
46    pub equity: Decimal,
47    pub post_balance: Decimal,
48    pub total_reserved_premium: Decimal,
49    pub initial_margin: Decimal,
50    pub position_im: Decimal,
51    pub current_open_orders_im: Decimal,
52    pub incremental_open_orders_im: Decimal,
53    pub post_accept_open_orders_im: Decimal,
54}
55
56pub fn decide_portfolio_margin(input: PortfolioMarginAdmissionInput) -> MarginAdmissionDecision {
57    if input.is_reduce_only {
58        return MarginAdmissionDecision::Accepted;
59    }
60
61    if let Some(context) = &input.settlement_context {
62        if context.pool_available_usdc < context.pool_target_usdc {
63            return MarginAdmissionDecision::Rejected(format!(
64                "PM settlement pool below target for {}: available={}, target={}",
65                context.underlying, context.pool_available_usdc, context.pool_target_usdc
66            ));
67        }
68        if context.account_debt_usdc > Decimal::ZERO {
69            return MarginAdmissionDecision::Rejected(format!(
70                "PM settlement debt is active for {}: debt={}",
71                context.underlying, context.account_debt_usdc
72            ));
73        }
74        if context.bridge_overdue {
75            return MarginAdmissionDecision::Rejected(format!(
76                "PM settlement bridge is overdue for {}",
77                context.underlying
78            ));
79        }
80        let Some(current_utilization) = context.current_utilization else {
81            return MarginAdmissionDecision::Rejected(format!(
82                "Missing PM settlement current utilization for {}",
83                context.underlying
84            ));
85        };
86        if current_utilization > context.crisis_utilization_cap {
87            return MarginAdmissionDecision::Rejected(format!(
88                "PM settlement utilization above crisis cap for {}: utilization={}, cap={}",
89                context.underlying, current_utilization, context.crisis_utilization_cap
90            ));
91        }
92        let Some(projected_utilization) = context.projected_post_order_utilization else {
93            return MarginAdmissionDecision::Rejected(format!(
94                "Missing PM settlement projected utilization for {}",
95                context.underlying
96            ));
97        };
98        if projected_utilization > context.normal_utilization_cap {
99            return MarginAdmissionDecision::Rejected(format!(
100                "PM settlement projected utilization above normal cap for {}: projected={}, cap={}",
101                context.underlying, projected_utilization, context.normal_utilization_cap
102            ));
103        }
104    }
105
106    let excess_margin = input.available_collateral - input.margin_required;
107    if excess_margin < Decimal::ZERO {
108        return MarginAdmissionDecision::Rejected(format!(
109            "Insufficient margin: required={:.2}, available={:.2}, shortfall={:.2}",
110            input.margin_required, input.available_collateral, -excess_margin
111        ));
112    }
113
114    MarginAdmissionDecision::Accepted
115}
116
117pub fn decide_standard_margin(input: StandardMarginAdmissionInput) -> MarginAdmissionDecision {
118    if input.is_reduce_only || input.is_closing_position {
119        return MarginAdmissionDecision::Accepted;
120    }
121
122    if input.equity < dec!(0) {
123        return MarginAdmissionDecision::Rejected(format!(
124            "Insufficient funds (Standard): balance after premium reservation is negative. \
125                equity={:.2}, reserved_premium={:.2}, shortfall={:.2}",
126            input.equity, input.total_reserved_premium, -input.equity
127        ));
128    }
129
130    if input.is_premium_debiting_buy && input.post_balance < dec!(0) {
131        return MarginAdmissionDecision::Rejected(format!(
132            "Insufficient cash (Standard): USDC balance after premium would be negative. \
133                 post_balance={:.8}, reserved_premium={:.2}",
134            input.post_balance, input.total_reserved_premium
135        ));
136    }
137
138    if input.initial_margin < dec!(0) {
139        return MarginAdmissionDecision::Rejected(format!(
140            "Insufficient margin (Standard): equity={:.2}, position_im={:.2}, current_open_orders_im={:.2}, incremental_open_orders_im={:.2}, post_accept_open_orders_im={:.2}, reserved_premium={:.2}, shortfall={:.2}",
141            input.equity,
142            input.position_im,
143            input.current_open_orders_im,
144            input.incremental_open_orders_im,
145            input.post_accept_open_orders_im,
146            input.total_reserved_premium,
147            -input.initial_margin
148        ));
149    }
150
151    MarginAdmissionDecision::Accepted
152}
153
154pub fn margin_decision_result(decision: MarginAdmissionDecision) -> Result<(), String> {
155    match decision {
156        MarginAdmissionDecision::Accepted => Ok(()),
157        MarginAdmissionDecision::Rejected(reason) => Err(reason),
158    }
159}
160
161#[cfg(test)]
162mod tests {
163    use super::*;
164    use std::str::FromStr;
165
166    fn wallet() -> WalletAddress {
167        WalletAddress::from_str("0x1234567890123456789012345678901234567890").expect("valid wallet")
168    }
169
170    fn settlement_context() -> PmAdmissionSettlementContext {
171        PmAdmissionSettlementContext {
172            wallet: wallet(),
173            underlying: "BTC".to_string(),
174            pool_available_usdc: dec!(1_000),
175            pool_target_usdc: dec!(500),
176            active_timing_bridge_usdc: dec!(100),
177            active_settlement_debt_usdc: dec!(0),
178            current_utilization: Some(dec!(0.10)),
179            projected_post_order_utilization: Some(dec!(0.20)),
180            account_bridge_usdc: dec!(0),
181            account_debt_usdc: dec!(0),
182            bridge_overdue: false,
183            facts_as_of_ms: 1_780_000_000_000,
184            policy_version: 1,
185            normal_utilization_cap: dec!(0.80),
186            crisis_utilization_cap: dec!(0.98),
187        }
188    }
189
190    #[test]
191    fn portfolio_reduce_only_is_accepted_despite_shortfall() {
192        assert_eq!(
193            decide_portfolio_margin(PortfolioMarginAdmissionInput {
194                is_reduce_only: true,
195                available_collateral: dec!(10),
196                margin_required: dec!(20),
197                settlement_context: None,
198            }),
199            MarginAdmissionDecision::Accepted
200        );
201    }
202
203    #[test]
204    fn portfolio_shortfall_is_rejected() {
205        let decision = decide_portfolio_margin(PortfolioMarginAdmissionInput {
206            is_reduce_only: false,
207            available_collateral: dec!(10),
208            margin_required: dec!(20),
209            settlement_context: None,
210        });
211        assert!(
212            matches!(decision, MarginAdmissionDecision::Rejected(reason) if reason.contains("Insufficient margin"))
213        );
214    }
215
216    #[test]
217    fn standard_closing_position_is_accepted_despite_shortfall() {
218        assert_eq!(
219            decide_standard_margin(StandardMarginAdmissionInput {
220                is_reduce_only: false,
221                is_closing_position: true,
222                is_premium_debiting_buy: true,
223                equity: dec!(-1),
224                post_balance: dec!(-1),
225                total_reserved_premium: dec!(5),
226                initial_margin: dec!(-1),
227                position_im: dec!(1),
228                current_open_orders_im: dec!(0),
229                incremental_open_orders_im: dec!(0),
230                post_accept_open_orders_im: dec!(0),
231            }),
232            MarginAdmissionDecision::Accepted
233        );
234    }
235
236    #[test]
237    fn standard_negative_cash_buy_is_rejected() {
238        let decision = decide_standard_margin(StandardMarginAdmissionInput {
239            is_reduce_only: false,
240            is_closing_position: false,
241            is_premium_debiting_buy: true,
242            equity: dec!(1),
243            post_balance: dec!(-1),
244            total_reserved_premium: dec!(5),
245            initial_margin: dec!(1),
246            position_im: dec!(1),
247            current_open_orders_im: dec!(0),
248            incremental_open_orders_im: dec!(0),
249            post_accept_open_orders_im: dec!(0),
250        });
251        assert!(
252            matches!(decision, MarginAdmissionDecision::Rejected(reason) if reason.contains("Insufficient cash"))
253        );
254    }
255
256    #[test]
257    fn standard_negative_equity_is_rejected() {
258        let decision = decide_standard_margin(StandardMarginAdmissionInput {
259            is_reduce_only: false,
260            is_closing_position: false,
261            is_premium_debiting_buy: false,
262            equity: dec!(-1),
263            post_balance: dec!(100),
264            total_reserved_premium: dec!(0),
265            initial_margin: dec!(100),
266            position_im: dec!(0),
267            current_open_orders_im: dec!(0),
268            incremental_open_orders_im: dec!(0),
269            post_accept_open_orders_im: dec!(0),
270        });
271        assert!(matches!(
272            decision,
273            MarginAdmissionDecision::Rejected(reason) if reason.contains("Insufficient funds")
274        ));
275    }
276
277    #[test]
278    fn standard_insufficient_margin_is_rejected() {
279        let decision = decide_standard_margin(StandardMarginAdmissionInput {
280            is_reduce_only: false,
281            is_closing_position: false,
282            is_premium_debiting_buy: false,
283            equity: dec!(100),
284            post_balance: dec!(100),
285            total_reserved_premium: dec!(0),
286            initial_margin: dec!(-50),
287            position_im: dec!(80),
288            current_open_orders_im: dec!(40),
289            incremental_open_orders_im: dec!(30),
290            post_accept_open_orders_im: dec!(70),
291        });
292        assert!(matches!(
293            decision,
294            MarginAdmissionDecision::Rejected(reason) if reason.contains("Insufficient margin")
295        ));
296    }
297
298    #[test]
299    fn standard_sufficient_margin_is_accepted() {
300        assert_eq!(
301            decide_standard_margin(StandardMarginAdmissionInput {
302                is_reduce_only: false,
303                is_closing_position: false,
304                is_premium_debiting_buy: false,
305                equity: dec!(1000),
306                post_balance: dec!(1000),
307                total_reserved_premium: dec!(0),
308                initial_margin: dec!(500),
309                position_im: dec!(200),
310                current_open_orders_im: dec!(100),
311                incremental_open_orders_im: dec!(50),
312                post_accept_open_orders_im: dec!(150),
313            }),
314            MarginAdmissionDecision::Accepted
315        );
316    }
317
318    #[test]
319    fn standard_reduce_only_bypasses_all_checks() {
320        assert_eq!(
321            decide_standard_margin(StandardMarginAdmissionInput {
322                is_reduce_only: true,
323                is_closing_position: false,
324                is_premium_debiting_buy: true,
325                equity: dec!(-1000),
326                post_balance: dec!(-1000),
327                total_reserved_premium: dec!(0),
328                initial_margin: dec!(-1000),
329                position_im: dec!(0),
330                current_open_orders_im: dec!(0),
331                incremental_open_orders_im: dec!(0),
332                post_accept_open_orders_im: dec!(0),
333            }),
334            MarginAdmissionDecision::Accepted
335        );
336    }
337
338    #[test]
339    fn portfolio_sufficient_margin_is_accepted() {
340        assert_eq!(
341            decide_portfolio_margin(PortfolioMarginAdmissionInput {
342                is_reduce_only: false,
343                available_collateral: dec!(100),
344                margin_required: dec!(50),
345                settlement_context: None,
346            }),
347            MarginAdmissionDecision::Accepted
348        );
349    }
350
351    #[test]
352    fn portfolio_settlement_gate_missing_projected_utilization_rejects_risk_increase() {
353        let mut context = settlement_context();
354        context.projected_post_order_utilization = None;
355        let decision = decide_portfolio_margin(PortfolioMarginAdmissionInput {
356            is_reduce_only: false,
357            available_collateral: dec!(1_000),
358            margin_required: dec!(1),
359            settlement_context: Some(context),
360        });
361
362        assert!(matches!(
363            decision,
364            MarginAdmissionDecision::Rejected(reason)
365                if reason.contains("Missing PM settlement projected utilization")
366        ));
367    }
368
369    #[test]
370    fn portfolio_settlement_gate_rejects_stressed_pool_conditions() {
371        let mut below_target = settlement_context();
372        below_target.pool_available_usdc = dec!(100);
373        let decision = decide_portfolio_margin(PortfolioMarginAdmissionInput {
374            is_reduce_only: false,
375            available_collateral: dec!(1_000),
376            margin_required: dec!(1),
377            settlement_context: Some(below_target),
378        });
379        assert!(matches!(
380            decision,
381            MarginAdmissionDecision::Rejected(reason) if reason.contains("below target")
382        ));
383
384        let mut debt = settlement_context();
385        debt.account_debt_usdc = dec!(1);
386        let decision = decide_portfolio_margin(PortfolioMarginAdmissionInput {
387            is_reduce_only: false,
388            available_collateral: dec!(1_000),
389            margin_required: dec!(1),
390            settlement_context: Some(debt),
391        });
392        assert!(matches!(
393            decision,
394            MarginAdmissionDecision::Rejected(reason) if reason.contains("debt is active")
395        ));
396
397        let mut overdue = settlement_context();
398        overdue.bridge_overdue = true;
399        let decision = decide_portfolio_margin(PortfolioMarginAdmissionInput {
400            is_reduce_only: false,
401            available_collateral: dec!(1_000),
402            margin_required: dec!(1),
403            settlement_context: Some(overdue),
404        });
405        assert!(matches!(
406            decision,
407            MarginAdmissionDecision::Rejected(reason) if reason.contains("overdue")
408        ));
409
410        let mut above_cap = settlement_context();
411        above_cap.projected_post_order_utilization = Some(dec!(0.81));
412        let decision = decide_portfolio_margin(PortfolioMarginAdmissionInput {
413            is_reduce_only: false,
414            available_collateral: dec!(1_000),
415            margin_required: dec!(1),
416            settlement_context: Some(above_cap),
417        });
418        assert!(matches!(
419            decision,
420            MarginAdmissionDecision::Rejected(reason) if reason.contains("above normal cap")
421        ));
422    }
423
424    #[test]
425    fn portfolio_reduce_only_bypasses_settlement_gate() {
426        let mut context = settlement_context();
427        context.projected_post_order_utilization = None;
428        context.account_debt_usdc = dec!(10);
429
430        assert_eq!(
431            decide_portfolio_margin(PortfolioMarginAdmissionInput {
432                is_reduce_only: true,
433                available_collateral: dec!(0),
434                margin_required: dec!(10),
435                settlement_context: Some(context),
436            }),
437            MarginAdmissionDecision::Accepted
438        );
439    }
440
441    #[test]
442    fn margin_decision_result_conversion() {
443        assert!(margin_decision_result(MarginAdmissionDecision::Accepted).is_ok());
444        assert!(margin_decision_result(MarginAdmissionDecision::Rejected("bad".into())).is_err());
445    }
446}