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
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 pub total_collateral: i128,
43 pub total_liabilities: i128,
45}
46
47impl MarginState {
48 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 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 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 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 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 Ok(cmp::min(clamp_to_u64(cmp::max(0, usage)), 10_000))
143 }
144}
145
146#[derive(Debug, Clone, Copy, PartialEq, Eq)]
148pub struct PositionLimits {
149 pub withdraw_limit: u64,
150 pub borrow_limit: u64,
151}
152
153pub 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 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 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 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
247fn 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
265fn 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 #[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, 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, price_usdc_base_units: 1_000_000,
364 twap5min: 1_000_000,
365 spot_market: &market,
366 },
367 PositionData {
368 token_balance: -500_000, 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); }
382
383 #[test]
384 fn multi_market_positions() {
385 let usdc = usdc_market();
386 let sol = sol_market(); let positions = [
388 PositionData {
389 token_balance: 10_000_000, 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, price_usdc_base_units: 100_000_000, twap5min: 100_000_000,
398 spot_market: &sol,
399 },
400 ];
401 let state = MarginState::calculate(&positions).unwrap();
402 assert_eq!(state.total_weighted_collateral, 10_000_000 + 80_000_000);
405 assert_eq!(state.total_weighted_liabilities, 0);
406 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 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 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 #[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 #[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 #[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 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(); 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 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(); let state = MarginState {
544 total_weighted_collateral: 100_000_000, total_weighted_liabilities: 20_000_000, total_collateral: 125_000_000,
547 total_liabilities: 20_000_000,
548 };
549 let limits = calculate_position_limits(
553 &state,
554 &market,
555 100_000_000, 2_000_000_000, false,
558 )
559 .unwrap();
560 assert_eq!(limits.withdraw_limit, 1_000_000_000); }
562
563 #[test]
566 fn borrow_limit_basic() {
567 let market = usdc_market(); 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 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 let usdc = usdc_market();
587 let state = MarginState {
588 total_weighted_collateral: 80_000_000, total_weighted_liabilities: 0,
590 total_collateral: 100_000_000,
591 total_liabilities: 0,
592 };
593 let limits = calculate_position_limits(&state, &usdc, 1_000_000, 0, false).unwrap();
595 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 assert_eq!(limits.withdraw_limit, 5_000_000);
615 assert_eq!(limits.borrow_limit, 15_000_000);
619 }
620
621 #[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 #[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 #[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}