1use 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}