Skip to main content

pyra_margin/drift/
limits.rs

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