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
13pub struct PositionData<'a> {
19 pub token_balance: i128,
23 pub price_usdc_base_units: u64,
25 pub twap5min: i64,
27 pub spot_market: &'a SpotMarket,
29}
30
31#[derive(Debug, Clone, Copy)]
36pub struct MarginState {
37 pub total_weighted_collateral: i128,
39 pub total_weighted_liabilities: i128,
41}
42
43impl MarginState {
44 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 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 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 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 Ok(cmp::min(clamp_to_u64(cmp::max(0, usage)), 10_000))
124 }
125}
126
127#[derive(Debug, Clone, Copy, PartialEq, Eq)]
129pub struct PositionLimits {
130 pub withdraw_limit: u64,
131 pub borrow_limit: u64,
132}
133
134pub 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 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 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 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
228fn 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
246fn 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 #[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, 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, price_usdc_base_units: 1_000_000,
341 twap5min: 1_000_000,
342 spot_market: &market,
343 },
344 PositionData {
345 token_balance: -500_000, 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); }
357
358 #[test]
359 fn multi_market_positions() {
360 let usdc = usdc_market();
361 let sol = sol_market(); let positions = [
363 PositionData {
364 token_balance: 10_000_000, 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, price_usdc_base_units: 100_000_000, twap5min: 100_000_000,
373 spot_market: &sol,
374 },
375 ];
376 let state = MarginState::calculate(&positions).unwrap();
377 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 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 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 #[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 #[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 #[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 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(); let state = MarginState {
464 total_weighted_collateral: 10_000_000,
465 total_weighted_liabilities: 5_000_000,
466 };
467 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(); let state = MarginState {
502 total_weighted_collateral: 100_000_000, total_weighted_liabilities: 20_000_000, };
505 let limits = calculate_position_limits(
509 &state,
510 &market,
511 100_000_000, 2_000_000_000, false,
514 )
515 .unwrap();
516 assert_eq!(limits.withdraw_limit, 1_000_000_000); }
518
519 #[test]
522 fn borrow_limit_basic() {
523 let market = usdc_market(); 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 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 let usdc = usdc_market();
541 let state = MarginState {
542 total_weighted_collateral: 80_000_000, total_weighted_liabilities: 0,
544 };
545 let limits = calculate_position_limits(&state, &usdc, 1_000_000, 0, false).unwrap();
547 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 assert_eq!(limits.withdraw_limit, 5_000_000);
565 assert_eq!(limits.borrow_limit, 15_000_000);
569 }
570
571 #[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 #[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 #[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}