Skip to main content

pyra_margin/
limits.rs

1use std::cmp;
2
3use pyra_types::SpotMarket;
4
5use crate::balance::calculate_value_usdc_base_units;
6use crate::error::{MathError, MathResult};
7use crate::math::CheckedDivCeil;
8use crate::weights::{calculate_asset_weight, calculate_liability_weight, get_strict_price};
9
10const MARGIN_PRECISION: i128 = 10_000;
11const PRICE_PRECISION: i128 = 1_000_000;
12
13/// Input data for a single position in margin calculations.
14///
15/// The caller is responsible for computing `token_balance` — this allows
16/// different services to compose balances differently (e.g. Drift-only
17/// vs Drift + deposit addresses - pending withdraws).
18pub struct PositionData<'a> {
19    /// Token balance in base units (positive = deposit, negative = borrow).
20    /// Use `get_token_balance()` to compute from a `SpotPosition`, then add
21    /// any off-chain adjustments.
22    pub token_balance: i128,
23    /// Current oracle price in USDC base units (1e6).
24    pub price_usdc_base_units: u64,
25    /// 5-minute TWAP from `SpotMarket::historical_oracle_data.last_oracle_price_twap5min`.
26    pub twap5min: i64,
27    /// The spot market configuration.
28    pub spot_market: &'a SpotMarket,
29}
30
31/// Aggregated margin state across all positions.
32///
33/// Computed once from all positions, then used to derive per-market
34/// withdraw/borrow limits and credit usage.
35#[derive(Debug, Clone, Copy)]
36pub struct MarginState {
37    /// Total weighted collateral in USDC base units.
38    pub total_weighted_collateral: i128,
39    /// Total weighted liabilities in USDC base units (always non-negative).
40    pub total_weighted_liabilities: i128,
41}
42
43impl MarginState {
44    /// Calculate margin state from a set of positions.
45    ///
46    /// For each position: applies strict oracle pricing, computes USDC value,
47    /// applies IMF-adjusted weights, and accumulates into collateral/liabilities.
48    pub fn calculate(positions: &[PositionData<'_>]) -> MathResult<Self> {
49        let mut total_weighted_collateral: i128 = 0;
50        let mut total_weighted_liabilities: i128 = 0;
51
52        for pos in positions {
53            if pos.token_balance == 0 {
54                continue;
55            }
56
57            let is_asset = pos.token_balance >= 0;
58            let strict_price = get_strict_price(pos.price_usdc_base_units, pos.twap5min, is_asset);
59
60            let value_usdc = calculate_value_usdc_base_units(
61                pos.token_balance,
62                strict_price,
63                pos.spot_market.decimals,
64            )?;
65
66            let token_amount_unsigned = pos.token_balance.unsigned_abs();
67            let weight_bps = if is_asset {
68                calculate_asset_weight(
69                    token_amount_unsigned,
70                    pos.price_usdc_base_units,
71                    pos.spot_market,
72                )?
73            } else {
74                calculate_liability_weight(token_amount_unsigned, pos.spot_market)?
75            };
76
77            // weight_bps is u128 ≤ ~20_000, safe to cast to i128
78            let weighted_value = value_usdc
79                .checked_mul(weight_bps as i128)
80                .ok_or(MathError::Overflow)?
81                .checked_div(MARGIN_PRECISION)
82                .ok_or(MathError::Overflow)?;
83
84            if weighted_value >= 0 {
85                total_weighted_collateral = total_weighted_collateral
86                    .checked_add(weighted_value)
87                    .ok_or(MathError::Overflow)?;
88            } else {
89                total_weighted_liabilities = total_weighted_liabilities
90                    .checked_add(weighted_value.checked_neg().ok_or(MathError::Overflow)?)
91                    .ok_or(MathError::Overflow)?;
92            }
93        }
94
95        Ok(Self {
96            total_weighted_collateral,
97            total_weighted_liabilities,
98        })
99    }
100
101    /// Free collateral = weighted collateral - weighted liabilities, clamped to 0.
102    pub fn free_collateral(&self) -> u64 {
103        let fc = self
104            .total_weighted_collateral
105            .saturating_sub(self.total_weighted_liabilities);
106        clamp_to_u64(cmp::max(0, fc))
107    }
108
109    /// Credit usage in basis points (0 = no liabilities, 10_000 = 100%).
110    /// Capped at 10_000 — an under-collateralized account is at 100% usage.
111    /// Returns 0 if collateral is zero or negative.
112    pub fn credit_usage_bps(&self) -> MathResult<u64> {
113        if self.total_weighted_collateral <= 0 {
114            return Ok(0);
115        }
116        let usage = self
117            .total_weighted_liabilities
118            .checked_mul(10_000)
119            .ok_or(MathError::Overflow)?
120            .checked_div(self.total_weighted_collateral)
121            .ok_or(MathError::Overflow)?;
122        // Clamp to 10_000 (100%)
123        Ok(cmp::min(clamp_to_u64(cmp::max(0, usage)), 10_000))
124    }
125}
126
127/// Combined withdraw and borrow limits for a single market.
128#[derive(Debug, Clone, Copy, PartialEq, Eq)]
129pub struct PositionLimits {
130    pub withdraw_limit: u64,
131    pub borrow_limit: u64,
132}
133
134/// Calculate withdraw and borrow limits for a single market.
135///
136/// When `reduce_only` is true, borrowing is not allowed — `borrow_limit == withdraw_limit`.
137/// `token_balance` should be the same balance used in `MarginState::calculate`.
138pub fn calculate_position_limits(
139    margin_state: &MarginState,
140    spot_market: &SpotMarket,
141    price_usdc_base_units: u64,
142    token_balance: i128,
143    reduce_only: bool,
144) -> MathResult<PositionLimits> {
145    if price_usdc_base_units == 0 {
146        return Ok(PositionLimits {
147            withdraw_limit: 0,
148            borrow_limit: 0,
149        });
150    }
151
152    let free_collateral = cmp::max(
153        0,
154        margin_state
155            .total_weighted_collateral
156            .saturating_sub(margin_state.total_weighted_liabilities),
157    );
158    let token_deposit_balance = clamp_to_u64(cmp::max(0, token_balance));
159    let asset_weight = spot_market.initial_asset_weight;
160    let (numerator_scale, denominator_scale) = decimal_scale(spot_market.decimals)?;
161
162    // Withdraw limit: how much can be withdrawn without breaching margin.
163    // No liabilities or zero asset weight -> can withdraw entire deposit.
164    let withdraw_limit = if asset_weight == 0 || margin_state.total_weighted_liabilities == 0 {
165        token_deposit_balance
166    } else {
167        let withdraw_limit_i128 = free_collateral
168            .checked_mul(MARGIN_PRECISION)
169            .and_then(|v| v.checked_div_ceil(asset_weight as i128))
170            .and_then(|v| v.checked_mul(PRICE_PRECISION))
171            .and_then(|v| v.checked_div_ceil(price_usdc_base_units as i128))
172            .and_then(|v| v.checked_mul(numerator_scale as i128))
173            .and_then(|v| v.checked_div(denominator_scale as i128))
174            .ok_or(MathError::Overflow)?;
175
176        cmp::min(
177            token_deposit_balance,
178            clamp_to_u64(cmp::max(0, withdraw_limit_i128)),
179        )
180    };
181
182    if reduce_only {
183        return Ok(PositionLimits {
184            withdraw_limit,
185            borrow_limit: withdraw_limit,
186        });
187    }
188
189    // Borrow limit: withdraw_limit + max additional liability.
190    // Match ts-sdk: subtract the full weighted value of the entire deposit position
191    // from free collateral before computing max liability.
192    let free_collateral_after = if token_balance > 0 {
193        let position_value_usdc = calculate_value_usdc_base_units(
194            token_balance,
195            price_usdc_base_units,
196            spot_market.decimals,
197        )?;
198        let weighted_position_value = position_value_usdc
199            .checked_mul(asset_weight as i128)
200            .and_then(|v| v.checked_div(MARGIN_PRECISION))
201            .ok_or(MathError::Overflow)?;
202        cmp::max(0, free_collateral.saturating_sub(weighted_position_value))
203    } else {
204        free_collateral
205    };
206
207    // Max additional liability the remaining collateral can support
208    let liability_weight = spot_market.initial_liability_weight as i128;
209    let max_liability = free_collateral_after
210        .checked_mul(MARGIN_PRECISION)
211        .and_then(|v| v.checked_div(liability_weight))
212        .and_then(|v| v.checked_mul(PRICE_PRECISION))
213        .and_then(|v| v.checked_div(price_usdc_base_units as i128))
214        .and_then(|v| v.checked_mul(numerator_scale as i128))
215        .and_then(|v| v.checked_div(denominator_scale as i128))
216        .ok_or(MathError::Overflow)?;
217
218    let borrow_limit_unclamped = (withdraw_limit as i128)
219        .checked_add(max_liability)
220        .ok_or(MathError::Overflow)?;
221
222    Ok(PositionLimits {
223        withdraw_limit,
224        borrow_limit: clamp_to_u64(cmp::max(0, borrow_limit_unclamped)),
225    })
226}
227
228/// Compute decimal scaling factors for converting between token precision and USDC (6 decimals).
229fn decimal_scale(token_decimals: u32) -> MathResult<(u32, u32)> {
230    if token_decimals > 6 {
231        let numerator = 10u32
232            .checked_pow(token_decimals.checked_sub(6).ok_or(MathError::Overflow)?)
233            .ok_or(MathError::Overflow)?;
234        Ok((numerator, 1))
235    } else {
236        let denominator = 10u32
237            .checked_pow(
238                6u32.checked_sub(token_decimals)
239                    .ok_or(MathError::Overflow)?,
240            )
241            .ok_or(MathError::Overflow)?;
242        Ok((1, denominator))
243    }
244}
245
246/// Clamp an i128 value to u64 range.
247fn clamp_to_u64(value: i128) -> u64 {
248    if value < 0 {
249        0
250    } else if value > u64::MAX as i128 {
251        u64::MAX
252    } else {
253        value as u64
254    }
255}
256
257#[cfg(test)]
258#[allow(
259    clippy::unwrap_used,
260    clippy::expect_used,
261    clippy::panic,
262    clippy::arithmetic_side_effects
263)]
264mod tests {
265    use super::*;
266    use pyra_types::{HistoricalOracleData, InsuranceFund};
267
268    fn make_spot_market(
269        market_index: u16,
270        decimals: u32,
271        initial_asset_weight: u32,
272        initial_liability_weight: u32,
273    ) -> SpotMarket {
274        let precision_decrease = 10u128.pow(19u32.saturating_sub(decimals));
275        SpotMarket {
276            pubkey: vec![],
277            market_index,
278            initial_asset_weight,
279            initial_liability_weight,
280            imf_factor: 0,
281            scale_initial_asset_weight_start: 0,
282            decimals,
283            cumulative_deposit_interest: precision_decrease,
284            cumulative_borrow_interest: precision_decrease,
285            deposit_balance: 0,
286            borrow_balance: 0,
287            optimal_utilization: 0,
288            optimal_borrow_rate: 0,
289            max_borrow_rate: 0,
290            min_borrow_rate: 0,
291            insurance_fund: InsuranceFund::default(),
292            historical_oracle_data: HistoricalOracleData {
293                last_oracle_price_twap5min: 1_000_000,
294            },
295            oracle: None,
296        }
297    }
298
299    fn usdc_market() -> SpotMarket {
300        make_spot_market(0, 6, 10_000, 10_000)
301    }
302
303    fn sol_market() -> SpotMarket {
304        make_spot_market(1, 9, 8_000, 12_000)
305    }
306
307    // --- MarginState tests ---
308
309    #[test]
310    fn empty_positions() {
311        let state = MarginState::calculate(&[]).unwrap();
312        assert_eq!(state.total_weighted_collateral, 0);
313        assert_eq!(state.total_weighted_liabilities, 0);
314        assert_eq!(state.free_collateral(), 0);
315        assert_eq!(state.credit_usage_bps().unwrap(), 0);
316    }
317
318    #[test]
319    fn single_deposit() {
320        let market = usdc_market();
321        let positions = [PositionData {
322            token_balance: 1_000_000, // 1 USDC
323            price_usdc_base_units: 1_000_000,
324            twap5min: 1_000_000,
325            spot_market: &market,
326        }];
327        let state = MarginState::calculate(&positions).unwrap();
328        assert_eq!(state.total_weighted_collateral, 1_000_000);
329        assert_eq!(state.total_weighted_liabilities, 0);
330        assert_eq!(state.free_collateral(), 1_000_000);
331        assert_eq!(state.credit_usage_bps().unwrap(), 0);
332    }
333
334    #[test]
335    fn deposit_and_borrow() {
336        let market = usdc_market();
337        let positions = [
338            PositionData {
339                token_balance: 1_000_000, // 1 USDC deposit
340                price_usdc_base_units: 1_000_000,
341                twap5min: 1_000_000,
342                spot_market: &market,
343            },
344            PositionData {
345                token_balance: -500_000, // 0.5 USDC borrow
346                price_usdc_base_units: 1_000_000,
347                twap5min: 1_000_000,
348                spot_market: &market,
349            },
350        ];
351        let state = MarginState::calculate(&positions).unwrap();
352        assert_eq!(state.total_weighted_collateral, 1_000_000);
353        assert_eq!(state.total_weighted_liabilities, 500_000);
354        assert_eq!(state.free_collateral(), 500_000);
355        assert_eq!(state.credit_usage_bps().unwrap(), 5_000); // 50%
356    }
357
358    #[test]
359    fn multi_market_positions() {
360        let usdc = usdc_market();
361        let sol = sol_market(); // 80% asset weight
362        let positions = [
363            PositionData {
364                token_balance: 10_000_000, // 10 USDC
365                price_usdc_base_units: 1_000_000,
366                twap5min: 1_000_000,
367                spot_market: &usdc,
368            },
369            PositionData {
370                token_balance: 1_000_000_000,       // 1 SOL
371                price_usdc_base_units: 100_000_000, // $100
372                twap5min: 100_000_000,
373                spot_market: &sol,
374            },
375        ];
376        let state = MarginState::calculate(&positions).unwrap();
377        // 10 USDC * 100% = 10 USDC weighted
378        // 1 SOL * $100 * 80% = $80 weighted
379        assert_eq!(state.total_weighted_collateral, 10_000_000 + 80_000_000);
380        assert_eq!(state.total_weighted_liabilities, 0);
381    }
382
383    #[test]
384    fn strict_pricing_for_assets() {
385        let market = usdc_market();
386        // TWAP is lower than oracle -> use TWAP for asset
387        let positions = [PositionData {
388            token_balance: 1_000_000,
389            price_usdc_base_units: 1_100_000,
390            twap5min: 1_000_000,
391            spot_market: &market,
392        }];
393        let state = MarginState::calculate(&positions).unwrap();
394        // Should use min(1_100_000, 1_000_000) = 1_000_000
395        assert_eq!(state.total_weighted_collateral, 1_000_000);
396    }
397
398    #[test]
399    fn zero_balance_skipped() {
400        let market = usdc_market();
401        let positions = [PositionData {
402            token_balance: 0,
403            price_usdc_base_units: 1_000_000,
404            twap5min: 1_000_000,
405            spot_market: &market,
406        }];
407        let state = MarginState::calculate(&positions).unwrap();
408        assert_eq!(state.total_weighted_collateral, 0);
409    }
410
411    // --- free_collateral clamped to zero ---
412
413    #[test]
414    fn free_collateral_clamped_to_zero() {
415        let state = MarginState {
416            total_weighted_collateral: 5_000_000,
417            total_weighted_liabilities: 10_000_000,
418        };
419        assert_eq!(state.free_collateral(), 0);
420    }
421
422    // --- credit_usage capped at 10_000 ---
423
424    #[test]
425    fn credit_usage_capped_at_10000() {
426        let state = MarginState {
427            total_weighted_collateral: 5_000_000,
428            total_weighted_liabilities: 10_000_000,
429        };
430        assert_eq!(state.credit_usage_bps().unwrap(), 10_000);
431    }
432
433    // --- Position limits tests ---
434
435    #[test]
436    fn withdraw_limit_no_liabilities() {
437        let market = usdc_market();
438        let state = MarginState {
439            total_weighted_collateral: 10_000_000,
440            total_weighted_liabilities: 0,
441        };
442        let limits =
443            calculate_position_limits(&state, &market, 1_000_000, 10_000_000, false).unwrap();
444        // No liabilities -> full deposit
445        assert_eq!(limits.withdraw_limit, 10_000_000);
446    }
447
448    #[test]
449    fn withdraw_limit_zero_asset_weight() {
450        let market = make_spot_market(0, 6, 0, 10_000);
451        let state = MarginState {
452            total_weighted_collateral: 10_000_000,
453            total_weighted_liabilities: 5_000_000,
454        };
455        let limits =
456            calculate_position_limits(&state, &market, 1_000_000, 10_000_000, false).unwrap();
457        assert_eq!(limits.withdraw_limit, 10_000_000);
458    }
459
460    #[test]
461    fn withdraw_limit_with_liabilities() {
462        let market = usdc_market(); // 100% asset weight
463        let state = MarginState {
464            total_weighted_collateral: 10_000_000,
465            total_weighted_liabilities: 5_000_000,
466        };
467        // free_collateral = 5_000_000
468        // withdraw_limit = 5M * 10k / 10k * 1M / 1M * 1 / 1 = 5_000_000
469        let limits =
470            calculate_position_limits(&state, &market, 1_000_000, 10_000_000, false).unwrap();
471        assert_eq!(limits.withdraw_limit, 5_000_000);
472    }
473
474    #[test]
475    fn withdraw_limit_capped_at_deposit() {
476        let market = usdc_market();
477        let state = MarginState {
478            total_weighted_collateral: 100_000_000,
479            total_weighted_liabilities: 1_000_000,
480        };
481        let deposit = 2_000_000i128;
482        let limits = calculate_position_limits(&state, &market, 1_000_000, deposit, false).unwrap();
483        assert_eq!(limits.withdraw_limit, deposit as u64);
484    }
485
486    #[test]
487    fn withdraw_limit_zero_price() {
488        let market = usdc_market();
489        let state = MarginState {
490            total_weighted_collateral: 10_000_000,
491            total_weighted_liabilities: 5_000_000,
492        };
493        let limits = calculate_position_limits(&state, &market, 0, 10_000_000, false).unwrap();
494        assert_eq!(limits.withdraw_limit, 0);
495        assert_eq!(limits.borrow_limit, 0);
496    }
497
498    #[test]
499    fn withdraw_limit_sol_decimals() {
500        let market = sol_market(); // 9 decimals, 80% weight
501        let state = MarginState {
502            total_weighted_collateral: 100_000_000, // $100
503            total_weighted_liabilities: 20_000_000, // $20
504        };
505        // free_collateral = $80
506        // withdraw_limit = 80M * 10k / 8k * 1M / 100M * 1000 / 1
507        //                = 100M * 0.01 * 1000 = 1_000_000_000 (1 SOL)
508        let limits = calculate_position_limits(
509            &state,
510            &market,
511            100_000_000,   // $100
512            2_000_000_000, // 2 SOL deposit
513            false,
514        )
515        .unwrap();
516        assert_eq!(limits.withdraw_limit, 1_000_000_000); // 1 SOL
517    }
518
519    // --- Borrow limit tests ---
520
521    #[test]
522    fn borrow_limit_basic() {
523        let market = usdc_market(); // 100% asset, 100% liability weight
524        let state = MarginState {
525            total_weighted_collateral: 10_000_000,
526            total_weighted_liabilities: 0,
527        };
528        let limits =
529            calculate_position_limits(&state, &market, 1_000_000, 10_000_000, false).unwrap();
530        // After subtracting full weighted position value (10M * 100% = 10M):
531        // free_collateral_after = 10M - 10M = 0
532        // max_liability = 0 -> borrow = withdraw_limit + 0 = 10M
533        assert_eq!(limits.withdraw_limit, 10_000_000);
534        assert_eq!(limits.borrow_limit, 10_000_000);
535    }
536
537    #[test]
538    fn borrow_limit_with_collateral_headroom() {
539        // SOL collateral backing USDC borrows
540        let usdc = usdc_market();
541        let state = MarginState {
542            total_weighted_collateral: 80_000_000, // $80 weighted (1 SOL at $100, 80% weight)
543            total_weighted_liabilities: 0,
544        };
545        // No USDC deposits -> token_balance = 0, so free_collateral_after = free_collateral
546        let limits = calculate_position_limits(&state, &usdc, 1_000_000, 0, false).unwrap();
547        // free_collateral_after = $80 (no position to subtract)
548        // max_liability = 80M * 10k / 10k * 1M / 1M = 80M
549        // borrow = 0 + 80M = $80
550        assert_eq!(limits.withdraw_limit, 0);
551        assert_eq!(limits.borrow_limit, 80_000_000);
552    }
553
554    #[test]
555    fn borrow_limit_zero_asset_weight() {
556        let market = make_spot_market(0, 6, 0, 10_000);
557        let state = MarginState {
558            total_weighted_collateral: 10_000_000,
559            total_weighted_liabilities: 0,
560        };
561        let limits =
562            calculate_position_limits(&state, &market, 1_000_000, 5_000_000, false).unwrap();
563        // asset_weight=0 -> can withdraw full deposit
564        assert_eq!(limits.withdraw_limit, 5_000_000);
565        // Weighted position value is 0 (asset_weight=0), so full $10M collateral
566        // (from other positions) remains to back borrowing:
567        // borrow = 5M withdraw + 10M max_liability = 15M
568        assert_eq!(limits.borrow_limit, 15_000_000);
569    }
570
571    // --- reduce_only tests ---
572
573    #[test]
574    fn usdc_reduce_only() {
575        let market = usdc_market();
576        let state = MarginState {
577            total_weighted_collateral: 100_000_000,
578            total_weighted_liabilities: 0,
579        };
580        let limits =
581            calculate_position_limits(&state, &market, 1_000_000, 10_000_000, true).unwrap();
582        assert_eq!(limits.borrow_limit, limits.withdraw_limit);
583    }
584
585    // --- decimal_scale tests ---
586
587    #[test]
588    fn decimal_scale_usdc() {
589        let (n, d) = decimal_scale(6).unwrap();
590        assert_eq!((n, d), (1, 1));
591    }
592
593    #[test]
594    fn decimal_scale_sol() {
595        let (n, d) = decimal_scale(9).unwrap();
596        assert_eq!((n, d), (1_000, 1));
597    }
598
599    #[test]
600    fn decimal_scale_small() {
601        let (n, d) = decimal_scale(4).unwrap();
602        assert_eq!((n, d), (1, 100));
603    }
604
605    // --- clamp_to_u64 tests ---
606
607    #[test]
608    fn clamp_negative() {
609        assert_eq!(clamp_to_u64(-100), 0);
610    }
611
612    #[test]
613    fn clamp_overflow() {
614        assert_eq!(clamp_to_u64(i128::from(u64::MAX) + 1), u64::MAX);
615    }
616
617    #[test]
618    fn clamp_normal() {
619        assert_eq!(clamp_to_u64(42), 42);
620    }
621}
622
623#[cfg(test)]
624#[allow(
625    clippy::unwrap_used,
626    clippy::expect_used,
627    clippy::panic,
628    clippy::arithmetic_side_effects
629)]
630mod proptests {
631    use super::*;
632    use proptest::prelude::*;
633    use pyra_types::{HistoricalOracleData, InsuranceFund};
634
635    fn arb_usdc_market() -> SpotMarket {
636        SpotMarket {
637            pubkey: vec![],
638            market_index: 0,
639            initial_asset_weight: 10_000,
640            initial_liability_weight: 10_000,
641            imf_factor: 0,
642            scale_initial_asset_weight_start: 0,
643            decimals: 6,
644            cumulative_deposit_interest: 10_000_000_000_000,
645            cumulative_borrow_interest: 10_000_000_000_000,
646            deposit_balance: 0,
647            borrow_balance: 0,
648            optimal_utilization: 0,
649            optimal_borrow_rate: 0,
650            max_borrow_rate: 0,
651            min_borrow_rate: 0,
652            insurance_fund: InsuranceFund::default(),
653            historical_oracle_data: HistoricalOracleData {
654                last_oracle_price_twap5min: 1_000_000,
655            },
656            oracle: None,
657        }
658    }
659
660    proptest! {
661        #[test]
662        fn withdraw_limit_le_deposit(
663            collateral in 0i128..=1_000_000_000_000i128,
664            liabilities in 0i128..=1_000_000_000_000i128,
665            deposit in 0i128..=1_000_000_000_000i128,
666        ) {
667            let market = arb_usdc_market();
668            let state = MarginState {
669                total_weighted_collateral: collateral,
670                total_weighted_liabilities: liabilities,
671            };
672            let limits = calculate_position_limits(&state, &market, 1_000_000, deposit, false).unwrap();
673            let deposit_u64 = clamp_to_u64(std::cmp::max(0, deposit));
674            prop_assert!(limits.withdraw_limit <= deposit_u64, "withdraw {} > deposit {}", limits.withdraw_limit, deposit_u64);
675        }
676
677        #[test]
678        fn borrow_limit_ge_withdraw_limit(
679            collateral in 1i128..=1_000_000_000_000i128,
680            liabilities in 0i128..=500_000_000_000i128,
681            deposit in 0i128..=1_000_000_000_000i128,
682        ) {
683            let market = arb_usdc_market();
684            let state = MarginState {
685                total_weighted_collateral: collateral,
686                total_weighted_liabilities: liabilities,
687            };
688            let limits = calculate_position_limits(&state, &market, 1_000_000, deposit, false).unwrap();
689            prop_assert!(limits.borrow_limit >= limits.withdraw_limit, "borrow {} < withdraw {}", limits.borrow_limit, limits.withdraw_limit);
690        }
691
692        #[test]
693        fn credit_usage_bounded(
694            collateral in 1i128..=1_000_000_000_000i128,
695            liabilities in 0i128..=1_000_000_000_000i128,
696        ) {
697            let state = MarginState {
698                total_weighted_collateral: collateral,
699                total_weighted_liabilities: liabilities,
700            };
701            let usage = state.credit_usage_bps().unwrap();
702            prop_assert!(usage <= 10_000, "usage {} > 10_000", usage);
703        }
704
705        #[test]
706        fn free_collateral_matches_components(
707            collateral in 0i128..=i128::MAX / 2,
708            liabilities in 0i128..=i128::MAX / 2,
709        ) {
710            let state = MarginState {
711                total_weighted_collateral: collateral,
712                total_weighted_liabilities: liabilities,
713            };
714            let fc = state.free_collateral();
715            let expected = collateral.saturating_sub(liabilities);
716            let expected_u64 = if expected < 0 { 0u64 } else { clamp_to_u64(expected) };
717            prop_assert_eq!(fc, expected_u64);
718        }
719    }
720}