1use bot_core::{InstrumentId, OrderSide, PositionSnapshot, Price};
13use rust_decimal::Decimal;
14use std::collections::HashMap;
15
16#[derive(Debug, Clone)]
25pub struct IsolatedPosition {
26 pub qty: Decimal,
28 pub avg_entry_px: Decimal,
30 pub leverage: Decimal,
32 pub isolated_margin_reserved: Decimal,
34 pub maintenance_margin_rate: Decimal,
37 pub realized_pnl: Decimal,
39 pub fee_paid: Decimal,
41}
42
43impl Default for IsolatedPosition {
44 fn default() -> Self {
45 Self {
46 qty: Decimal::ZERO,
47 avg_entry_px: Decimal::ZERO,
48 leverage: Decimal::ONE,
49 isolated_margin_reserved: Decimal::ZERO,
50 maintenance_margin_rate: Decimal::ONE, realized_pnl: Decimal::ZERO,
52 fee_paid: Decimal::ZERO,
53 }
54 }
55}
56
57impl IsolatedPosition {
58 pub fn unrealized_pnl(&self, mark_price: Decimal) -> Decimal {
61 if self.qty.is_zero() {
62 return Decimal::ZERO;
63 }
64 self.qty * (mark_price - self.avg_entry_px)
65 }
66
67 pub fn liquidation_price(&self) -> Option<Decimal> {
74 if self.qty.is_zero() {
75 return None;
76 }
77
78 let side_val = if self.qty > Decimal::ZERO {
79 Decimal::ONE
80 } else {
81 -Decimal::ONE
82 };
83 let mmr = self.maintenance_margin_rate;
84 let denominator = Decimal::ONE - mmr * side_val;
85
86 if denominator.is_zero() {
87 return None;
88 }
89
90 let margin_available =
91 self.isolated_margin_reserved - self.qty.abs() * self.avg_entry_px * mmr;
92
93 Some(self.avg_entry_px - side_val * margin_available / self.qty.abs() / denominator)
94 }
95
96 pub fn required_margin_for_order(
99 notional: Decimal,
100 leverage: Decimal,
101 fee_rate: Decimal,
102 ) -> Decimal {
103 let margin = notional / leverage;
104 let fee_buffer = notional * fee_rate * Decimal::TWO; margin + fee_buffer
106 }
107
108 pub fn is_liquidated(&self, mark_price: Decimal) -> bool {
113 if self.qty.is_zero() {
114 return false;
115 }
116
117 if let Some(liq_price) = self.liquidation_price() {
118 if self.qty > Decimal::ZERO {
119 mark_price <= liq_price
121 } else {
122 mark_price >= liq_price
124 }
125 } else {
126 false
127 }
128 }
129
130 pub fn is_order_reducing(&self, order_side: OrderSide) -> bool {
132 if self.qty.is_zero() {
133 return false;
134 }
135 match order_side {
136 OrderSide::Buy => self.qty < Decimal::ZERO, OrderSide::Sell => self.qty > Decimal::ZERO, }
139 }
140}
141
142#[derive(Debug)]
151pub struct MarginLedger {
152 free_usdc: Decimal,
154 positions: HashMap<InstrumentId, IsolatedPosition>,
156 leverage_settings: HashMap<InstrumentId, (Decimal, Decimal)>,
158 default_leverage: Decimal,
160 fee_rate: Decimal,
162}
163
164impl MarginLedger {
165 pub fn new(starting_balance: Decimal, fee_rate: Decimal) -> Self {
167 Self {
168 free_usdc: starting_balance,
169 positions: HashMap::new(),
170 leverage_settings: HashMap::new(),
171 default_leverage: Decimal::ONE, fee_rate,
173 }
174 }
175
176 pub fn free_usdc(&self) -> Decimal {
182 self.free_usdc
183 }
184
185 pub fn set_free_usdc(&mut self, amount: Decimal) {
187 self.free_usdc = amount;
188 }
189
190 pub fn adjust_free_usdc(&mut self, delta: Decimal) {
192 self.free_usdc += delta;
193 }
194
195 pub fn fee_rate(&self) -> Decimal {
197 self.fee_rate
198 }
199
200 pub fn set_fee_rate(&mut self, fee_rate: Decimal) {
202 self.fee_rate = fee_rate;
203 }
204
205 pub fn position(&self, instrument: &InstrumentId) -> Option<&IsolatedPosition> {
207 self.positions.get(instrument)
208 }
209
210 pub fn position_mut(&mut self, instrument: &InstrumentId) -> Option<&mut IsolatedPosition> {
212 self.positions.get_mut(instrument)
213 }
214
215 fn position_or_default(&mut self, instrument: &InstrumentId) -> &mut IsolatedPosition {
217 self.positions.entry(instrument.clone()).or_default()
218 }
219
220 pub fn leverage_for(&self, instrument: &InstrumentId) -> Decimal {
222 self.leverage_settings
223 .get(instrument)
224 .map(|(lev, _)| *lev)
225 .unwrap_or(self.default_leverage)
226 }
227
228 pub fn total_reserved_margin(&self) -> Decimal {
230 self.positions
231 .values()
232 .map(|p| p.isolated_margin_reserved)
233 .sum()
234 }
235
236 pub fn total_unrealized_pnl(&self, marks: &HashMap<InstrumentId, Decimal>) -> Decimal {
238 self.positions
239 .iter()
240 .map(|(inst, pos)| {
241 marks
242 .get(inst)
243 .map(|mark| pos.unrealized_pnl(*mark))
244 .unwrap_or(Decimal::ZERO)
245 })
246 .sum()
247 }
248
249 pub fn equity(&self, marks: &HashMap<InstrumentId, Decimal>) -> Decimal {
251 self.free_usdc + self.total_reserved_margin() + self.total_unrealized_pnl(marks)
252 }
253
254 pub fn position_qty(&self, instrument: &InstrumentId) -> Decimal {
256 self.positions
257 .get(instrument)
258 .map(|p| p.qty)
259 .unwrap_or(Decimal::ZERO)
260 }
261
262 pub fn set_leverage(
270 &mut self,
271 instrument: &InstrumentId,
272 leverage: Decimal,
273 max_leverage: Decimal,
274 ) {
275 self.leverage_settings
276 .insert(instrument.clone(), (leverage, max_leverage));
277
278 if let Some(pos) = self.positions.get_mut(instrument) {
280 pos.leverage = leverage;
281 if max_leverage > Decimal::ZERO {
282 pos.maintenance_margin_rate = Decimal::ONE / max_leverage;
283 }
284 }
285 }
286
287 pub fn set_default_leverage(&mut self, leverage: Decimal) {
289 self.default_leverage = leverage;
290 }
291
292 pub fn check_margin_for_perp_order(
302 &self,
303 instrument: &InstrumentId,
304 side: OrderSide,
305 price: Decimal,
306 qty: Decimal,
307 reduce_only: bool,
308 ) -> Result<(), String> {
309 let existing_pos = self.positions.get(instrument);
310
311 if reduce_only {
313 return Ok(());
314 }
315
316 let is_reducing = existing_pos.map_or(false, |pos| pos.is_order_reducing(side));
318 if is_reducing {
319 return Ok(());
320 }
321
322 let notional = price * qty;
324 let leverage = self.leverage_for(instrument);
325 let required =
326 IsolatedPosition::required_margin_for_order(notional, leverage, self.fee_rate);
327
328 if required > self.free_usdc {
329 return Err(format!(
330 "Insufficient margin: need {} USDC (notional={}, leverage={}x, fee_rate={}), have {} free",
331 required, notional, leverage, self.fee_rate, self.free_usdc
332 ));
333 }
334
335 Ok(())
336 }
337
338 pub fn apply_perp_fill(
352 &mut self,
353 instrument: &InstrumentId,
354 side: OrderSide,
355 fill_price: Decimal,
356 fill_qty: Decimal,
357 fee_amount: Decimal,
358 ) -> Decimal {
359 let leverage = self.leverage_for(instrument);
360 let max_leverage = self
361 .leverage_settings
362 .get(instrument)
363 .map(|(_, ml)| *ml)
364 .unwrap_or(leverage);
365 let mmr = if max_leverage > Decimal::ZERO {
366 Decimal::ONE / max_leverage
367 } else {
368 Decimal::ONE
369 };
370
371 let signed_fill_qty = match side {
373 OrderSide::Buy => fill_qty,
374 OrderSide::Sell => -fill_qty,
375 };
376
377 let pos = self.position_or_default(instrument);
378 pos.leverage = leverage;
379 pos.maintenance_margin_rate = mmr;
380
381 self.free_usdc -= fee_amount;
383 if let Some(pos) = self.positions.get_mut(instrument) {
384 pos.fee_paid += fee_amount;
385 }
386
387 let old_qty = self
388 .positions
389 .get(instrument)
390 .map(|p| p.qty)
391 .unwrap_or(Decimal::ZERO);
392
393 let is_same_direction = (old_qty >= Decimal::ZERO && signed_fill_qty > Decimal::ZERO)
394 || (old_qty <= Decimal::ZERO && signed_fill_qty < Decimal::ZERO);
395
396 let is_flat = old_qty.is_zero();
397
398 if is_flat || is_same_direction {
399 self.apply_increase(instrument, signed_fill_qty, fill_price, leverage)
401 } else {
402 let closing_qty = signed_fill_qty.abs().min(old_qty.abs());
404 let remaining_qty = signed_fill_qty.abs() - closing_qty;
405
406 let realized = self.apply_reduce(instrument, closing_qty, fill_price);
408
409 if remaining_qty > Decimal::ZERO {
411 let new_signed = if signed_fill_qty > Decimal::ZERO {
412 remaining_qty
413 } else {
414 -remaining_qty
415 };
416 self.apply_increase(instrument, new_signed, fill_price, leverage);
417 }
418
419 realized
420 }
421 }
422
423 fn apply_increase(
426 &mut self,
427 instrument: &InstrumentId,
428 signed_fill_qty: Decimal,
429 fill_price: Decimal,
430 leverage: Decimal,
431 ) -> Decimal {
432 let new_margin = (fill_price * signed_fill_qty.abs()) / leverage;
433
434 self.free_usdc -= new_margin;
436
437 let pos = self.position_or_default(instrument);
438
439 let old_notional = pos.qty.abs() * pos.avg_entry_px;
441 let new_notional = signed_fill_qty.abs() * fill_price;
442 let total_qty = pos.qty.abs() + signed_fill_qty.abs();
443
444 if total_qty > Decimal::ZERO {
445 pos.avg_entry_px = (old_notional + new_notional) / total_qty;
446 }
447
448 pos.qty += signed_fill_qty;
449 pos.isolated_margin_reserved += new_margin;
450
451 Decimal::ZERO }
453
454 fn apply_reduce(
457 &mut self,
458 instrument: &InstrumentId,
459 closing_qty: Decimal,
460 fill_price: Decimal,
461 ) -> Decimal {
462 let pos = match self.positions.get_mut(instrument) {
463 Some(p) => p,
464 None => return Decimal::ZERO,
465 };
466
467 if pos.qty.is_zero() {
468 return Decimal::ZERO;
469 }
470
471 let old_abs_qty = pos.qty.abs();
472 let close_fraction = closing_qty / old_abs_qty;
473
474 let realized = if pos.qty > Decimal::ZERO {
476 (fill_price - pos.avg_entry_px) * closing_qty
478 } else {
479 (pos.avg_entry_px - fill_price) * closing_qty
481 };
482
483 let released_margin = pos.isolated_margin_reserved * close_fraction;
485 pos.isolated_margin_reserved -= released_margin;
486
487 if pos.qty > Decimal::ZERO {
489 pos.qty -= closing_qty;
490 } else {
491 pos.qty += closing_qty;
492 }
493
494 self.free_usdc += released_margin + realized;
496
497 pos.realized_pnl += realized;
499
500 if pos.qty.is_zero() {
502 pos.avg_entry_px = Decimal::ZERO;
503 pos.isolated_margin_reserved = Decimal::ZERO;
504 }
505
506 realized
507 }
508
509 pub fn check_liquidations(&self, marks: &HashMap<InstrumentId, Decimal>) -> Vec<InstrumentId> {
516 let mut liquidated = Vec::new();
517
518 for (instrument, pos) in &self.positions {
519 if pos.qty.is_zero() {
520 continue;
521 }
522 if let Some(mark) = marks.get(instrument) {
523 if pos.is_liquidated(*mark) {
524 liquidated.push(instrument.clone());
525 }
526 }
527 }
528
529 liquidated
530 }
531
532 pub fn liquidate(&mut self, instrument: &InstrumentId, mark_price: Decimal) {
535 let pos = match self.positions.get(instrument) {
536 Some(p) if !p.qty.is_zero() => p,
537 _ => return,
538 };
539
540 let qty = pos.qty;
541 let closing_qty = qty.abs();
542
543 let close_side = if qty > Decimal::ZERO {
545 OrderSide::Sell } else {
547 OrderSide::Buy };
549
550 tracing::warn!(
551 instrument = %instrument,
552 qty = %qty,
553 entry = %pos.avg_entry_px,
554 mark = %mark_price,
555 "LIQUIDATION: force-closing position"
556 );
557
558 self.apply_perp_fill(
559 instrument,
560 close_side,
561 mark_price,
562 closing_qty,
563 Decimal::ZERO,
564 );
565 }
566
567 pub fn position_snapshots(
574 &self,
575 marks: &HashMap<InstrumentId, Decimal>,
576 ) -> Vec<PositionSnapshot> {
577 self.positions
578 .iter()
579 .filter(|(_, pos)| !pos.qty.is_zero())
580 .map(|(instrument, pos)| {
581 let unrealized = marks.get(instrument).map(|mark| pos.unrealized_pnl(*mark));
582
583 PositionSnapshot {
584 instrument: instrument.clone(),
585 qty: pos.qty,
586 avg_entry_px: if pos.avg_entry_px.is_zero() {
587 None
588 } else {
589 Some(Price::new(pos.avg_entry_px))
590 },
591 unrealized_pnl: unrealized,
592 liquidation_px: pos.liquidation_price(),
593 }
594 })
595 .collect()
596 }
597}
598
599#[cfg(test)]
604mod tests {
605 use super::*;
606
607 fn usdc(n: i64) -> Decimal {
608 Decimal::new(n, 0)
609 }
610
611 fn btc_perp() -> InstrumentId {
612 InstrumentId::new("BTC-PERP")
613 }
614
615 fn eth_perp() -> InstrumentId {
616 InstrumentId::new("ETH-PERP")
617 }
618
619 #[test]
624 fn test_unrealized_pnl_long() {
625 let pos = IsolatedPosition {
626 qty: Decimal::ONE,
627 avg_entry_px: usdc(50000),
628 ..Default::default()
629 };
630 assert_eq!(pos.unrealized_pnl(usdc(51000)), usdc(1000));
632 assert_eq!(pos.unrealized_pnl(usdc(49000)), -usdc(1000));
634 }
635
636 #[test]
637 fn test_unrealized_pnl_short() {
638 let pos = IsolatedPosition {
639 qty: -Decimal::ONE,
640 avg_entry_px: usdc(50000),
641 ..Default::default()
642 };
643 assert_eq!(pos.unrealized_pnl(usdc(49000)), usdc(1000));
645 assert_eq!(pos.unrealized_pnl(usdc(51000)), -usdc(1000));
647 }
648
649 #[test]
650 fn test_unrealized_pnl_flat() {
651 let pos = IsolatedPosition::default();
652 assert_eq!(pos.unrealized_pnl(usdc(99999)), Decimal::ZERO);
653 }
654
655 #[test]
656 fn test_required_margin() {
657 let margin = IsolatedPosition::required_margin_for_order(
658 usdc(10000), Decimal::new(10, 0), Decimal::new(2, 4), );
662 assert_eq!(margin, Decimal::new(1004, 0));
664 }
665
666 #[test]
667 fn test_is_order_reducing() {
668 let long_pos = IsolatedPosition {
669 qty: Decimal::ONE,
670 ..Default::default()
671 };
672 assert!(long_pos.is_order_reducing(OrderSide::Sell));
673 assert!(!long_pos.is_order_reducing(OrderSide::Buy));
674
675 let short_pos = IsolatedPosition {
676 qty: -Decimal::ONE,
677 ..Default::default()
678 };
679 assert!(short_pos.is_order_reducing(OrderSide::Buy));
680 assert!(!short_pos.is_order_reducing(OrderSide::Sell));
681 }
682
683 #[test]
688 fn test_margin_admission_rejects_insufficient() {
689 let ledger = MarginLedger::new(usdc(1000), Decimal::new(2, 4));
690 let result = ledger.check_margin_for_perp_order(
694 &btc_perp(),
695 OrderSide::Buy,
696 usdc(50000), Decimal::ONE, false,
699 );
700 assert!(result.is_err());
701 assert!(result.unwrap_err().contains("Insufficient margin"));
702 }
703
704 #[test]
705 fn test_margin_admission_accepts_leveraged() {
706 let mut ledger = MarginLedger::new(usdc(1000), Decimal::new(2, 4));
707 ledger.set_leverage(&btc_perp(), Decimal::new(10, 0), Decimal::new(50, 0));
708
709 let result = ledger.check_margin_for_perp_order(
712 &btc_perp(),
713 OrderSide::Buy,
714 usdc(50000),
715 Decimal::new(1, 1), false,
717 );
718 assert!(result.is_ok(), "Should accept: {:?}", result);
719 }
720
721 #[test]
722 fn test_margin_admission_allows_reducing() {
723 let mut ledger = MarginLedger::new(usdc(1000), Decimal::new(2, 4));
724 ledger.set_leverage(&btc_perp(), Decimal::new(10, 0), Decimal::new(50, 0));
725
726 ledger.apply_perp_fill(
728 &btc_perp(),
729 OrderSide::Buy,
730 usdc(50000),
731 Decimal::new(1, 1),
732 Decimal::ZERO,
733 );
734
735 let result = ledger.check_margin_for_perp_order(
737 &btc_perp(),
738 OrderSide::Sell,
739 usdc(50000),
740 Decimal::new(1, 1),
741 false,
742 );
743 assert!(result.is_ok(), "Reducing order should always pass");
744 }
745
746 #[test]
747 fn test_fill_open_long() {
748 let mut ledger = MarginLedger::new(usdc(10000), Decimal::new(2, 4));
749 ledger.set_leverage(&btc_perp(), Decimal::new(10, 0), Decimal::new(50, 0));
750
751 let realized = ledger.apply_perp_fill(
752 &btc_perp(),
753 OrderSide::Buy,
754 usdc(50000),
755 Decimal::new(1, 1), usdc(1), );
758
759 assert_eq!(realized, Decimal::ZERO, "No realized PnL on open");
760
761 let pos = ledger.position(&btc_perp()).unwrap();
762 assert_eq!(pos.qty, Decimal::new(1, 1)); assert_eq!(pos.avg_entry_px, usdc(50000));
764 assert_eq!(pos.isolated_margin_reserved, usdc(500));
766 assert_eq!(ledger.free_usdc(), usdc(9499));
768 }
769
770 #[test]
771 fn test_fill_close_long_with_profit() {
772 let mut ledger = MarginLedger::new(usdc(10000), Decimal::new(2, 4));
773 ledger.set_leverage(&btc_perp(), Decimal::new(10, 0), Decimal::new(50, 0));
774
775 ledger.apply_perp_fill(
777 &btc_perp(),
778 OrderSide::Buy,
779 usdc(50000),
780 Decimal::new(1, 1),
781 Decimal::ZERO,
782 );
783
784 let free_after_open = ledger.free_usdc();
785
786 let realized = ledger.apply_perp_fill(
788 &btc_perp(),
789 OrderSide::Sell,
790 usdc(51000),
791 Decimal::new(1, 1),
792 Decimal::ZERO,
793 );
794
795 assert_eq!(realized, usdc(100), "Realized PnL should be $100");
796
797 let pos = ledger.position(&btc_perp()).unwrap();
798 assert!(pos.qty.is_zero(), "Position should be flat");
799 assert_eq!(pos.isolated_margin_reserved, Decimal::ZERO);
800
801 assert_eq!(ledger.free_usdc(), free_after_open + usdc(500) + usdc(100));
803 }
804
805 #[test]
806 fn test_fill_close_long_with_loss() {
807 let mut ledger = MarginLedger::new(usdc(10000), Decimal::ZERO);
808 ledger.set_leverage(&btc_perp(), Decimal::new(10, 0), Decimal::new(50, 0));
809
810 ledger.apply_perp_fill(
812 &btc_perp(),
813 OrderSide::Buy,
814 usdc(50000),
815 Decimal::new(1, 1),
816 Decimal::ZERO,
817 );
818
819 let realized = ledger.apply_perp_fill(
821 &btc_perp(),
822 OrderSide::Sell,
823 usdc(49000),
824 Decimal::new(1, 1),
825 Decimal::ZERO,
826 );
827
828 assert_eq!(realized, -usdc(100), "Realized PnL should be -$100");
829
830 assert_eq!(ledger.free_usdc(), usdc(9900));
832 }
833
834 #[test]
835 fn test_fill_short_and_close() {
836 let mut ledger = MarginLedger::new(usdc(10000), Decimal::ZERO);
837 ledger.set_leverage(ð_perp(), Decimal::new(5, 0), Decimal::new(25, 0));
838
839 ledger.apply_perp_fill(
841 ð_perp(),
842 OrderSide::Sell,
843 usdc(3000),
844 Decimal::ONE,
845 Decimal::ZERO,
846 );
847
848 let pos = ledger.position(ð_perp()).unwrap();
849 assert_eq!(pos.qty, -Decimal::ONE); assert_eq!(pos.isolated_margin_reserved, usdc(600)); let realized = ledger.apply_perp_fill(
854 ð_perp(),
855 OrderSide::Buy,
856 usdc(2800),
857 Decimal::ONE,
858 Decimal::ZERO,
859 );
860
861 assert_eq!(realized, usdc(200), "Short profit should be $200");
862 }
863
864 #[test]
865 fn test_equity_with_unrealized() {
866 let mut ledger = MarginLedger::new(usdc(10000), Decimal::ZERO);
867 ledger.set_leverage(&btc_perp(), Decimal::new(10, 0), Decimal::new(50, 0));
868
869 ledger.apply_perp_fill(
871 &btc_perp(),
872 OrderSide::Buy,
873 usdc(50000),
874 Decimal::new(1, 1),
875 Decimal::ZERO,
876 );
877
878 let mut marks = HashMap::new();
880 marks.insert(btc_perp(), usdc(51000));
881
882 let equity = ledger.equity(&marks);
883 assert_eq!(equity, usdc(10100));
885 }
886
887 #[test]
888 fn test_position_snapshots() {
889 let mut ledger = MarginLedger::new(usdc(10000), Decimal::ZERO);
890 ledger.set_leverage(&btc_perp(), Decimal::new(10, 0), Decimal::new(50, 0));
891
892 ledger.apply_perp_fill(
893 &btc_perp(),
894 OrderSide::Buy,
895 usdc(50000),
896 Decimal::new(1, 1),
897 Decimal::ZERO,
898 );
899
900 let mut marks = HashMap::new();
901 marks.insert(btc_perp(), usdc(51000));
902
903 let snapshots = ledger.position_snapshots(&marks);
904 assert_eq!(snapshots.len(), 1);
905 assert_eq!(snapshots[0].instrument, btc_perp());
906 assert_eq!(snapshots[0].qty, Decimal::new(1, 1));
907 assert_eq!(snapshots[0].unrealized_pnl, Some(usdc(100)));
908 }
909
910 #[test]
911 fn test_liquidation_detection() {
912 let mut ledger = MarginLedger::new(usdc(1000), Decimal::ZERO);
913 ledger.set_leverage(&btc_perp(), Decimal::new(10, 0), Decimal::new(50, 0));
914
915 ledger.apply_perp_fill(
918 &btc_perp(),
919 OrderSide::Buy,
920 usdc(50000),
921 Decimal::new(2, 3), Decimal::ZERO,
923 );
924
925 let pos = ledger.position(&btc_perp()).unwrap();
926 let liq_price = pos.liquidation_price();
927 assert!(liq_price.is_some(), "Should have a liquidation price");
928
929 let mut marks = HashMap::new();
931 marks.insert(btc_perp(), usdc(50000));
932 assert!(ledger.check_liquidations(&marks).is_empty());
933
934 marks.insert(btc_perp(), usdc(1000));
936 let liquidated = ledger.check_liquidations(&marks);
937 assert!(!liquidated.is_empty(), "Should detect liquidation at $1000");
938 }
939}