Skip to main content

hypercall_margin/standard/
service.rs

1use super::params::StandardMarginParams;
2use super::types::{OptionPosition, StandardAccount};
3use rust_decimal::Decimal;
4use rust_decimal_macros::dec;
5use serde::{Deserialize, Serialize};
6use std::collections::HashMap;
7
8#[derive(Debug, Clone, Serialize, Deserialize)]
9pub struct StandardMarginResult {
10    pub equity: Decimal,
11    pub position_im: Decimal,
12    pub position_mm: Decimal,
13    pub open_orders_im: Decimal,
14    pub initial_margin: Decimal,
15    pub maintenance_margin: Decimal,
16}
17
18#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
19pub struct PositionMarginContribution {
20    pub initial_margin: Decimal,
21    pub maintenance_margin: Decimal,
22}
23
24impl StandardMarginResult {
25    pub fn can_increase_risk(&self) -> bool {
26        self.initial_margin >= dec!(0)
27    }
28
29    pub fn is_liquidatable(&self) -> bool {
30        self.maintenance_margin < dec!(0)
31    }
32
33    pub fn available_balance(&self) -> Decimal {
34        self.initial_margin.max(dec!(0))
35    }
36}
37
38pub struct StandardMarginService {
39    params: StandardMarginParams,
40}
41
42impl StandardMarginService {
43    pub fn new() -> Self {
44        Self {
45            params: StandardMarginParams::default(),
46        }
47    }
48
49    pub fn with_params(params: StandardMarginParams) -> Self {
50        Self { params }
51    }
52
53    pub fn compute_margin(&self, account: &StandardAccount) -> StandardMarginResult {
54        let equity = self.calculate_equity(account);
55        let (perp_im, perp_mm) = self.calculate_perp_margin(account);
56        let (option_im, option_mm) = self.calculate_option_margin(account);
57        let position_im = perp_im + option_im;
58        let position_mm = perp_mm + option_mm;
59        let open_orders_im = dec!(0);
60        let initial_margin = equity - position_im - open_orders_im;
61        let maintenance_margin = equity - position_mm;
62
63        StandardMarginResult {
64            equity,
65            position_im,
66            position_mm,
67            open_orders_im,
68            initial_margin,
69            maintenance_margin,
70        }
71    }
72
73    pub fn compute_position_margin_breakdown(
74        &self,
75        account: &StandardAccount,
76    ) -> HashMap<String, PositionMarginContribution> {
77        let mut contributions: HashMap<String, PositionMarginContribution> = HashMap::new();
78
79        for perp in &account.perp_positions {
80            let notional = perp.notional();
81            let im = self.params.perp_im_pct * notional;
82            let mm = self.params.perp_mm_pct * notional;
83
84            let entry =
85                contributions
86                    .entry(perp.symbol.clone())
87                    .or_insert(PositionMarginContribution {
88                        initial_margin: dec!(0),
89                        maintenance_margin: dec!(0),
90                    });
91            entry.initial_margin += im;
92            entry.maintenance_margin += mm;
93        }
94
95        let mut netted_options: HashMap<String, (Decimal, &OptionPosition)> = HashMap::new();
96        for option in &account.option_positions {
97            let entry = netted_options
98                .entry(option.symbol.clone())
99                .or_insert_with(|| (dec!(0), option));
100            entry.0 += option.size;
101            if entry.1.spot_price == dec!(0) && option.spot_price != dec!(0) {
102                entry.1 = option;
103            }
104        }
105
106        for (symbol, (net_size, representative)) in netted_options {
107            if net_size >= dec!(0) {
108                continue;
109            }
110
111            let qty = net_size.abs();
112            let spot = representative.spot_price;
113            let otm = representative.otm_amount();
114
115            let base_im = self.params.short_option_spot_pct * spot - otm;
116            let floor_im = self.params.short_im_floor_pct(representative.is_call) * spot;
117            let per_contract_im = base_im.max(floor_im);
118            let im = qty * per_contract_im;
119
120            let per_contract_mm = self.params.short_mm_pct(representative.is_call) * spot;
121            let mm = qty * per_contract_mm;
122
123            let entry = contributions
124                .entry(symbol)
125                .or_insert(PositionMarginContribution {
126                    initial_margin: dec!(0),
127                    maintenance_margin: dec!(0),
128                });
129            entry.initial_margin += im;
130            entry.maintenance_margin += mm;
131        }
132
133        contributions
134    }
135
136    fn calculate_equity(&self, account: &StandardAccount) -> Decimal {
137        let mut equity = account.usdc_balance;
138        for perp in &account.perp_positions {
139            equity += perp.unrealized_pnl();
140        }
141        for option in &account.option_positions {
142            equity += option.signed_market_value();
143        }
144        equity
145    }
146
147    fn calculate_perp_margin(&self, account: &StandardAccount) -> (Decimal, Decimal) {
148        let mut total_im = dec!(0);
149        let mut total_mm = dec!(0);
150        for perp in &account.perp_positions {
151            let notional = perp.notional();
152            total_im += self.params.perp_im_pct * notional;
153            total_mm += self.params.perp_mm_pct * notional;
154        }
155        (total_im, total_mm)
156    }
157
158    fn calculate_option_margin(&self, account: &StandardAccount) -> (Decimal, Decimal) {
159        let mut netted: HashMap<String, (Decimal, &OptionPosition)> = HashMap::new();
160        for option in &account.option_positions {
161            let entry = netted
162                .entry(option.symbol.clone())
163                .or_insert_with(|| (dec!(0), option));
164            entry.0 += option.size;
165            if entry.1.spot_price == dec!(0) && option.spot_price != dec!(0) {
166                entry.1 = option;
167            }
168        }
169
170        let mut total_im = dec!(0);
171        let mut total_mm = dec!(0);
172
173        for (net_size, representative) in netted.values() {
174            if *net_size >= dec!(0) {
175                continue;
176            }
177            let qty = net_size.abs();
178            let spot = representative.spot_price;
179            let otm = representative.otm_amount();
180
181            let base_im = self.params.short_option_spot_pct * spot - otm;
182            let floor_im = self.params.short_im_floor_pct(representative.is_call) * spot;
183            let per_contract_im = base_im.max(floor_im);
184            total_im += qty * per_contract_im;
185
186            let per_contract_mm = self.params.short_mm_pct(representative.is_call) * spot;
187            total_mm += qty * per_contract_mm;
188        }
189
190        (total_im, total_mm)
191    }
192
193    /// Check if a proposed trade would increase risk.
194    ///
195    /// `is_option` indicates whether the symbol is an option (vs perp).
196    pub fn is_risk_increasing(
197        &self,
198        account: &StandardAccount,
199        symbol: &str,
200        side_is_buy: bool,
201        quantity: Decimal,
202        is_option: bool,
203    ) -> bool {
204        let option_pos = account.option_positions.iter().find(|p| p.symbol == symbol);
205        let perp_pos = account.perp_positions.iter().find(|p| p.symbol == symbol);
206
207        if let Some(opt) = option_pos {
208            let current_size = opt.size;
209            let new_size = if side_is_buy {
210                current_size + quantity
211            } else {
212                current_size - quantity
213            };
214
215            if current_size > dec!(0) && side_is_buy {
216                return false;
217            }
218            if current_size == dec!(0) && side_is_buy {
219                return false;
220            }
221            if current_size < dec!(0) {
222                if side_is_buy && new_size.abs() < current_size.abs() {
223                    return false;
224                }
225                return !side_is_buy;
226            }
227            if current_size > dec!(0) && !side_is_buy {
228                return new_size < dec!(0);
229            }
230            !side_is_buy
231        } else if let Some(perp) = perp_pos {
232            let current_size = perp.size;
233            let new_size = if side_is_buy {
234                current_size + quantity
235            } else {
236                current_size - quantity
237            };
238            new_size.abs() > current_size.abs()
239        } else {
240            !(is_option && side_is_buy)
241        }
242    }
243}
244
245impl Default for StandardMarginService {
246    fn default() -> Self {
247        Self::new()
248    }
249}
250
251#[cfg(test)]
252mod tests {
253    use super::*;
254    use crate::standard::types::{OptionPosition, PerpPosition};
255
256    fn create_test_account() -> StandardAccount {
257        StandardAccount::new("test_wallet".to_string(), dec!(10000))
258    }
259
260    #[test]
261    fn test_empty_account() {
262        let service = StandardMarginService::new();
263        let account = create_test_account();
264        let result = service.compute_margin(&account);
265
266        assert_eq!(result.equity, dec!(10000));
267        assert_eq!(result.position_im, dec!(0));
268        assert_eq!(result.position_mm, dec!(0));
269        assert_eq!(result.initial_margin, dec!(10000));
270        assert_eq!(result.maintenance_margin, dec!(10000));
271        assert!(result.can_increase_risk());
272        assert!(!result.is_liquidatable());
273    }
274
275    #[test]
276    fn test_long_option_zero_margin() {
277        let service = StandardMarginService::new();
278        let mut account = StandardAccount::new("test".to_string(), dec!(9000));
279        account.option_positions.push(OptionPosition {
280            symbol: "ETH-20251231-4000-C".to_string(),
281            underlying: "ETH".to_string(),
282            expiry_ts: 0,
283            strike: dec!(4000),
284            is_call: true,
285            size: dec!(10),
286            mark_price: dec!(150),
287            entry_price: dec!(100),
288            spot_price: dec!(3500),
289        });
290
291        let result = service.compute_margin(&account);
292        assert_eq!(result.position_im, dec!(0));
293        assert_eq!(result.position_mm, dec!(0));
294        assert_eq!(result.equity, dec!(10500));
295        assert_eq!(result.initial_margin, dec!(10500));
296    }
297
298    #[test]
299    fn test_short_option_margin() {
300        let service = StandardMarginService::new();
301        let mut account = StandardAccount::new("test".to_string(), dec!(11000));
302        account.option_positions.push(OptionPosition {
303            symbol: "ETH-20251231-4000-C".to_string(),
304            underlying: "ETH".to_string(),
305            expiry_ts: 0,
306            strike: dec!(4000),
307            is_call: true,
308            size: dec!(-5),
309            mark_price: dec!(150),
310            entry_price: dec!(200),
311            spot_price: dec!(3500),
312        });
313
314        let result = service.compute_margin(&account);
315        assert_eq!(result.position_im, dec!(1750));
316        assert_eq!(result.position_mm, dec!(1050));
317        assert_eq!(result.equity, dec!(10250));
318        assert_eq!(result.initial_margin, dec!(8500));
319        assert!(result.can_increase_risk());
320    }
321
322    #[test]
323    fn test_position_margin_breakdown_for_short_option() {
324        let service = StandardMarginService::new();
325        let mut account = create_test_account();
326        account.option_positions.push(OptionPosition {
327            symbol: "ETH-20251231-4000-C".to_string(),
328            underlying: "ETH".to_string(),
329            expiry_ts: 0,
330            strike: dec!(4000),
331            is_call: true,
332            size: dec!(-5),
333            mark_price: dec!(150),
334            entry_price: dec!(200),
335            spot_price: dec!(3500),
336        });
337
338        let breakdown = service.compute_position_margin_breakdown(&account);
339        let contribution = breakdown.get("ETH-20251231-4000-C").expect("should exist");
340        assert_eq!(contribution.initial_margin, dec!(1750));
341        assert_eq!(contribution.maintenance_margin, dec!(1050));
342    }
343
344    #[test]
345    fn test_position_margin_breakdown_matches_total_margin() {
346        let service = StandardMarginService::new();
347        let mut account = create_test_account();
348
349        account.option_positions.push(OptionPosition {
350            symbol: "ETH-20251231-4000-C".to_string(),
351            underlying: "ETH".to_string(),
352            expiry_ts: 0,
353            strike: dec!(4000),
354            is_call: true,
355            size: dec!(-5),
356            mark_price: dec!(150),
357            entry_price: dec!(200),
358            spot_price: dec!(3500),
359        });
360        account.option_positions.push(OptionPosition {
361            symbol: "ETH-20251231-3200-P".to_string(),
362            underlying: "ETH".to_string(),
363            expiry_ts: 0,
364            strike: dec!(3200),
365            is_call: false,
366            size: dec!(3),
367            mark_price: dec!(100),
368            entry_price: dec!(90),
369            spot_price: dec!(3500),
370        });
371        account.perp_positions.push(PerpPosition {
372            symbol: "ETH-PERP".to_string(),
373            underlying: "ETH".to_string(),
374            size: dec!(2),
375            mark_price: dec!(3500),
376            entry_price: dec!(3400),
377        });
378
379        let total = service.compute_margin(&account);
380        let breakdown = service.compute_position_margin_breakdown(&account);
381
382        let sum_im: Decimal = breakdown.values().map(|c| c.initial_margin).sum();
383        let sum_mm: Decimal = breakdown.values().map(|c| c.maintenance_margin).sum();
384
385        assert_eq!(sum_im, total.position_im);
386        assert_eq!(sum_mm, total.position_mm);
387        assert!(!breakdown.contains_key("ETH-20251231-3200-P"));
388    }
389
390    #[test]
391    fn test_perp_margin() {
392        let service = StandardMarginService::new();
393        let mut account = create_test_account();
394        account.perp_positions.push(PerpPosition {
395            symbol: "ETH-PERP".to_string(),
396            underlying: "ETH".to_string(),
397            size: dec!(2),
398            mark_price: dec!(3500),
399            entry_price: dec!(3400),
400        });
401
402        let result = service.compute_margin(&account);
403        assert_eq!(result.position_im, dec!(700));
404        assert_eq!(result.position_mm, dec!(350));
405        assert_eq!(result.equity, dec!(10200));
406    }
407
408    #[test]
409    fn test_liquidation_condition() {
410        let service = StandardMarginService::new();
411        let mut account = StandardAccount::new("test".to_string(), dec!(2500));
412        account.option_positions.push(OptionPosition {
413            symbol: "ETH-20251231-3500-C".to_string(),
414            underlying: "ETH".to_string(),
415            expiry_ts: 0,
416            strike: dec!(3500),
417            is_call: true,
418            size: dec!(-10),
419            mark_price: dec!(300),
420            entry_price: dec!(200),
421            spot_price: dec!(3500),
422        });
423
424        let result = service.compute_margin(&account);
425        assert_eq!(result.equity, dec!(-500));
426        assert_eq!(result.position_im, dec!(5250));
427        assert_eq!(result.position_mm, dec!(2100));
428        assert!(result.is_liquidatable());
429        assert!(!result.can_increase_risk());
430    }
431
432    #[test]
433    fn test_is_risk_increasing_open_long() {
434        let service = StandardMarginService::new();
435        let account = create_test_account();
436        assert!(!service.is_risk_increasing(&account, "ETH-20251231-4000-C", true, dec!(10), true));
437        assert!(service.is_risk_increasing(&account, "ETH-20251231-4000-C", false, dec!(10), true));
438    }
439
440    #[test]
441    fn test_is_risk_increasing_close_short() {
442        let service = StandardMarginService::new();
443        let mut account = create_test_account();
444        account.option_positions.push(OptionPosition {
445            symbol: "ETH-20251231-4000-C".to_string(),
446            underlying: "ETH".to_string(),
447            expiry_ts: 0,
448            strike: dec!(4000),
449            is_call: true,
450            size: dec!(-10),
451            mark_price: dec!(150),
452            entry_price: dec!(200),
453            spot_price: dec!(3500),
454        });
455
456        assert!(!service.is_risk_increasing(&account, "ETH-20251231-4000-C", true, dec!(5), true));
457        assert!(service.is_risk_increasing(&account, "ETH-20251231-4000-C", false, dec!(5), true));
458    }
459
460    #[test]
461    fn test_reduce_only_sell_nets_with_long_position() {
462        let service = StandardMarginService::new();
463        let mut account = create_test_account();
464        let symbol = "ETH-20251231-4000-C";
465
466        account.option_positions.push(OptionPosition {
467            symbol: symbol.to_string(),
468            underlying: "ETH".to_string(),
469            expiry_ts: 0,
470            strike: dec!(4000),
471            is_call: true,
472            size: dec!(10),
473            mark_price: dec!(150),
474            entry_price: dec!(100),
475            spot_price: dec!(3500),
476        });
477
478        let base_result = service.compute_margin(&account);
479        assert_eq!(base_result.position_im, dec!(0));
480
481        account.option_positions.push(OptionPosition {
482            symbol: symbol.to_string(),
483            underlying: "ETH".to_string(),
484            expiry_ts: 0,
485            strike: dec!(4000),
486            is_call: true,
487            size: dec!(-5),
488            mark_price: dec!(150),
489            entry_price: dec!(150),
490            spot_price: dec!(3500),
491        });
492
493        let hypothetical_result = service.compute_margin(&account);
494        assert_eq!(hypothetical_result.position_im, dec!(0));
495    }
496
497    #[test]
498    fn test_fully_close_long_nets_to_zero() {
499        let service = StandardMarginService::new();
500        let mut account = create_test_account();
501        let symbol = "ETH-20251231-4000-C";
502
503        account.option_positions.push(OptionPosition {
504            symbol: symbol.to_string(),
505            underlying: "ETH".to_string(),
506            expiry_ts: 0,
507            strike: dec!(4000),
508            is_call: true,
509            size: dec!(10),
510            mark_price: dec!(150),
511            entry_price: dec!(100),
512            spot_price: dec!(3500),
513        });
514        account.option_positions.push(OptionPosition {
515            symbol: symbol.to_string(),
516            underlying: "ETH".to_string(),
517            expiry_ts: 0,
518            strike: dec!(4000),
519            is_call: true,
520            size: dec!(-10),
521            mark_price: dec!(150),
522            entry_price: dec!(150),
523            spot_price: dec!(3500),
524        });
525
526        let result = service.compute_margin(&account);
527        assert_eq!(result.position_im, dec!(0));
528    }
529
530    #[test]
531    fn test_negative_cash_masked_by_option_mark_values() {
532        let service = StandardMarginService::new();
533        let mut account = StandardAccount::new("test".to_string(), dec!(-0.00182084));
534
535        account.option_positions.push(OptionPosition {
536            symbol: "US500-20260327-698-C".to_string(),
537            underlying: "US500".to_string(),
538            expiry_ts: 0,
539            strike: dec!(698),
540            is_call: true,
541            size: dec!(29.58055),
542            mark_price: dec!(33.806),
543            entry_price: dec!(33.806),
544            spot_price: dec!(700),
545        });
546        account.option_positions.push(OptionPosition {
547            symbol: "ETH-20260327-1900-P".to_string(),
548            underlying: "ETH".to_string(),
549            expiry_ts: 0,
550            strike: dec!(1900),
551            is_call: false,
552            size: dec!(207.11438),
553            mark_price: dec!(19.313),
554            entry_price: dec!(19.313),
555            spot_price: dec!(1850),
556        });
557
558        let result = service.compute_margin(&account);
559        assert!(result.equity > dec!(0));
560        assert!(account.usdc_balance < dec!(0));
561        assert_eq!(result.position_im, dec!(0));
562        assert!(result.initial_margin > dec!(0));
563    }
564}