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