use std::cmp;
use pyra_types::SpotMarket;
use super::balance::calculate_value_usdc_base_units;
use super::weights::{calculate_asset_weight, calculate_liability_weight, get_strict_price};
use crate::error::{MathError, MathResult};
use crate::math::CheckedDivCeil;
const MARGIN_PRECISION: i128 = 10_000;
const PRICE_PRECISION: i128 = 1_000_000;
pub struct PositionData<'a> {
pub token_balance: i128,
pub price_usdc_base_units: u64,
pub twap5min: i64,
pub spot_market: &'a SpotMarket,
}
#[derive(Debug, Clone, Copy)]
pub struct MarginState {
pub total_weighted_collateral: i128,
pub total_weighted_liabilities: i128,
pub total_collateral: i128,
pub total_liabilities: i128,
}
impl MarginState {
pub fn calculate(positions: &[PositionData<'_>]) -> MathResult<Self> {
let mut total_weighted_collateral: i128 = 0;
let mut total_weighted_liabilities: i128 = 0;
let mut total_collateral: i128 = 0;
let mut total_liabilities: i128 = 0;
for pos in positions {
if pos.token_balance == 0 {
continue;
}
let is_asset = pos.token_balance >= 0;
let strict_price = get_strict_price(pos.price_usdc_base_units, pos.twap5min, is_asset);
let value_usdc = calculate_value_usdc_base_units(
pos.token_balance,
strict_price,
pos.spot_market.decimals,
)?;
if value_usdc >= 0 {
total_collateral = total_collateral
.checked_add(value_usdc)
.ok_or(MathError::Overflow)?;
} else {
total_liabilities = total_liabilities
.checked_add(value_usdc.checked_neg().ok_or(MathError::Overflow)?)
.ok_or(MathError::Overflow)?;
}
let token_amount_unsigned = pos.token_balance.unsigned_abs();
let weight_bps = if is_asset {
calculate_asset_weight(
token_amount_unsigned,
pos.price_usdc_base_units,
pos.spot_market,
)?
} else {
calculate_liability_weight(token_amount_unsigned, pos.spot_market)?
};
let weighted_value = value_usdc
.checked_mul(weight_bps as i128)
.ok_or(MathError::Overflow)?
.checked_div(MARGIN_PRECISION)
.ok_or(MathError::Overflow)?;
if weighted_value >= 0 {
total_weighted_collateral = total_weighted_collateral
.checked_add(weighted_value)
.ok_or(MathError::Overflow)?;
} else {
total_weighted_liabilities = total_weighted_liabilities
.checked_add(weighted_value.checked_neg().ok_or(MathError::Overflow)?)
.ok_or(MathError::Overflow)?;
}
}
Ok(Self {
total_weighted_collateral,
total_weighted_liabilities,
total_collateral,
total_liabilities,
})
}
pub fn free_collateral(&self) -> u64 {
let fc = self
.total_weighted_collateral
.saturating_sub(self.total_weighted_liabilities);
clamp_to_u64(cmp::max(0, fc))
}
pub fn credit_usage_bps(&self) -> MathResult<u64> {
if self.total_weighted_collateral <= 0 {
return Ok(0);
}
let usage = self
.total_weighted_liabilities
.checked_mul(10_000)
.ok_or(MathError::Overflow)?
.checked_div(self.total_weighted_collateral)
.ok_or(MathError::Overflow)?;
Ok(cmp::min(clamp_to_u64(cmp::max(0, usage)), 10_000))
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct PositionLimits {
pub withdraw_limit: u64,
pub borrow_limit: u64,
}
pub fn calculate_position_limits(
margin_state: &MarginState,
spot_market: &SpotMarket,
price_usdc_base_units: u64,
token_balance: i128,
reduce_only: bool,
) -> MathResult<PositionLimits> {
if price_usdc_base_units == 0 {
return Ok(PositionLimits {
withdraw_limit: 0,
borrow_limit: 0,
});
}
let free_collateral = cmp::max(
0,
margin_state
.total_weighted_collateral
.saturating_sub(margin_state.total_weighted_liabilities),
);
let token_deposit_balance = clamp_to_u64(cmp::max(0, token_balance));
let asset_weight = spot_market.initial_asset_weight;
let (numerator_scale, denominator_scale) = decimal_scale(spot_market.decimals)?;
let withdraw_limit = if asset_weight == 0 || margin_state.total_weighted_liabilities == 0 {
token_deposit_balance
} else {
let withdraw_limit_i128 = free_collateral
.checked_mul(MARGIN_PRECISION)
.and_then(|v| v.checked_div_ceil(asset_weight as i128))
.and_then(|v| v.checked_mul(PRICE_PRECISION))
.and_then(|v| v.checked_div_ceil(price_usdc_base_units as i128))
.and_then(|v| v.checked_mul(numerator_scale as i128))
.and_then(|v| v.checked_div(denominator_scale as i128))
.ok_or(MathError::Overflow)?;
cmp::min(
token_deposit_balance,
clamp_to_u64(cmp::max(0, withdraw_limit_i128)),
)
};
if reduce_only {
return Ok(PositionLimits {
withdraw_limit,
borrow_limit: withdraw_limit,
});
}
let free_collateral_after = if token_balance > 0 {
let position_value_usdc = calculate_value_usdc_base_units(
token_balance,
price_usdc_base_units,
spot_market.decimals,
)?;
let weighted_position_value = position_value_usdc
.checked_mul(asset_weight as i128)
.and_then(|v| v.checked_div(MARGIN_PRECISION))
.ok_or(MathError::Overflow)?;
cmp::max(0, free_collateral.saturating_sub(weighted_position_value))
} else {
free_collateral
};
let liability_weight = spot_market.initial_liability_weight as i128;
let max_liability = free_collateral_after
.checked_mul(MARGIN_PRECISION)
.and_then(|v| v.checked_div(liability_weight))
.and_then(|v| v.checked_mul(PRICE_PRECISION))
.and_then(|v| v.checked_div(price_usdc_base_units as i128))
.and_then(|v| v.checked_mul(numerator_scale as i128))
.and_then(|v| v.checked_div(denominator_scale as i128))
.ok_or(MathError::Overflow)?;
let borrow_limit_unclamped = (withdraw_limit as i128)
.checked_add(max_liability)
.ok_or(MathError::Overflow)?;
Ok(PositionLimits {
withdraw_limit,
borrow_limit: clamp_to_u64(cmp::max(0, borrow_limit_unclamped)),
})
}
fn decimal_scale(token_decimals: u32) -> MathResult<(u32, u32)> {
if token_decimals > 6 {
let numerator = 10u32
.checked_pow(token_decimals.checked_sub(6).ok_or(MathError::Overflow)?)
.ok_or(MathError::Overflow)?;
Ok((numerator, 1))
} else {
let denominator = 10u32
.checked_pow(
6u32.checked_sub(token_decimals)
.ok_or(MathError::Overflow)?,
)
.ok_or(MathError::Overflow)?;
Ok((1, denominator))
}
}
fn clamp_to_u64(value: i128) -> u64 {
if value < 0 {
0
} else if value > u64::MAX as i128 {
u64::MAX
} else {
value as u64
}
}
#[cfg(test)]
#[allow(
clippy::allow_attributes,
clippy::allow_attributes_without_reason,
clippy::unwrap_used,
clippy::expect_used,
clippy::panic,
clippy::arithmetic_side_effects,
reason = "test code"
)]
mod tests {
use super::*;
use pyra_types::{HistoricalOracleData, InsuranceFund};
fn make_spot_market(
market_index: u16,
decimals: u32,
initial_asset_weight: u32,
initial_liability_weight: u32,
) -> SpotMarket {
let precision_decrease = 10u128.pow(19u32.saturating_sub(decimals));
SpotMarket {
pubkey: vec![],
market_index,
initial_asset_weight,
initial_liability_weight,
imf_factor: 0,
scale_initial_asset_weight_start: 0,
decimals,
cumulative_deposit_interest: precision_decrease,
cumulative_borrow_interest: precision_decrease,
deposit_balance: 0,
borrow_balance: 0,
optimal_utilization: 0,
optimal_borrow_rate: 0,
max_borrow_rate: 0,
min_borrow_rate: 0,
insurance_fund: InsuranceFund::default(),
historical_oracle_data: HistoricalOracleData {
last_oracle_price_twap5min: 1_000_000,
},
oracle: None,
}
}
fn usdc_market() -> SpotMarket {
make_spot_market(0, 6, 10_000, 10_000)
}
fn sol_market() -> SpotMarket {
make_spot_market(1, 9, 8_000, 12_000)
}
#[test]
fn empty_positions() {
let state = MarginState::calculate(&[]).unwrap();
assert_eq!(state.total_weighted_collateral, 0);
assert_eq!(state.total_weighted_liabilities, 0);
assert_eq!(state.free_collateral(), 0);
assert_eq!(state.credit_usage_bps().unwrap(), 0);
}
#[test]
fn single_deposit() {
let market = usdc_market();
let positions = [PositionData {
token_balance: 1_000_000, price_usdc_base_units: 1_000_000,
twap5min: 1_000_000,
spot_market: &market,
}];
let state = MarginState::calculate(&positions).unwrap();
assert_eq!(state.total_weighted_collateral, 1_000_000);
assert_eq!(state.total_weighted_liabilities, 0);
assert_eq!(state.total_collateral, 1_000_000);
assert_eq!(state.total_liabilities, 0);
assert_eq!(state.free_collateral(), 1_000_000);
assert_eq!(state.credit_usage_bps().unwrap(), 0);
}
#[test]
fn deposit_and_borrow() {
let market = usdc_market();
let positions = [
PositionData {
token_balance: 1_000_000, price_usdc_base_units: 1_000_000,
twap5min: 1_000_000,
spot_market: &market,
},
PositionData {
token_balance: -500_000, price_usdc_base_units: 1_000_000,
twap5min: 1_000_000,
spot_market: &market,
},
];
let state = MarginState::calculate(&positions).unwrap();
assert_eq!(state.total_weighted_collateral, 1_000_000);
assert_eq!(state.total_weighted_liabilities, 500_000);
assert_eq!(state.total_collateral, 1_000_000);
assert_eq!(state.total_liabilities, 500_000);
assert_eq!(state.free_collateral(), 500_000);
assert_eq!(state.credit_usage_bps().unwrap(), 5_000); }
#[test]
fn multi_market_positions() {
let usdc = usdc_market();
let sol = sol_market(); let positions = [
PositionData {
token_balance: 10_000_000, price_usdc_base_units: 1_000_000,
twap5min: 1_000_000,
spot_market: &usdc,
},
PositionData {
token_balance: 1_000_000_000, price_usdc_base_units: 100_000_000, twap5min: 100_000_000,
spot_market: &sol,
},
];
let state = MarginState::calculate(&positions).unwrap();
assert_eq!(state.total_weighted_collateral, 10_000_000 + 80_000_000);
assert_eq!(state.total_weighted_liabilities, 0);
assert_eq!(state.total_collateral, 10_000_000 + 100_000_000);
assert_eq!(state.total_liabilities, 0);
}
#[test]
fn strict_pricing_for_assets() {
let market = usdc_market();
let positions = [PositionData {
token_balance: 1_000_000,
price_usdc_base_units: 1_100_000,
twap5min: 1_000_000,
spot_market: &market,
}];
let state = MarginState::calculate(&positions).unwrap();
assert_eq!(state.total_weighted_collateral, 1_000_000);
}
#[test]
fn zero_balance_skipped() {
let market = usdc_market();
let positions = [PositionData {
token_balance: 0,
price_usdc_base_units: 1_000_000,
twap5min: 1_000_000,
spot_market: &market,
}];
let state = MarginState::calculate(&positions).unwrap();
assert_eq!(state.total_weighted_collateral, 0);
}
#[test]
fn free_collateral_clamped_to_zero() {
let state = MarginState {
total_weighted_collateral: 5_000_000,
total_weighted_liabilities: 10_000_000,
total_collateral: 5_000_000,
total_liabilities: 10_000_000,
};
assert_eq!(state.free_collateral(), 0);
}
#[test]
fn credit_usage_capped_at_10000() {
let state = MarginState {
total_weighted_collateral: 5_000_000,
total_weighted_liabilities: 10_000_000,
total_collateral: 5_000_000,
total_liabilities: 10_000_000,
};
assert_eq!(state.credit_usage_bps().unwrap(), 10_000);
}
#[test]
fn withdraw_limit_no_liabilities() {
let market = usdc_market();
let state = MarginState {
total_weighted_collateral: 10_000_000,
total_weighted_liabilities: 0,
total_collateral: 10_000_000,
total_liabilities: 0,
};
let limits =
calculate_position_limits(&state, &market, 1_000_000, 10_000_000, false).unwrap();
assert_eq!(limits.withdraw_limit, 10_000_000);
}
#[test]
fn withdraw_limit_zero_asset_weight() {
let market = make_spot_market(0, 6, 0, 10_000);
let state = MarginState {
total_weighted_collateral: 10_000_000,
total_weighted_liabilities: 5_000_000,
total_collateral: 10_000_000,
total_liabilities: 5_000_000,
};
let limits =
calculate_position_limits(&state, &market, 1_000_000, 10_000_000, false).unwrap();
assert_eq!(limits.withdraw_limit, 10_000_000);
}
#[test]
fn withdraw_limit_with_liabilities() {
let market = usdc_market(); let state = MarginState {
total_weighted_collateral: 10_000_000,
total_weighted_liabilities: 5_000_000,
total_collateral: 10_000_000,
total_liabilities: 5_000_000,
};
let limits =
calculate_position_limits(&state, &market, 1_000_000, 10_000_000, false).unwrap();
assert_eq!(limits.withdraw_limit, 5_000_000);
}
#[test]
fn withdraw_limit_capped_at_deposit() {
let market = usdc_market();
let state = MarginState {
total_weighted_collateral: 100_000_000,
total_weighted_liabilities: 1_000_000,
total_collateral: 100_000_000,
total_liabilities: 1_000_000,
};
let deposit = 2_000_000i128;
let limits = calculate_position_limits(&state, &market, 1_000_000, deposit, false).unwrap();
assert_eq!(limits.withdraw_limit, deposit as u64);
}
#[test]
fn withdraw_limit_zero_price() {
let market = usdc_market();
let state = MarginState {
total_weighted_collateral: 10_000_000,
total_weighted_liabilities: 5_000_000,
total_collateral: 10_000_000,
total_liabilities: 5_000_000,
};
let limits = calculate_position_limits(&state, &market, 0, 10_000_000, false).unwrap();
assert_eq!(limits.withdraw_limit, 0);
assert_eq!(limits.borrow_limit, 0);
}
#[test]
fn withdraw_limit_sol_decimals() {
let market = sol_market(); let state = MarginState {
total_weighted_collateral: 100_000_000, total_weighted_liabilities: 20_000_000, total_collateral: 125_000_000,
total_liabilities: 20_000_000,
};
let limits = calculate_position_limits(
&state,
&market,
100_000_000, 2_000_000_000, false,
)
.unwrap();
assert_eq!(limits.withdraw_limit, 1_000_000_000); }
#[test]
fn borrow_limit_basic() {
let market = usdc_market(); let state = MarginState {
total_weighted_collateral: 10_000_000,
total_weighted_liabilities: 0,
total_collateral: 10_000_000,
total_liabilities: 0,
};
let limits =
calculate_position_limits(&state, &market, 1_000_000, 10_000_000, false).unwrap();
assert_eq!(limits.withdraw_limit, 10_000_000);
assert_eq!(limits.borrow_limit, 10_000_000);
}
#[test]
fn borrow_limit_with_collateral_headroom() {
let usdc = usdc_market();
let state = MarginState {
total_weighted_collateral: 80_000_000, total_weighted_liabilities: 0,
total_collateral: 100_000_000,
total_liabilities: 0,
};
let limits = calculate_position_limits(&state, &usdc, 1_000_000, 0, false).unwrap();
assert_eq!(limits.withdraw_limit, 0);
assert_eq!(limits.borrow_limit, 80_000_000);
}
#[test]
fn borrow_limit_zero_asset_weight() {
let market = make_spot_market(0, 6, 0, 10_000);
let state = MarginState {
total_weighted_collateral: 10_000_000,
total_weighted_liabilities: 0,
total_collateral: 10_000_000,
total_liabilities: 0,
};
let limits =
calculate_position_limits(&state, &market, 1_000_000, 5_000_000, false).unwrap();
assert_eq!(limits.withdraw_limit, 5_000_000);
assert_eq!(limits.borrow_limit, 15_000_000);
}
#[test]
fn usdc_reduce_only() {
let market = usdc_market();
let state = MarginState {
total_weighted_collateral: 100_000_000,
total_weighted_liabilities: 0,
total_collateral: 100_000_000,
total_liabilities: 0,
};
let limits =
calculate_position_limits(&state, &market, 1_000_000, 10_000_000, true).unwrap();
assert_eq!(limits.borrow_limit, limits.withdraw_limit);
}
#[test]
fn decimal_scale_usdc() {
let (n, d) = decimal_scale(6).unwrap();
assert_eq!((n, d), (1, 1));
}
#[test]
fn decimal_scale_sol() {
let (n, d) = decimal_scale(9).unwrap();
assert_eq!((n, d), (1_000, 1));
}
#[test]
fn decimal_scale_small() {
let (n, d) = decimal_scale(4).unwrap();
assert_eq!((n, d), (1, 100));
}
#[test]
fn clamp_negative() {
assert_eq!(clamp_to_u64(-100), 0);
}
#[test]
fn clamp_overflow() {
assert_eq!(clamp_to_u64(i128::from(u64::MAX) + 1), u64::MAX);
}
#[test]
fn clamp_normal() {
assert_eq!(clamp_to_u64(42), 42);
}
}
#[cfg(test)]
#[allow(
clippy::allow_attributes,
clippy::allow_attributes_without_reason,
clippy::unwrap_used,
clippy::expect_used,
clippy::panic,
clippy::arithmetic_side_effects,
reason = "test code"
)]
mod proptests {
use super::*;
use proptest::prelude::*;
use pyra_types::{HistoricalOracleData, InsuranceFund};
fn arb_usdc_market() -> SpotMarket {
SpotMarket {
pubkey: vec![],
market_index: 0,
initial_asset_weight: 10_000,
initial_liability_weight: 10_000,
imf_factor: 0,
scale_initial_asset_weight_start: 0,
decimals: 6,
cumulative_deposit_interest: 10_000_000_000_000,
cumulative_borrow_interest: 10_000_000_000_000,
deposit_balance: 0,
borrow_balance: 0,
optimal_utilization: 0,
optimal_borrow_rate: 0,
max_borrow_rate: 0,
min_borrow_rate: 0,
insurance_fund: InsuranceFund::default(),
historical_oracle_data: HistoricalOracleData {
last_oracle_price_twap5min: 1_000_000,
},
oracle: None,
}
}
proptest! {
#[test]
fn withdraw_limit_le_deposit(
collateral in 0i128..=1_000_000_000_000i128,
liabilities in 0i128..=1_000_000_000_000i128,
deposit in 0i128..=1_000_000_000_000i128,
) {
let market = arb_usdc_market();
let state = MarginState {
total_weighted_collateral: collateral,
total_weighted_liabilities: liabilities,
total_collateral: collateral,
total_liabilities: liabilities,
};
let limits = calculate_position_limits(&state, &market, 1_000_000, deposit, false).unwrap();
let deposit_u64 = clamp_to_u64(std::cmp::max(0, deposit));
prop_assert!(limits.withdraw_limit <= deposit_u64, "withdraw {} > deposit {}", limits.withdraw_limit, deposit_u64);
}
#[test]
fn borrow_limit_ge_withdraw_limit(
collateral in 1i128..=1_000_000_000_000i128,
liabilities in 0i128..=500_000_000_000i128,
deposit in 0i128..=1_000_000_000_000i128,
) {
let market = arb_usdc_market();
let state = MarginState {
total_weighted_collateral: collateral,
total_weighted_liabilities: liabilities,
total_collateral: collateral,
total_liabilities: liabilities,
};
let limits = calculate_position_limits(&state, &market, 1_000_000, deposit, false).unwrap();
prop_assert!(limits.borrow_limit >= limits.withdraw_limit, "borrow {} < withdraw {}", limits.borrow_limit, limits.withdraw_limit);
}
#[test]
fn credit_usage_bounded(
collateral in 1i128..=1_000_000_000_000i128,
liabilities in 0i128..=1_000_000_000_000i128,
) {
let state = MarginState {
total_weighted_collateral: collateral,
total_weighted_liabilities: liabilities,
total_collateral: collateral,
total_liabilities: liabilities,
};
let usage = state.credit_usage_bps().unwrap();
prop_assert!(usage <= 10_000, "usage {} > 10_000", usage);
}
#[test]
fn free_collateral_matches_components(
collateral in 0i128..=i128::MAX / 2,
liabilities in 0i128..=i128::MAX / 2,
) {
let state = MarginState {
total_weighted_collateral: collateral,
total_weighted_liabilities: liabilities,
total_collateral: collateral,
total_liabilities: liabilities,
};
let fc = state.free_collateral();
let expected = collateral.saturating_sub(liabilities);
let expected_u64 = if expected < 0 { 0u64 } else { clamp_to_u64(expected) };
prop_assert_eq!(fc, expected_u64);
}
}
}