pyra-margin 0.4.2

Margin weight, balance, and price calculations for Drift spot positions
Documentation
use pyra_types::{KaminoReserve, KAMINO_FRACTION_SCALE};

use crate::error::{MathError, MathResult};

/// Weight precision matching Drift: 10_000 = 100%.
const SPOT_WEIGHT_PRECISION: u128 = 10_000;

/// Price precision matching Drift: 1_000_000 = $1.
const PRICE_PRECISION: u128 = 1_000_000;

/// Calculate Kamino asset weight from loan-to-value percentage.
///
/// Converts `reserve.config.loan_to_value_pct` (0-100) to Drift's
/// SPOT_WEIGHT_PRECISION scale (10_000 = 100%).
///
/// Example: 80% LTV -> weight = 8_000.
pub fn get_kamino_asset_weight(reserve: &KaminoReserve) -> u128 {
    // loan_to_value_pct is u8 (max 100), so this can never overflow
    (reserve.config.loan_to_value_pct as u128)
        .saturating_mul(SPOT_WEIGHT_PRECISION)
        .saturating_div(100)
}

/// Calculate Kamino liability weight from liquidation threshold percentage.
///
/// Liability weight is the inverse of the liquidation threshold, scaled to
/// SPOT_WEIGHT_PRECISION. A higher threshold means lower liability weight
/// (less margin required).
///
/// Example: 85% threshold -> weight = 10_000 * 100 / 85 = 11_764.
///
/// Returns Overflow if `liquidation_threshold_pct` is 0.
pub fn get_kamino_liability_weight(reserve: &KaminoReserve) -> MathResult<u128> {
    let threshold = reserve.config.liquidation_threshold_pct as u128;
    if threshold == 0 {
        return Err(MathError::Overflow);
    }
    SPOT_WEIGHT_PRECISION
        .checked_mul(100)
        .ok_or(MathError::Overflow)?
        .checked_div(threshold)
        .ok_or(MathError::Overflow)
}

/// Extract price from a Kamino reserve's `market_price_sf` field.
///
/// Converts from Fraction (U68F60) to PRICE_PRECISION scale (1e6 = $1):
/// `price = market_price_sf * PRICE_PRECISION / 2^60`
///
/// No TWAP/strict pricing for now; uses the on-chain stored price directly.
///
/// Reference: `liquidity_amount_to_market_value()` in Klend `state/reserve.rs`.
pub fn get_kamino_price(reserve: &KaminoReserve) -> MathResult<i128> {
    let price = reserve
        .liquidity
        .market_price_sf
        .checked_mul(PRICE_PRECISION)
        .ok_or(MathError::Overflow)?
        .checked_div(KAMINO_FRACTION_SCALE)
        .ok_or(MathError::Overflow)?;
    i128::try_from(price).map_err(|_| MathError::Overflow)
}

#[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::{
        KaminoBigFractionBytes, KaminoLastUpdate, KaminoReserveCollateral, KaminoReserveConfig,
        KaminoReserveLiquidity,
    };
    use solana_pubkey::Pubkey;

    fn make_reserve_with_config(ltv: u8, liq_threshold: u8, price_sf: u128) -> KaminoReserve {
        KaminoReserve {
            lending_market: Pubkey::default(),
            liquidity: KaminoReserveLiquidity {
                mint_pubkey: Pubkey::default(),
                supply_vault: Pubkey::default(),
                fee_vault: Pubkey::default(),
                total_available_amount: 0,
                borrowed_amount_sf: 0,
                cumulative_borrow_rate_bsf: KaminoBigFractionBytes::default(),
                mint_decimals: 6,
                market_price_sf: price_sf,
                accumulated_protocol_fees_sf: 0,
                accumulated_referrer_fees_sf: 0,
                pending_referrer_fees_sf: 0,
                token_program: Pubkey::default(),
            },
            collateral: KaminoReserveCollateral {
                mint_pubkey: Pubkey::default(),
                supply_vault: Pubkey::default(),
                mint_total_supply: 0,
            },
            config: KaminoReserveConfig {
                loan_to_value_pct: ltv,
                liquidation_threshold_pct: liq_threshold,
                protocol_take_rate_pct: 0,
                protocol_liquidation_fee_pct: 0,
                borrow_factor_pct: 100,
                deposit_limit: 0,
                borrow_limit: 0,
                fees: Default::default(),
                borrow_rate_curve: Default::default(),
                deposit_withdrawal_cap: Default::default(),
                debt_withdrawal_cap: Default::default(),
                elevation_groups: [0; 20],
            },
            last_update: KaminoLastUpdate::default(),
        }
    }

    // --- get_kamino_asset_weight tests ---

    #[test]
    fn asset_weight_80_ltv() {
        let reserve = make_reserve_with_config(80, 85, 0);
        assert_eq!(get_kamino_asset_weight(&reserve), 8_000);
    }

    #[test]
    fn asset_weight_100_ltv() {
        let reserve = make_reserve_with_config(100, 100, 0);
        assert_eq!(get_kamino_asset_weight(&reserve), 10_000);
    }

    #[test]
    fn asset_weight_0_ltv() {
        let reserve = make_reserve_with_config(0, 85, 0);
        assert_eq!(get_kamino_asset_weight(&reserve), 0);
    }

    #[test]
    fn asset_weight_50_ltv() {
        let reserve = make_reserve_with_config(50, 85, 0);
        assert_eq!(get_kamino_asset_weight(&reserve), 5_000);
    }

    // --- get_kamino_liability_weight tests ---

    #[test]
    fn liability_weight_85_threshold() {
        let reserve = make_reserve_with_config(80, 85, 0);
        assert_eq!(get_kamino_liability_weight(&reserve).unwrap(), 11_764);
    }

    #[test]
    fn liability_weight_100_threshold() {
        let reserve = make_reserve_with_config(80, 100, 0);
        assert_eq!(get_kamino_liability_weight(&reserve).unwrap(), 10_000);
    }

    #[test]
    fn liability_weight_50_threshold() {
        let reserve = make_reserve_with_config(80, 50, 0);
        assert_eq!(get_kamino_liability_weight(&reserve).unwrap(), 20_000);
    }

    #[test]
    fn liability_weight_zero_threshold_errors() {
        let reserve = make_reserve_with_config(80, 0, 0);
        assert!(get_kamino_liability_weight(&reserve).is_err());
    }

    // --- get_kamino_price tests ---

    #[test]
    fn price_one_dollar() {
        let price_sf = 1u128 << 60; // $1
        let reserve = make_reserve_with_config(80, 85, price_sf);
        assert_eq!(get_kamino_price(&reserve).unwrap(), 1_000_000);
    }

    #[test]
    fn price_hundred_dollars() {
        let price_sf = 100u128 << 60;
        let reserve = make_reserve_with_config(80, 85, price_sf);
        assert_eq!(get_kamino_price(&reserve).unwrap(), 100_000_000);
    }

    #[test]
    fn price_zero() {
        let reserve = make_reserve_with_config(80, 85, 0);
        assert_eq!(get_kamino_price(&reserve).unwrap(), 0);
    }

    #[test]
    fn price_fractional() {
        let price_sf = 1u128 << 59; // $0.50
        let reserve = make_reserve_with_config(80, 85, price_sf);
        assert_eq!(get_kamino_price(&reserve).unwrap(), 500_000);
    }
}