Skip to main content

bot_engine/simulation/
account.rs

1//! Isolated margin accounting for simulated perpetual futures.
2//!
3//! Models per-instrument isolated margin positions:
4//! - Tracks qty, avg entry price, leverage, reserved margin
5//! - Computes unrealized PnL at any mark price
6//! - Computes liquidation price using the Hyperliquid formula
7//! - Determines required initial margin for new orders
8//!
9//! The `MarginLedger` manages all positions + free USDC, providing
10//! a single source of truth for the simulated account.
11
12use bot_core::{InstrumentId, OrderSide, PositionSnapshot, Price};
13use rust_decimal::Decimal;
14use std::collections::HashMap;
15
16// =============================================================================
17// IsolatedPosition — Per-instrument margin state
18// =============================================================================
19
20/// Per-instrument isolated margin position.
21///
22/// Tracks all state needed for accurate perp simulation:
23/// position size, average entry, reserved margin, PnL, and fees.
24#[derive(Debug, Clone)]
25pub struct IsolatedPosition {
26    /// Signed position quantity: positive = long, negative = short
27    pub qty: Decimal,
28    /// Weighted average entry price
29    pub avg_entry_px: Decimal,
30    /// Leverage for this position (e.g., 10x)
31    pub leverage: Decimal,
32    /// USDC locked as isolated margin for this position
33    pub isolated_margin_reserved: Decimal,
34    /// Maintenance margin rate = 1 / max_leverage
35    /// Used for liquidation price calculation
36    pub maintenance_margin_rate: Decimal,
37    /// Accumulated realized PnL from partial/full closes
38    pub realized_pnl: Decimal,
39    /// Accumulated fees paid
40    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, // 1x = 100% margin rate
51            realized_pnl: Decimal::ZERO,
52            fee_paid: Decimal::ZERO,
53        }
54    }
55}
56
57impl IsolatedPosition {
58    /// Unrealized PnL at given mark price.
59    /// Long: profit when price goes up. Short: profit when price goes down.
60    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    /// Liquidation price for this isolated position (Hyperliquid formula).
68    ///
69    /// For a long position: liq_price = entry - margin_available / |qty| / (1 - mmr)
70    /// For a short position: liq_price = entry + margin_available / |qty| / (1 + mmr)
71    ///
72    /// Returns None if position is flat.
73    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    /// Required initial margin for a new order at given notional and leverage.
97    /// Includes a fee buffer for entry + exit (2x fee_rate).
98    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; // entry + exit fees
105        margin + fee_buffer
106    }
107
108    /// Check if this position is liquidated at the given mark price.
109    ///
110    /// A long is liquidated when mark drops below liq_price.
111    /// A short is liquidated when mark rises above liq_price.
112    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                // Long: liquidated when mark <= liq_price
120                mark_price <= liq_price
121            } else {
122                // Short: liquidated when mark >= liq_price
123                mark_price >= liq_price
124            }
125        } else {
126            false
127        }
128    }
129
130    /// Check if an order on this instrument is reducing the current position.
131    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, // buying reduces a short
137            OrderSide::Sell => self.qty > Decimal::ZERO, // selling reduces a long
138        }
139    }
140}
141
142// =============================================================================
143// MarginLedger — All positions + free USDC
144// =============================================================================
145
146/// Manages all isolated margin positions and free USDC balance.
147///
148/// This is the single source of truth for simulated account state,
149/// replacing the scattered tracking in PaperExchange and FillSimulator.
150#[derive(Debug)]
151pub struct MarginLedger {
152    /// Free USDC not locked in any margin position
153    free_usdc: Decimal,
154    /// Per-instrument isolated positions
155    positions: HashMap<InstrumentId, IsolatedPosition>,
156    /// Per-instrument leverage settings: (user_leverage, max_leverage)
157    leverage_settings: HashMap<InstrumentId, (Decimal, Decimal)>,
158    /// Default leverage for instruments without explicit settings
159    default_leverage: Decimal,
160    /// Fee rate (e.g. 0.0002 = 0.02%)
161    fee_rate: Decimal,
162}
163
164impl MarginLedger {
165    /// Create a new ledger with starting USDC balance.
166    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, // 1x = no leverage by default
172            fee_rate,
173        }
174    }
175
176    // =========================================================================
177    // Read accessors
178    // =========================================================================
179
180    /// Free USDC available for new margin reservations.
181    pub fn free_usdc(&self) -> Decimal {
182        self.free_usdc
183    }
184
185    /// Set free USDC directly (for compatibility with FillSimulator spot logic).
186    pub fn set_free_usdc(&mut self, amount: Decimal) {
187        self.free_usdc = amount;
188    }
189
190    /// Adjust free_usdc by delta (positive = add, negative = subtract).
191    pub fn adjust_free_usdc(&mut self, delta: Decimal) {
192        self.free_usdc += delta;
193    }
194
195    /// Fee rate used for margin calculations.
196    pub fn fee_rate(&self) -> Decimal {
197        self.fee_rate
198    }
199
200    /// Set fee rate.
201    pub fn set_fee_rate(&mut self, fee_rate: Decimal) {
202        self.fee_rate = fee_rate;
203    }
204
205    /// Get position for an instrument (if any).
206    pub fn position(&self, instrument: &InstrumentId) -> Option<&IsolatedPosition> {
207        self.positions.get(instrument)
208    }
209
210    /// Get mutable position for an instrument.
211    pub fn position_mut(&mut self, instrument: &InstrumentId) -> Option<&mut IsolatedPosition> {
212        self.positions.get_mut(instrument)
213    }
214
215    /// Get or create a position for an instrument.
216    fn position_or_default(&mut self, instrument: &InstrumentId) -> &mut IsolatedPosition {
217        self.positions.entry(instrument.clone()).or_default()
218    }
219
220    /// Get leverage for an instrument.
221    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    /// Total reserved margin across all positions.
229    pub fn total_reserved_margin(&self) -> Decimal {
230        self.positions
231            .values()
232            .map(|p| p.isolated_margin_reserved)
233            .sum()
234    }
235
236    /// Total unrealized PnL across all positions at given mark prices.
237    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    /// Account equity = free_usdc + total_reserved_margin + total_unrealized_pnl
250    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    /// Position quantity for an instrument (signed: +long, -short).
255    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    // =========================================================================
263    // Configuration
264    // =========================================================================
265
266    /// Set leverage for an instrument.
267    /// `leverage` is the user's chosen leverage (e.g. 10x).
268    /// `max_leverage` is the exchange maximum for this asset (determines MMR).
269    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        // Update existing position's maintenance margin rate if it exists
279        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    /// Set default leverage for instruments without explicit settings.
288    pub fn set_default_leverage(&mut self, leverage: Decimal) {
289        self.default_leverage = leverage;
290    }
291
292    // =========================================================================
293    // Order admission (A1)
294    // =========================================================================
295
296    /// Check if there's sufficient margin for a PERP order.
297    ///
298    /// - Reducing orders always pass (they release margin).
299    /// - New/increasing orders need: notional / leverage + 2x fee buffer.
300    /// - Spot orders are not handled here (use FillSimulator's check_balance).
301    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        // Reduce-only orders don't need margin
312        if reduce_only {
313            return Ok(());
314        }
315
316        // Is this reducing an existing position?
317        let is_reducing = existing_pos.map_or(false, |pos| pos.is_order_reducing(side));
318        if is_reducing {
319            return Ok(());
320        }
321
322        // New or increasing position: compute required margin
323        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    // =========================================================================
339    // Fill settlement (A2)
340    // =========================================================================
341
342    /// Apply a PERP fill to the margin ledger.
343    ///
344    /// Handles all scenarios:
345    /// - Opening new position: reserve margin, set entry price
346    /// - Increasing existing position: reserve additional margin, update avg entry
347    /// - Reducing existing position: release margin proportionally, realize PnL
348    /// - Flipping position: close old + open new in opposite direction
349    ///
350    /// Returns the realized PnL from this fill (zero for new positions).
351    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        // Determine signed fill quantity
372        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        // Always deduct fee from free USDC
382        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            // Opening or increasing position
400            self.apply_increase(instrument, signed_fill_qty, fill_price, leverage)
401        } else {
402            // Reducing or flipping
403            let closing_qty = signed_fill_qty.abs().min(old_qty.abs());
404            let remaining_qty = signed_fill_qty.abs() - closing_qty;
405
406            // 1. Close the portion
407            let realized = self.apply_reduce(instrument, closing_qty, fill_price);
408
409            // 2. If there's remaining qty, open in opposite direction
410            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    /// Increase position (same direction or new).
424    /// Reserves additional margin, updates weighted avg entry price.
425    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        // Reserve margin from free USDC
435        self.free_usdc -= new_margin;
436
437        let pos = self.position_or_default(instrument);
438
439        // Update weighted average entry price
440        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 // No realized PnL on increase
452    }
453
454    /// Reduce position (opposite direction).
455    /// Releases margin proportionally, realizes PnL.
456    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        // Compute realized PnL for the closed portion
475        let realized = if pos.qty > Decimal::ZERO {
476            // Long: profit = (fill_price - entry) * closing_qty
477            (fill_price - pos.avg_entry_px) * closing_qty
478        } else {
479            // Short: profit = (entry - fill_price) * closing_qty
480            (pos.avg_entry_px - fill_price) * closing_qty
481        };
482
483        // Release proportional margin
484        let released_margin = pos.isolated_margin_reserved * close_fraction;
485        pos.isolated_margin_reserved -= released_margin;
486
487        // Update position qty (reduce toward zero)
488        if pos.qty > Decimal::ZERO {
489            pos.qty -= closing_qty;
490        } else {
491            pos.qty += closing_qty;
492        }
493
494        // Return released margin + realized PnL to free USDC
495        self.free_usdc += released_margin + realized;
496
497        // Track realized PnL
498        pos.realized_pnl += realized;
499
500        // Clean up flat positions
501        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    // =========================================================================
510    // Liquidation checks (A4)
511    // =========================================================================
512
513    /// Check all positions for liquidation at current mark prices.
514    /// Returns list of instruments that should be liquidated.
515    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    /// Force-liquidate a position at the given mark price.
533    /// Closes the entire position, realizes the loss, releases remaining margin.
534    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        // Determine the side of the closing fill
544        let close_side = if qty > Decimal::ZERO {
545            OrderSide::Sell // close long
546        } else {
547            OrderSide::Buy // close short
548        };
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    // =========================================================================
568    // Account state snapshots (A3)
569    // =========================================================================
570
571    /// Generate position snapshots for `poll_account_state()`.
572    /// Returns non-flat positions in the format expected by `AccountState`.
573    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// =============================================================================
600// Tests
601// =============================================================================
602
603#[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    // =========================================================================
620    // IsolatedPosition tests
621    // =========================================================================
622
623    #[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        // Price went up: $1000 profit
631        assert_eq!(pos.unrealized_pnl(usdc(51000)), usdc(1000));
632        // Price went down: $1000 loss
633        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        // Price went down: $1000 profit (short wins)
644        assert_eq!(pos.unrealized_pnl(usdc(49000)), usdc(1000));
645        // Price went up: $1000 loss
646        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),         // $10k notional
659            Decimal::new(10, 0), // 10x leverage
660            Decimal::new(2, 4),  // 0.02% fee
661        );
662        // margin = 10000/10 = 1000, fee_buffer = 10000 * 0.0002 * 2 = 4
663        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    // =========================================================================
684    // MarginLedger tests
685    // =========================================================================
686
687    #[test]
688    fn test_margin_admission_rejects_insufficient() {
689        let ledger = MarginLedger::new(usdc(1000), Decimal::new(2, 4));
690        // Set leverage to 10x
691        // With $1000 at 10x, max notional should be around $10k
692
693        let result = ledger.check_margin_for_perp_order(
694            &btc_perp(),
695            OrderSide::Buy,
696            usdc(50000),  // price
697            Decimal::ONE, // 1 BTC = $50k notional
698            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        // $1000 at 10x → can trade up to ~$10k notional
710        // 0.1 BTC at $50000 = $5000 notional → margin needed = 500 + fees
711        let result = ledger.check_margin_for_perp_order(
712            &btc_perp(),
713            OrderSide::Buy,
714            usdc(50000),
715            Decimal::new(1, 1), // 0.1 BTC
716            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        // Open a long position first
727        ledger.apply_perp_fill(
728            &btc_perp(),
729            OrderSide::Buy,
730            usdc(50000),
731            Decimal::new(1, 1),
732            Decimal::ZERO,
733        );
734
735        // Now selling (reducing) should always pass, even with zero free USDC
736        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), // 0.1 BTC
756            usdc(1),            // $1 fee
757        );
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)); // 0.1 BTC long
763        assert_eq!(pos.avg_entry_px, usdc(50000));
764        // margin = 5000/10 = 500
765        assert_eq!(pos.isolated_margin_reserved, usdc(500));
766        // free = 10000 - 500 - 1(fee) = 9499
767        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        // Open: buy 0.1 BTC at $50000
776        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        // Close: sell 0.1 BTC at $51000 (profit = 0.1 * 1000 = $100)
787        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        // free = free_after_open + 500(margin_released) + 100(pnl)
802        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        // Open: buy 0.1 BTC at $50000
811        ledger.apply_perp_fill(
812            &btc_perp(),
813            OrderSide::Buy,
814            usdc(50000),
815            Decimal::new(1, 1),
816            Decimal::ZERO,
817        );
818
819        // Close: sell 0.1 BTC at $49000 (loss = 0.1 * 1000 = -$100)
820        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        // free = 10000 - 500(open) + 500(released) + (-100)(loss) = 9900
831        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(&eth_perp(), Decimal::new(5, 0), Decimal::new(25, 0));
838
839        // Open short: sell 1 ETH at $3000
840        ledger.apply_perp_fill(
841            &eth_perp(),
842            OrderSide::Sell,
843            usdc(3000),
844            Decimal::ONE,
845            Decimal::ZERO,
846        );
847
848        let pos = ledger.position(&eth_perp()).unwrap();
849        assert_eq!(pos.qty, -Decimal::ONE); // short
850        assert_eq!(pos.isolated_margin_reserved, usdc(600)); // 3000/5 = 600
851
852        // Close short: buy 1 ETH at $2800 (profit = $200)
853        let realized = ledger.apply_perp_fill(
854            &eth_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        // Open: buy 0.1 BTC at $50000
870        ledger.apply_perp_fill(
871            &btc_perp(),
872            OrderSide::Buy,
873            usdc(50000),
874            Decimal::new(1, 1),
875            Decimal::ZERO,
876        );
877
878        // Mark at $51000 → unrealized = 0.1 * 1000 = $100
879        let mut marks = HashMap::new();
880        marks.insert(btc_perp(), usdc(51000));
881
882        let equity = ledger.equity(&marks);
883        // equity = free(9500) + reserved(500) + unrealized(100) = 10100
884        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        // Open: buy 0.002 BTC at $50000 (notional = $100)
916        // margin = 100/10 = $10
917        ledger.apply_perp_fill(
918            &btc_perp(),
919            OrderSide::Buy,
920            usdc(50000),
921            Decimal::new(2, 3), // 0.002 BTC
922            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        // Well above liq price → not liquidated
930        let mut marks = HashMap::new();
931        marks.insert(btc_perp(), usdc(50000));
932        assert!(ledger.check_liquidations(&marks).is_empty());
933
934        // Drop price dramatically → should liquidate
935        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}