From 8ffa91633bc5792ed5c4dbe0ced14177c6bf0b50 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jakob=20Pov=C5=A1i=C4=8D?= Date: Wed, 1 Nov 2023 16:28:30 +0100 Subject: [PATCH 1/3] fix: price liquidations with price bias --- .../marginfi_account/liquidate.rs | 4 +- programs/marginfi/src/state/price.rs | 42 +++++++++++++++++++ 2 files changed, 44 insertions(+), 2 deletions(-) diff --git a/programs/marginfi/src/instructions/marginfi_account/liquidate.rs b/programs/marginfi/src/instructions/marginfi_account/liquidate.rs index 3160a49d..0607e8ce 100644 --- a/programs/marginfi/src/instructions/marginfi_account/liquidate.rs +++ b/programs/marginfi/src/instructions/marginfi_account/liquidate.rs @@ -118,7 +118,7 @@ pub fn lending_account_liquidate( current_timestamp, MAX_PRICE_AGE_SEC, )?; - asset_pf.get_price()? + asset_pf.get_price_with_lower_bias()? }; let mut liab_bank = ctx.accounts.liab_bank.load_mut()?; @@ -131,7 +131,7 @@ pub fn lending_account_liquidate( MAX_PRICE_AGE_SEC, )?; - liab_pf.get_price()? + liab_pf.get_price_with_higher_bias()? }; let final_discount = I80F48::ONE - (LIQUIDATION_INSURANCE_FEE + LIQUIDATION_LIQUIDATOR_FEE); diff --git a/programs/marginfi/src/state/price.rs b/programs/marginfi/src/state/price.rs index 584f9ff0..0e05fca0 100644 --- a/programs/marginfi/src/state/price.rs +++ b/programs/marginfi/src/state/price.rs @@ -32,6 +32,8 @@ pub trait PriceAdapter { /// Get a normalized price range for the given price feed. /// The range is the price +/- the CONF_INTERVAL_MULTIPLE * confidence interval. fn get_price_range(&self) -> MarginfiResult<(I80F48, I80F48)>; + fn get_price_with_lower_bias(&self) -> MarginfiResult; + fn get_price_with_higher_bias(&self) -> MarginfiResult; } #[enum_dispatch(PriceAdapter)] @@ -161,6 +163,24 @@ impl PriceAdapter for PythEmaPriceFeed { Ok((lowest_price, highest_price)) } + + fn get_price_with_lower_bias(&self) -> MarginfiResult { + let price = self.get_price()?; + let conf_interval = self.get_confidence_interval()?; + + let price = price.checked_sub(conf_interval).ok_or_else(math_error!())?; + + Ok(price) + } + + fn get_price_with_higher_bias(&self) -> MarginfiResult { + let price = self.get_price()?; + let conf_interval = self.get_confidence_interval()?; + + let price = price.checked_add(conf_interval).ok_or_else(math_error!())?; + + Ok(price) + } } pub struct SwitchboardV2PriceFeed { @@ -248,6 +268,28 @@ impl PriceAdapter for SwitchboardV2PriceFeed { Ok((lowest_price, highest_price)) } + + fn get_price_with_lower_bias(&self) -> MarginfiResult { + let base_price = self.get_price()?; + let price_range = self.get_confidence_interval()?; + + let lowest_price = base_price + .checked_sub(price_range) + .ok_or_else(math_error!())?; + + Ok(lowest_price) + } + + fn get_price_with_higher_bias(&self) -> MarginfiResult { + let base_price = self.get_price()?; + let price_range = self.get_confidence_interval()?; + + let highest_price = base_price + .checked_add(price_range) + .ok_or_else(math_error!())?; + + Ok(highest_price) + } } /// A slimmed down version of the AggregatorAccountData struct copied from the switchboard-v2/src/aggregator.rs From 5b09647de6db511a9a3aab1da682d5ad537cf12a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jakob=20Pov=C5=A1i=C4=8D?= Date: Wed, 1 Nov 2023 22:06:05 +0100 Subject: [PATCH 2/3] fix: use non ema/twap price for liquidation pricing --- .../marginfi_account/liquidate.rs | 4 +- programs/marginfi/src/state/price.rs | 65 ++++++------------- 2 files changed, 23 insertions(+), 46 deletions(-) diff --git a/programs/marginfi/src/instructions/marginfi_account/liquidate.rs b/programs/marginfi/src/instructions/marginfi_account/liquidate.rs index 0607e8ce..3bc5f62b 100644 --- a/programs/marginfi/src/instructions/marginfi_account/liquidate.rs +++ b/programs/marginfi/src/instructions/marginfi_account/liquidate.rs @@ -118,7 +118,7 @@ pub fn lending_account_liquidate( current_timestamp, MAX_PRICE_AGE_SEC, )?; - asset_pf.get_price_with_lower_bias()? + asset_pf.get_price_non_weighted()? }; let mut liab_bank = ctx.accounts.liab_bank.load_mut()?; @@ -131,7 +131,7 @@ pub fn lending_account_liquidate( MAX_PRICE_AGE_SEC, )?; - liab_pf.get_price_with_higher_bias()? + liab_pf.get_price_non_weighted()? }; let final_discount = I80F48::ONE - (LIQUIDATION_INSURANCE_FEE + LIQUIDATION_LIQUIDATOR_FEE); diff --git a/programs/marginfi/src/state/price.rs b/programs/marginfi/src/state/price.rs index 0e05fca0..c013798e 100644 --- a/programs/marginfi/src/state/price.rs +++ b/programs/marginfi/src/state/price.rs @@ -32,8 +32,9 @@ pub trait PriceAdapter { /// Get a normalized price range for the given price feed. /// The range is the price +/- the CONF_INTERVAL_MULTIPLE * confidence interval. fn get_price_range(&self) -> MarginfiResult<(I80F48, I80F48)>; - fn get_price_with_lower_bias(&self) -> MarginfiResult; - fn get_price_with_higher_bias(&self) -> MarginfiResult; + /// Get the price without any weighting applied. + /// This is the price that is used for liquidation. + fn get_price_non_weighted(&self) -> MarginfiResult; } #[enum_dispatch(PriceAdapter)] @@ -110,17 +111,23 @@ impl OraclePriceFeedAdapter { } pub struct PythEmaPriceFeed { + ema_price: Box, price: Box, } impl PythEmaPriceFeed { pub fn load_checked(ai: &AccountInfo, current_time: i64, max_age: u64) -> MarginfiResult { let price_feed = load_pyth_price_feed(ai)?; - let price = price_feed + let ema_price = price_feed .get_ema_price_no_older_than(current_time, max_age) .ok_or(MarginfiError::StaleOracle)?; + let price = price_feed + .get_price_no_older_than(current_time, max_age) + .ok_or(MarginfiError::StaleOracle)?; + Ok(Self { + ema_price: Box::new(ema_price), price: Box::new(price), }) } @@ -133,14 +140,16 @@ impl PythEmaPriceFeed { impl PriceAdapter for PythEmaPriceFeed { fn get_price(&self) -> MarginfiResult { - pyth_price_components_to_i80f48(I80F48::from_num(self.price.price), self.price.expo) + pyth_price_components_to_i80f48(I80F48::from_num(self.ema_price.price), self.ema_price.expo) } fn get_confidence_interval(&self) -> MarginfiResult { - let conf_interval = - pyth_price_components_to_i80f48(I80F48::from_num(self.price.conf), self.price.expo)? - .checked_mul(CONF_INTERVAL_MULTIPLE) - .ok_or_else(math_error!())?; + let conf_interval = pyth_price_components_to_i80f48( + I80F48::from_num(self.ema_price.conf), + self.ema_price.expo, + )? + .checked_mul(CONF_INTERVAL_MULTIPLE) + .ok_or_else(math_error!())?; assert!( conf_interval >= I80F48::ZERO, @@ -164,22 +173,8 @@ impl PriceAdapter for PythEmaPriceFeed { Ok((lowest_price, highest_price)) } - fn get_price_with_lower_bias(&self) -> MarginfiResult { - let price = self.get_price()?; - let conf_interval = self.get_confidence_interval()?; - - let price = price.checked_sub(conf_interval).ok_or_else(math_error!())?; - - Ok(price) - } - - fn get_price_with_higher_bias(&self) -> MarginfiResult { - let price = self.get_price()?; - let conf_interval = self.get_confidence_interval()?; - - let price = price.checked_add(conf_interval).ok_or_else(math_error!())?; - - Ok(price) + fn get_price_non_weighted(&self) -> MarginfiResult { + pyth_price_components_to_i80f48(I80F48::from_num(self.price.price), self.price.expo) } } @@ -269,26 +264,8 @@ impl PriceAdapter for SwitchboardV2PriceFeed { Ok((lowest_price, highest_price)) } - fn get_price_with_lower_bias(&self) -> MarginfiResult { - let base_price = self.get_price()?; - let price_range = self.get_confidence_interval()?; - - let lowest_price = base_price - .checked_sub(price_range) - .ok_or_else(math_error!())?; - - Ok(lowest_price) - } - - fn get_price_with_higher_bias(&self) -> MarginfiResult { - let base_price = self.get_price()?; - let price_range = self.get_confidence_interval()?; - - let highest_price = base_price - .checked_add(price_range) - .ok_or_else(math_error!())?; - - Ok(highest_price) + fn get_price_non_weighted(&self) -> MarginfiResult { + self.get_price() } } From d9eeec95c0488715006b3ab212f81c1cfabded77 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jakob=20Pov=C5=A1i=C4=8D?= Date: Thu, 2 Nov 2023 08:39:22 +0100 Subject: [PATCH 3/3] fix: use confidence bands for liquidation pricing --- .../marginfi_account/liquidate.rs | 6 +- programs/marginfi/src/state/price.rs | 83 +++++++++++++++---- programs/marginfi/tests/marginfi_account.rs | 8 +- test-utils/src/utils.rs | 5 ++ 4 files changed, 77 insertions(+), 25 deletions(-) diff --git a/programs/marginfi/src/instructions/marginfi_account/liquidate.rs b/programs/marginfi/src/instructions/marginfi_account/liquidate.rs index 3bc5f62b..77a61de9 100644 --- a/programs/marginfi/src/instructions/marginfi_account/liquidate.rs +++ b/programs/marginfi/src/instructions/marginfi_account/liquidate.rs @@ -7,7 +7,7 @@ use crate::state::marginfi_account::{ calc_asset_amount, calc_asset_value, RiskEngine, RiskRequirementType, }; use crate::state::marginfi_group::{Bank, BankVaultType}; -use crate::state::price::{OraclePriceFeedAdapter, PriceAdapter}; +use crate::state::price::{OraclePriceFeedAdapter, PriceAdapter, PriceBias}; use crate::{ bank_signer, constants::{LIQUIDITY_VAULT_AUTHORITY_SEED, LIQUIDITY_VAULT_SEED}, @@ -118,7 +118,7 @@ pub fn lending_account_liquidate( current_timestamp, MAX_PRICE_AGE_SEC, )?; - asset_pf.get_price_non_weighted()? + asset_pf.get_price_non_weighted(Some(PriceBias::Low))? }; let mut liab_bank = ctx.accounts.liab_bank.load_mut()?; @@ -131,7 +131,7 @@ pub fn lending_account_liquidate( MAX_PRICE_AGE_SEC, )?; - liab_pf.get_price_non_weighted()? + liab_pf.get_price_non_weighted(Some(PriceBias::High))? }; let final_discount = I80F48::ONE - (LIQUIDATION_INSURANCE_FEE + LIQUIDATION_LIQUIDATOR_FEE); diff --git a/programs/marginfi/src/state/price.rs b/programs/marginfi/src/state/price.rs index c013798e..8824c357 100644 --- a/programs/marginfi/src/state/price.rs +++ b/programs/marginfi/src/state/price.rs @@ -25,6 +25,12 @@ pub enum OracleSetup { SwitchboardV2, } +#[derive(Copy, Clone, Debug)] +pub enum PriceBias { + Low, + High, +} + #[enum_dispatch] pub trait PriceAdapter { fn get_price(&self) -> MarginfiResult; @@ -34,7 +40,7 @@ pub trait PriceAdapter { fn get_price_range(&self) -> MarginfiResult<(I80F48, I80F48)>; /// Get the price without any weighting applied. /// This is the price that is used for liquidation. - fn get_price_non_weighted(&self) -> MarginfiResult; + fn get_price_non_weighted(&self, bias: Option) -> MarginfiResult; } #[enum_dispatch(PriceAdapter)] @@ -136,20 +142,18 @@ impl PythEmaPriceFeed { load_pyth_price_feed(ai)?; Ok(()) } -} -impl PriceAdapter for PythEmaPriceFeed { - fn get_price(&self) -> MarginfiResult { - pyth_price_components_to_i80f48(I80F48::from_num(self.ema_price.price), self.ema_price.expo) - } + fn get_confidence_interval(&self, use_ema: bool) -> MarginfiResult { + let price = if use_ema { + &self.ema_price + } else { + &self.price + }; - fn get_confidence_interval(&self) -> MarginfiResult { - let conf_interval = pyth_price_components_to_i80f48( - I80F48::from_num(self.ema_price.conf), - self.ema_price.expo, - )? - .checked_mul(CONF_INTERVAL_MULTIPLE) - .ok_or_else(math_error!())?; + let conf_interval = + pyth_price_components_to_i80f48(I80F48::from_num(price.conf), price.expo)? + .checked_mul(CONF_INTERVAL_MULTIPLE) + .ok_or_else(math_error!())?; assert!( conf_interval >= I80F48::ZERO, @@ -158,10 +162,20 @@ impl PriceAdapter for PythEmaPriceFeed { Ok(conf_interval) } +} + +impl PriceAdapter for PythEmaPriceFeed { + fn get_price(&self) -> MarginfiResult { + pyth_price_components_to_i80f48(I80F48::from_num(self.ema_price.price), self.ema_price.expo) + } + + fn get_confidence_interval(&self) -> MarginfiResult { + self.get_confidence_interval(true) + } fn get_price_range(&self) -> MarginfiResult<(I80F48, I80F48)> { let base_price = self.get_price()?; - let price_range = self.get_confidence_interval()?; + let price_range = self.get_confidence_interval(true)?; let lowest_price = base_price .checked_sub(price_range) @@ -173,8 +187,25 @@ impl PriceAdapter for PythEmaPriceFeed { Ok((lowest_price, highest_price)) } - fn get_price_non_weighted(&self) -> MarginfiResult { - pyth_price_components_to_i80f48(I80F48::from_num(self.price.price), self.price.expo) + fn get_price_non_weighted(&self, price_bias: Option) -> MarginfiResult { + let price = + pyth_price_components_to_i80f48(I80F48::from_num(self.price.price), self.price.expo)?; + + match price_bias { + None => Ok(price), + Some(price_bias) => { + let confidence_interval = self.get_confidence_interval(false)?; + + match price_bias { + PriceBias::Low => Ok(price + .checked_sub(confidence_interval) + .ok_or_else(math_error!())?), + PriceBias::High => Ok(price + .checked_add(confidence_interval) + .ok_or_else(math_error!())?), + } + } + } } } @@ -264,8 +295,24 @@ impl PriceAdapter for SwitchboardV2PriceFeed { Ok((lowest_price, highest_price)) } - fn get_price_non_weighted(&self) -> MarginfiResult { - self.get_price() + fn get_price_non_weighted(&self, price_bias: Option) -> MarginfiResult { + let price = self.get_price()?; + + match price_bias { + Some(price_bias) => { + let confidence_interval = self.get_confidence_interval()?; + + match price_bias { + PriceBias::Low => Ok(price + .checked_sub(confidence_interval) + .ok_or_else(math_error!())?), + PriceBias::High => Ok(price + .checked_add(confidence_interval) + .ok_or_else(math_error!())?), + } + } + None => Ok(price), + } } } diff --git a/programs/marginfi/tests/marginfi_account.rs b/programs/marginfi/tests/marginfi_account.rs index fbe5d50b..e5a4ab1c 100644 --- a/programs/marginfi/tests/marginfi_account.rs +++ b/programs/marginfi/tests/marginfi_account.rs @@ -925,7 +925,7 @@ async fn marginfi_account_liquidation_success_many_balances() -> anyhow::Result< assert_eq_noise!( insurance_fund_usdc.balance().await as i64, native!(0.25, "USDC", f64) as i64, - 1 + native!(0.001, "USDC", f64) as i64 ); Ok(()) @@ -1012,7 +1012,7 @@ async fn marginfi_account_liquidation_success_swb() -> anyhow::Result<()> { .get_asset_amount(depositor_ma.lending_account.balances[0].asset_shares.into()) .unwrap(), I80F48::from(native!(1990.25, "USDC", f64)), - native!(0.00001, "USDC", f64) + native!(0.01, "USDC", f64) ); // Borrower should have 99 SOL @@ -1033,7 +1033,7 @@ async fn marginfi_account_liquidation_success_swb() -> anyhow::Result<()> { ) .unwrap(), I80F48::from(native!(989.50, "USDC", f64)), - native!(0.00001, "USDC", f64) + native!(0.01, "USDC", f64) ); // Check insurance fund fee @@ -1044,7 +1044,7 @@ async fn marginfi_account_liquidation_success_swb() -> anyhow::Result<()> { assert_eq_noise!( insurance_fund_usdc.balance().await as i64, native!(0.25, "USDC", f64) as i64, - 1 + native!(0.001, "USDC", f64) as i64 ); Ok(()) diff --git a/test-utils/src/utils.rs b/test-utils/src/utils.rs index 6a14f6f2..a719c767 100644 --- a/test-utils/src/utils.rs +++ b/test-utils/src/utils.rs @@ -76,6 +76,11 @@ pub fn create_pyth_price_account( denom: 1, }, prev_timestamp: timestamp.unwrap_or(0), + ema_conf: Rational { + val: 0, + numer: 0, + denom: 1, + }, ..Default::default() }) .to_vec(),