diff --git a/programs/mango-v4/src/state/health.rs b/programs/mango-v4/src/state/health.rs deleted file mode 100644 index 18ab30019..000000000 --- a/programs/mango-v4/src/state/health.rs +++ /dev/null @@ -1,2431 +0,0 @@ -use anchor_lang::prelude::*; -use anchor_lang::ZeroCopy; - -use fixed::types::I80F48; -use fixed_macro::types::I80F48; -use serum_dex::state::OpenOrders; - -use std::cell::Ref; -use std::collections::HashMap; - -use crate::accounts_zerocopy::*; -use crate::error::*; -use crate::serum3_cpi; -use crate::state::{ - Bank, MangoAccountFixed, PerpMarket, PerpMarketIndex, PerpPosition, Serum3MarketIndex, - TokenIndex, -}; -use crate::util::checked_math as cm; - -#[cfg(feature = "client")] -use crate::state::orderbook::Side as PerpOrderSide; - -use super::MangoAccountRef; - -const ONE_NATIVE_USDC_IN_USD: I80F48 = I80F48!(0.000001); - -/// Information about prices for a bank or perp market. -#[derive(Clone, AnchorDeserialize, AnchorSerialize)] -pub struct Prices { - /// The current oracle price - pub oracle: I80F48, // native/native - - /// A "stable" price, provided by StablePriceModel - pub stable: I80F48, // native/native -} - -impl Prices { - // intended for tests - pub fn new_single_price(price: I80F48) -> Self { - Self { - oracle: price, - stable: price, - } - } - - /// The liability price to use for the given health type - #[inline(always)] - pub fn liab(&self, health_type: HealthType) -> I80F48 { - if health_type == HealthType::Maint { - self.oracle - } else { - self.oracle.max(self.stable) - } - } - - /// The asset price to use for the given health type - #[inline(always)] - pub fn asset(&self, health_type: HealthType) -> I80F48 { - if health_type == HealthType::Maint { - self.oracle - } else { - self.oracle.min(self.stable) - } - } -} - -/// This trait abstracts how to find accounts needed for the health computation. -/// -/// There are different ways they are retrieved from remainingAccounts, based -/// on the instruction: -/// - FixedOrderAccountRetriever requires the remainingAccounts to be in a well -/// defined order and is the fastest. It's used where possible. -/// - ScanningAccountRetriever does a linear scan for each account it needs. -/// It needs more compute, but works when a union of bank/oracle/market accounts -/// are passed because health needs to be computed for different baskets in -/// one instruction (such as for liquidation instructions). -pub trait AccountRetriever { - fn bank_and_oracle( - &self, - group: &Pubkey, - active_token_position_index: usize, - token_index: TokenIndex, - ) -> Result<(&Bank, I80F48)>; - - fn serum_oo(&self, active_serum_oo_index: usize, key: &Pubkey) -> Result<&OpenOrders>; - - fn perp_market_and_oracle_price( - &self, - group: &Pubkey, - active_perp_position_index: usize, - perp_market_index: PerpMarketIndex, - ) -> Result<(&PerpMarket, I80F48)>; -} - -/// Assumes the account infos needed for the health computation follow a strict order. -/// -/// 1. n_banks Bank account, in the order of account.token_iter_active() -/// 2. n_banks oracle accounts, one for each bank in the same order -/// 3. PerpMarket accounts, in the order of account.perps.iter_active_accounts() -/// 4. PerpMarket oracle accounts, in the order of the perp market accounts -/// 5. serum3 OpenOrders accounts, in the order of account.serum3.iter_active() -pub struct FixedOrderAccountRetriever { - pub ais: Vec, - pub n_banks: usize, - pub n_perps: usize, - pub begin_perp: usize, - pub begin_serum3: usize, - pub staleness_slot: Option, -} - -pub fn new_fixed_order_account_retriever<'a, 'info>( - ais: &'a [AccountInfo<'info>], - account: &MangoAccountRef, -) -> Result>> { - let active_token_len = account.active_token_positions().count(); - let active_serum3_len = account.active_serum3_orders().count(); - let active_perp_len = account.active_perp_positions().count(); - let expected_ais = cm!(active_token_len * 2 // banks + oracles - + active_perp_len * 2 // PerpMarkets + Oracles - + active_serum3_len); // open_orders - require_eq!(ais.len(), expected_ais); - - Ok(FixedOrderAccountRetriever { - ais: AccountInfoRef::borrow_slice(ais)?, - n_banks: active_token_len, - n_perps: active_perp_len, - begin_perp: cm!(active_token_len * 2), - begin_serum3: cm!(active_token_len * 2 + active_perp_len * 2), - staleness_slot: Some(Clock::get()?.slot), - }) -} - -impl FixedOrderAccountRetriever { - fn bank(&self, group: &Pubkey, account_index: usize, token_index: TokenIndex) -> Result<&Bank> { - let bank = self.ais[account_index].load::()?; - require_keys_eq!(bank.group, *group); - require_eq!(bank.token_index, token_index); - Ok(bank) - } - - fn perp_market( - &self, - group: &Pubkey, - account_index: usize, - perp_market_index: PerpMarketIndex, - ) -> Result<&PerpMarket> { - let market_ai = &self.ais[account_index]; - let market = market_ai.load::()?; - require_keys_eq!(market.group, *group); - require_eq!(market.perp_market_index, perp_market_index); - Ok(market) - } - - fn oracle_price_bank(&self, account_index: usize, bank: &Bank) -> Result { - let oracle = &self.ais[account_index]; - bank.oracle_price(oracle, self.staleness_slot) - } - - fn oracle_price_perp(&self, account_index: usize, perp_market: &PerpMarket) -> Result { - let oracle = &self.ais[account_index]; - perp_market.oracle_price(oracle, self.staleness_slot) - } -} - -impl AccountRetriever for FixedOrderAccountRetriever { - fn bank_and_oracle( - &self, - group: &Pubkey, - active_token_position_index: usize, - token_index: TokenIndex, - ) -> Result<(&Bank, I80F48)> { - let bank_account_index = active_token_position_index; - let bank = self - .bank(group, bank_account_index, token_index) - .with_context(|| { - format!( - "loading bank with health account index {}, token index {}, passed account {}", - bank_account_index, - token_index, - self.ais[bank_account_index].key(), - ) - })?; - - let oracle_index = cm!(self.n_banks + active_token_position_index); - let oracle_price = self.oracle_price_bank(oracle_index, bank).with_context(|| { - format!( - "getting oracle for bank with health account index {} and token index {}, passed account {}", - bank_account_index, - token_index, - self.ais[oracle_index].key(), - ) - })?; - - Ok((bank, oracle_price)) - } - - fn perp_market_and_oracle_price( - &self, - group: &Pubkey, - active_perp_position_index: usize, - perp_market_index: PerpMarketIndex, - ) -> Result<(&PerpMarket, I80F48)> { - let perp_index = cm!(self.begin_perp + active_perp_position_index); - let perp_market = self - .perp_market(group, perp_index, perp_market_index) - .with_context(|| { - format!( - "loading perp market with health account index {} and perp market index {}, passed account {}", - perp_index, - perp_market_index, - self.ais[perp_index].key(), - ) - })?; - - let oracle_index = cm!(perp_index + self.n_perps); - let oracle_price = self.oracle_price_perp(oracle_index, perp_market).with_context(|| { - format!( - "getting oracle for perp market with health account index {} and perp market index {}, passed account {}", - oracle_index, - perp_market_index, - self.ais[oracle_index].key(), - ) - })?; - Ok((perp_market, oracle_price)) - } - - fn serum_oo(&self, active_serum_oo_index: usize, key: &Pubkey) -> Result<&OpenOrders> { - let serum_oo_index = cm!(self.begin_serum3 + active_serum_oo_index); - let ai = &self.ais[serum_oo_index]; - (|| { - require_keys_eq!(*key, *ai.key()); - serum3_cpi::load_open_orders(ai) - })() - .with_context(|| { - format!( - "loading serum open orders with health account index {}, passed account {}", - serum_oo_index, - ai.key(), - ) - }) - } -} - -/// Takes a list of account infos containing -/// - an unknown number of Banks in any order, followed by -/// - the same number of oracles in the same order as the banks, followed by -/// - an unknown number of PerpMarket accounts -/// - the same number of oracles in the same order as the perp markets -/// - an unknown number of serum3 OpenOrders accounts -/// and retrieves accounts needed for the health computation by doing a linear -/// scan for each request. -pub struct ScanningAccountRetriever<'a, 'info> { - banks: Vec>, - oracles: Vec>, - perp_markets: Vec>, - perp_oracles: Vec>, - serum3_oos: Vec>, - token_index_map: HashMap, - perp_index_map: HashMap, - staleness_slot: Option, -} - -/// Returns None if `ai` doesn't have the owner or discriminator for T. -/// Forwards "can't borrow" error, so it can be raised immediately. -fn can_load_as<'a, T: ZeroCopy + Owner>( - (i, ai): (usize, &'a AccountInfo), -) -> Option<(usize, Result>)> { - let load_result = ai.load::(); - match load_result { - Err(Error::AnchorError(error)) - if error.error_code_number == ErrorCode::AccountDiscriminatorMismatch as u32 - || error.error_code_number == ErrorCode::AccountDiscriminatorNotFound as u32 - || error.error_code_number == ErrorCode::AccountOwnedByWrongProgram as u32 => - { - return None; - } - _ => {} - }; - Some((i, load_result)) -} - -impl<'a, 'info> ScanningAccountRetriever<'a, 'info> { - pub fn new(ais: &'a [AccountInfo<'info>], group: &Pubkey) -> Result { - Self::new_with_staleness(ais, group, Some(Clock::get()?.slot)) - } - - pub fn new_with_staleness( - ais: &'a [AccountInfo<'info>], - group: &Pubkey, - staleness_slot: Option, - ) -> Result { - // find all Bank accounts - let mut token_index_map = HashMap::with_capacity(ais.len() / 2); - ais.iter() - .enumerate() - .map_while(can_load_as::) - .try_for_each(|(i, loaded)| { - (|| { - let bank = loaded?; - require_keys_eq!(bank.group, *group); - let previous = token_index_map.insert(bank.token_index, i); - require_msg!( - previous.is_none(), - "duplicate bank for token index {}", - bank.token_index - ); - Ok(()) - })() - .with_context(|| format!("scanning banks, health account index {}", i)) - })?; - let n_banks = token_index_map.len(); - - // skip all banks and oracles, then find number of PerpMarket accounts - let perps_start = n_banks * 2; - let mut perp_index_map = HashMap::with_capacity(ais.len().saturating_sub(perps_start)); - ais[perps_start..] - .iter() - .enumerate() - .map_while(can_load_as::) - .try_for_each(|(i, loaded)| { - (|| { - let perp_market = loaded?; - require_keys_eq!(perp_market.group, *group); - let previous = perp_index_map.insert(perp_market.perp_market_index, i); - require_msg!( - previous.is_none(), - "duplicate perp market for perp market index {}", - perp_market.perp_market_index - ); - Ok(()) - })() - .with_context(|| { - format!( - "scanning perp markets, health account index {}", - i + perps_start - ) - }) - })?; - let n_perps = perp_index_map.len(); - let perp_oracles_start = perps_start + n_perps; - let serum3_start = perp_oracles_start + n_perps; - - Ok(Self { - banks: AccountInfoRefMut::borrow_slice(&ais[..n_banks])?, - oracles: AccountInfoRef::borrow_slice(&ais[n_banks..perps_start])?, - perp_markets: AccountInfoRef::borrow_slice(&ais[perps_start..perp_oracles_start])?, - perp_oracles: AccountInfoRef::borrow_slice(&ais[perp_oracles_start..serum3_start])?, - serum3_oos: AccountInfoRef::borrow_slice(&ais[serum3_start..])?, - token_index_map, - perp_index_map, - staleness_slot, - }) - } - - #[inline] - fn bank_index(&self, token_index: TokenIndex) -> Result { - Ok(*self - .token_index_map - .get(&token_index) - .ok_or_else(|| error_msg!("token index {} not found", token_index))?) - } - - #[inline] - fn perp_market_index(&self, perp_market_index: PerpMarketIndex) -> Result { - Ok(*self - .perp_index_map - .get(&perp_market_index) - .ok_or_else(|| error_msg!("perp market index {} not found", perp_market_index))?) - } - - #[allow(clippy::type_complexity)] - pub fn banks_mut_and_oracles( - &mut self, - token_index1: TokenIndex, - token_index2: TokenIndex, - ) -> Result<(&mut Bank, I80F48, Option<(&mut Bank, I80F48)>)> { - if token_index1 == token_index2 { - let index = self.bank_index(token_index1)?; - let bank = self.banks[index].load_mut_fully_unchecked::()?; - let oracle = &self.oracles[index]; - let price = bank.oracle_price(oracle, self.staleness_slot)?; - return Ok((bank, price, None)); - } - let index1 = self.bank_index(token_index1)?; - let index2 = self.bank_index(token_index2)?; - let (first, second, swap) = if index1 < index2 { - (index1, index2, false) - } else { - (index2, index1, true) - }; - - // split_at_mut after the first bank and after the second bank - let (first_bank_part, second_bank_part) = self.banks.split_at_mut(first + 1); - - let bank1 = first_bank_part[first].load_mut_fully_unchecked::()?; - let bank2 = second_bank_part[second - (first + 1)].load_mut_fully_unchecked::()?; - let oracle1 = &self.oracles[first]; - let oracle2 = &self.oracles[second]; - let price1 = bank1.oracle_price(oracle1, self.staleness_slot)?; - let price2 = bank2.oracle_price(oracle2, self.staleness_slot)?; - if swap { - Ok((bank2, price2, Some((bank1, price1)))) - } else { - Ok((bank1, price1, Some((bank2, price2)))) - } - } - - pub fn scanned_bank_and_oracle(&self, token_index: TokenIndex) -> Result<(&Bank, I80F48)> { - let index = self.bank_index(token_index)?; - // The account was already loaded successfully during construction - let bank = self.banks[index].load_fully_unchecked::()?; - let oracle = &self.oracles[index]; - let price = bank.oracle_price(oracle, self.staleness_slot)?; - Ok((bank, price)) - } - - pub fn scanned_perp_market_and_oracle( - &self, - perp_market_index: PerpMarketIndex, - ) -> Result<(&PerpMarket, I80F48)> { - let index = self.perp_market_index(perp_market_index)?; - // The account was already loaded successfully during construction - let perp_market = self.perp_markets[index].load_fully_unchecked::()?; - let oracle_acc = &self.perp_oracles[index]; - let price = perp_market.oracle_price(oracle_acc, self.staleness_slot)?; - Ok((perp_market, price)) - } - - pub fn scanned_serum_oo(&self, key: &Pubkey) -> Result<&OpenOrders> { - let oo = self - .serum3_oos - .iter() - .find(|ai| ai.key == key) - .ok_or_else(|| error_msg!("no serum3 open orders for key {}", key))?; - serum3_cpi::load_open_orders(oo) - } -} - -impl<'a, 'info> AccountRetriever for ScanningAccountRetriever<'a, 'info> { - fn bank_and_oracle( - &self, - _group: &Pubkey, - _account_index: usize, - token_index: TokenIndex, - ) -> Result<(&Bank, I80F48)> { - self.scanned_bank_and_oracle(token_index) - } - - fn perp_market_and_oracle_price( - &self, - _group: &Pubkey, - _account_index: usize, - perp_market_index: PerpMarketIndex, - ) -> Result<(&PerpMarket, I80F48)> { - self.scanned_perp_market_and_oracle(perp_market_index) - } - - fn serum_oo(&self, _account_index: usize, key: &Pubkey) -> Result<&OpenOrders> { - self.scanned_serum_oo(key) - } -} - -/// There are two types of health, initial health used for opening new positions and maintenance -/// health used for liquidations. They are both calculated as a weighted sum of the assets -/// minus the liabilities but the maint. health uses slightly larger weights for assets and -/// slightly smaller weights for the liabilities. Zero is used as the bright line for both -/// i.e. if your init health falls below zero, you cannot open new positions and if your maint. health -/// falls below zero you will be liquidated. -#[derive(PartialEq, Copy, Clone, AnchorSerialize, AnchorDeserialize)] -pub enum HealthType { - Init, - Maint, -} - -/// Computes health for a mango account given a set of account infos -/// -/// These account infos must fit the fixed layout defined by FixedOrderAccountRetriever. -pub fn compute_health_from_fixed_accounts( - account: &MangoAccountRef, - health_type: HealthType, - ais: &[AccountInfo], -) -> Result { - let retriever = new_fixed_order_account_retriever(ais, account)?; - Ok(new_health_cache(account, &retriever)?.health(health_type)) -} - -/// Compute health with an arbitrary AccountRetriever -pub fn compute_health( - account: &MangoAccountRef, - health_type: HealthType, - retriever: &impl AccountRetriever, -) -> Result { - Ok(new_health_cache(account, retriever)?.health(health_type)) -} - -#[derive(Clone, AnchorDeserialize, AnchorSerialize)] -pub struct TokenInfo { - pub token_index: TokenIndex, - pub maint_asset_weight: I80F48, - pub init_asset_weight: I80F48, - pub maint_liab_weight: I80F48, - pub init_liab_weight: I80F48, - pub prices: Prices, - pub balance_native: I80F48, -} - -impl TokenInfo { - #[inline(always)] - fn asset_weight(&self, health_type: HealthType) -> I80F48 { - match health_type { - HealthType::Init => self.init_asset_weight, - HealthType::Maint => self.maint_asset_weight, - } - } - - #[inline(always)] - fn liab_weight(&self, health_type: HealthType) -> I80F48 { - match health_type { - HealthType::Init => self.init_liab_weight, - HealthType::Maint => self.maint_liab_weight, - } - } - - #[inline(always)] - fn health_contribution(&self, health_type: HealthType) -> I80F48 { - let (weight, price) = if self.balance_native.is_negative() { - (self.liab_weight(health_type), self.prices.liab(health_type)) - } else { - ( - self.asset_weight(health_type), - self.prices.asset(health_type), - ) - }; - cm!(self.balance_native * price * weight) - } -} - -#[derive(Clone, AnchorDeserialize, AnchorSerialize)] -pub struct Serum3Info { - // reserved amounts as stored on the open orders - pub reserved_base: I80F48, - pub reserved_quote: I80F48, - - pub base_index: usize, - pub quote_index: usize, - pub market_index: Serum3MarketIndex, -} - -impl Serum3Info { - #[inline(always)] - fn health_contribution( - &self, - health_type: HealthType, - token_infos: &[TokenInfo], - token_max_reserved: &[I80F48], - market_reserved: &Serum3Reserved, - ) -> I80F48 { - if market_reserved.all_reserved_as_base.is_zero() - || market_reserved.all_reserved_as_quote.is_zero() - { - return I80F48::ZERO; - } - - let base_info = &token_infos[self.base_index]; - let quote_info = &token_infos[self.quote_index]; - let base_max_reserved = token_max_reserved[self.base_index]; - let quote_max_reserved = token_max_reserved[self.quote_index]; - - // How much would health increase if the reserved balance were applied to the passed - // token info? - let compute_health_effect = - |token_info: &TokenInfo, token_max_reserved: I80F48, market_reserved: I80F48| { - // This balance includes all possible reserved funds from markets that relate to the - // token, including this market itself: `market_reserved` is already included in `token_max_reserved`. - let max_balance = cm!(token_info.balance_native + token_max_reserved); - - // For simplicity, we assume that `market_reserved` was added to `max_balance` last - // (it underestimates health because that gives the smallest effects): how much did - // health change because of it? - let (asset_part, liab_part) = if max_balance >= market_reserved { - (market_reserved, I80F48::ZERO) - } else if max_balance.is_negative() { - (I80F48::ZERO, market_reserved) - } else { - (max_balance, cm!(market_reserved - max_balance)) - }; - - let asset_weight = token_info.asset_weight(health_type); - let liab_weight = token_info.liab_weight(health_type); - let asset_price = token_info.prices.asset(health_type); - let liab_price = token_info.prices.liab(health_type); - cm!(asset_part * asset_weight * asset_price + liab_part * liab_weight * liab_price) - }; - - let health_base = compute_health_effect( - base_info, - base_max_reserved, - market_reserved.all_reserved_as_base, - ); - let health_quote = compute_health_effect( - quote_info, - quote_max_reserved, - market_reserved.all_reserved_as_quote, - ); - health_base.min(health_quote) - } -} - -#[derive(Clone)] -struct Serum3Reserved { - /// base tokens when the serum3info.reserved_quote get converted to base and added to reserved_base - all_reserved_as_base: I80F48, - /// ditto the other way around - all_reserved_as_quote: I80F48, -} - -#[derive(Clone, AnchorDeserialize, AnchorSerialize)] -pub struct PerpInfo { - pub perp_market_index: PerpMarketIndex, - pub maint_asset_weight: I80F48, - pub init_asset_weight: I80F48, - pub maint_liab_weight: I80F48, - pub init_liab_weight: I80F48, - pub base_lot_size: i64, - pub base_lots: i64, - pub bids_base_lots: i64, - pub asks_base_lots: i64, - // in health-reference-token native units, no asset/liab factor needed - pub quote: I80F48, - pub prices: Prices, - pub has_open_orders: bool, - pub trusted_market: bool, -} - -impl PerpInfo { - fn new(perp_position: &PerpPosition, perp_market: &PerpMarket, prices: Prices) -> Result { - let base_lots = cm!(perp_position.base_position_lots() + perp_position.taker_base_lots); - - let unsettled_funding = perp_position.unsettled_funding(&perp_market); - let taker_quote = I80F48::from(cm!( - perp_position.taker_quote_lots * perp_market.quote_lot_size - )); - let quote_current = - cm!(perp_position.quote_position_native() - unsettled_funding + taker_quote); - - Ok(Self { - perp_market_index: perp_market.perp_market_index, - init_asset_weight: perp_market.init_asset_weight, - init_liab_weight: perp_market.init_liab_weight, - maint_asset_weight: perp_market.maint_asset_weight, - maint_liab_weight: perp_market.maint_liab_weight, - base_lot_size: perp_market.base_lot_size, - base_lots, - bids_base_lots: perp_position.bids_base_lots, - asks_base_lots: perp_position.asks_base_lots, - quote: quote_current, - prices, - has_open_orders: perp_position.has_open_orders(), - trusted_market: perp_market.trusted_market(), - }) - } - - /// Total health contribution from perp balances - /// - /// Due to isolation of perp markets, users may never borrow against perp - /// positions in untrusted without settling first: perp health is capped at zero. - /// - /// Users need to settle their perp pnl with other perp market participants - /// in order to realize their gains if they want to use them as collateral. - /// - /// This is because we don't trust the perp's base price to not suddenly jump to - /// zero (if users could borrow against their perp balances they might now - /// be bankrupt) or suddenly increase a lot (if users could borrow against perp - /// balances they could now borrow other assets). - #[inline(always)] - fn health_contribution(&self, health_type: HealthType) -> I80F48 { - let c = self.uncapped_health_contribution(health_type); - - if self.trusted_market { - c - } else { - c.min(I80F48::ZERO) - } - } - - #[inline(always)] - fn uncapped_health_contribution(&self, health_type: HealthType) -> I80F48 { - let order_execution_case = |orders_base_lots: i64, order_price: I80F48| { - let net_base_native = - I80F48::from(cm!((self.base_lots + orders_base_lots) * self.base_lot_size)); - let (weight, base_price) = match (health_type, net_base_native.is_negative()) { - (HealthType::Init, true) => (self.init_liab_weight, self.prices.liab(health_type)), - (HealthType::Init, false) => { - (self.init_asset_weight, self.prices.asset(health_type)) - } - (HealthType::Maint, true) => { - (self.maint_liab_weight, self.prices.liab(health_type)) - } - (HealthType::Maint, false) => { - (self.maint_asset_weight, self.prices.asset(health_type)) - } - }; - // Total value of the order-execution adjusted base position - let base_health = cm!(net_base_native * weight * base_price); - - let orders_base_native = I80F48::from(cm!(orders_base_lots * self.base_lot_size)); - // The quote change from executing the bids/asks - let order_quote = cm!(-orders_base_native * order_price); - - cm!(base_health + order_quote) - }; - - // What is worse: Executing all bids at oracle_price.liab, or executing all asks at oracle_price.asset? - let bids_case = order_execution_case(self.bids_base_lots, self.prices.liab(health_type)); - let asks_case = order_execution_case(-self.asks_base_lots, self.prices.asset(health_type)); - let worst_case = bids_case.min(asks_case); - - cm!(self.quote + worst_case) - } -} - -#[derive(Clone, AnchorDeserialize, AnchorSerialize)] -pub struct HealthCache { - token_infos: Vec, - serum3_infos: Vec, - perp_infos: Vec, - being_liquidated: bool, -} - -impl HealthCache { - pub fn health(&self, health_type: HealthType) -> I80F48 { - let mut health = I80F48::ZERO; - let sum = |contrib| { - cm!(health += contrib); - }; - self.health_sum(health_type, sum); - health - } - - pub fn check_health_pre(&self, account: &mut MangoAccountFixed) -> Result { - let pre_health = self.health(HealthType::Init); - msg!("pre_health: {}", pre_health); - account.maybe_recover_from_being_liquidated(pre_health); - require!(!account.being_liquidated(), MangoError::BeingLiquidated); - Ok(pre_health) - } - - pub fn check_health_post( - &self, - account: &mut MangoAccountFixed, - pre_health: I80F48, - ) -> Result<()> { - let post_health = self.health(HealthType::Init); - msg!("post_health: {}", post_health); - require!( - post_health >= 0 || post_health > pre_health, - MangoError::HealthMustBePositiveOrIncrease - ); - account.maybe_recover_from_being_liquidated(post_health); - Ok(()) - } - - pub fn token_info(&self, token_index: TokenIndex) -> Result<&TokenInfo> { - Ok(&self.token_infos[self.token_info_index(token_index)?]) - } - - fn token_info_index(&self, token_index: TokenIndex) -> Result { - self.token_infos - .iter() - .position(|t| t.token_index == token_index) - .ok_or_else(|| error_msg!("token index {} not found", token_index)) - } - - #[cfg(feature = "client")] - fn perp_info_index(&self, perp_market_index: PerpMarketIndex) -> Result { - self.perp_infos - .iter() - .position(|pi| pi.perp_market_index == perp_market_index) - .ok_or_else(|| error_msg!("perp market index {} not found", perp_market_index)) - } - - pub fn adjust_token_balance(&mut self, token_index: TokenIndex, change: I80F48) -> Result<()> { - let entry_index = self.token_info_index(token_index)?; - let mut entry = &mut self.token_infos[entry_index]; - - // Work around the fact that -((-x) * y) == x * y does not hold for I80F48: - // We need to make sure that if balance is before * price, then change = -before - // brings it to exactly zero. - let removed_contribution = -change; - cm!(entry.balance_native -= removed_contribution); - Ok(()) - } - - pub fn adjust_serum3_reserved( - &mut self, - market_index: Serum3MarketIndex, - base_token_index: TokenIndex, - reserved_base_change: I80F48, - free_base_change: I80F48, - quote_token_index: TokenIndex, - reserved_quote_change: I80F48, - free_quote_change: I80F48, - ) -> Result<()> { - let base_entry_index = self.token_info_index(base_token_index)?; - let quote_entry_index = self.token_info_index(quote_token_index)?; - - // Apply it to the tokens - { - let base_entry = &mut self.token_infos[base_entry_index]; - cm!(base_entry.balance_native += free_base_change); - } - { - let quote_entry = &mut self.token_infos[quote_entry_index]; - cm!(quote_entry.balance_native += free_quote_change); - } - - // Apply it to the serum3 info - let market_entry = self - .serum3_infos - .iter_mut() - .find(|m| m.market_index == market_index) - .ok_or_else(|| error_msg!("serum3 market {} not found", market_index))?; - cm!(market_entry.reserved_base += reserved_base_change); - cm!(market_entry.reserved_quote += reserved_quote_change); - Ok(()) - } - - pub fn recompute_perp_info( - &mut self, - perp_position: &PerpPosition, - perp_market: &PerpMarket, - ) -> Result<()> { - let perp_entry = self - .perp_infos - .iter_mut() - .find(|m| m.perp_market_index == perp_market.perp_market_index) - .ok_or_else(|| error_msg!("perp market {} not found", perp_market.perp_market_index))?; - *perp_entry = PerpInfo::new(perp_position, perp_market, perp_entry.prices.clone())?; - Ok(()) - } - - pub fn has_liquidatable_assets(&self) -> bool { - let spot_liquidatable = self.token_infos.iter().any(|ti| { - // can use token_liq_with_token - ti.balance_native.is_positive() - }); - // can use serum3_liq_force_cancel_orders - let serum3_cancelable = self - .serum3_infos - .iter() - .any(|si| si.reserved_base != 0 || si.reserved_quote != 0); - let perp_liquidatable = self.perp_infos.iter().any(|p| { - // can use perp_liq_base_position - p.base_lots != 0 - // can use perp_settle_pnl - || p.quote > ONE_NATIVE_USDC_IN_USD // TODO: we're not guaranteed to be able to settle positive perp pnl! - // can use perp_liq_force_cancel_orders - || p.has_open_orders - }); - spot_liquidatable || serum3_cancelable || perp_liquidatable - } - - pub fn has_spot_borrows(&self) -> bool { - self.token_infos - .iter() - .any(|ti| ti.balance_native.is_negative()) - } - - pub fn has_borrows(&self) -> bool { - let perp_borrows = self - .perp_infos - .iter() - .any(|p| p.quote.is_negative() || p.base_lots != 0); - self.has_spot_borrows() || perp_borrows - } - - #[cfg(feature = "client")] - pub fn can_call_spot_bankruptcy(&self) -> bool { - !self.has_liquidatable_assets() && self.has_spot_borrows() - } - - #[cfg(feature = "client")] - pub fn is_liquidatable(&self) -> bool { - if self.being_liquidated { - self.health(HealthType::Init).is_negative() - } else { - self.health(HealthType::Maint).is_negative() - } - } - - fn compute_serum3_reservations( - &self, - health_type: HealthType, - ) -> (Vec, Vec) { - // For each token, compute the sum of serum-reserved amounts over all markets. - let mut token_max_reserved = vec![I80F48::ZERO; self.token_infos.len()]; - - // For each serum market, compute what happened if reserved_base was converted to quote - // or reserved_quote was converted to base. - let mut serum3_reserved = Vec::with_capacity(self.serum3_infos.len()); - - for info in self.serum3_infos.iter() { - let quote = &self.token_infos[info.quote_index]; - let base = &self.token_infos[info.base_index]; - - let reserved_base = info.reserved_base; - let reserved_quote = info.reserved_quote; - - let quote_asset = quote.prices.asset(health_type); - let base_liab = base.prices.liab(health_type); - // OPTIMIZATION: These divisions can be extremely expensive (up to 5k CU each) - let all_reserved_as_base = - cm!(reserved_base + reserved_quote * quote_asset / base_liab); - - let base_asset = base.prices.asset(health_type); - let quote_liab = quote.prices.liab(health_type); - let all_reserved_as_quote = - cm!(reserved_quote + reserved_base * base_asset / quote_liab); - - let base_max_reserved = &mut token_max_reserved[info.base_index]; - // note: cm!() does not work with mutable references - *base_max_reserved = base_max_reserved.checked_add(all_reserved_as_base).unwrap(); - let quote_max_reserved = &mut token_max_reserved[info.quote_index]; - *quote_max_reserved = quote_max_reserved - .checked_add(all_reserved_as_quote) - .unwrap(); - - serum3_reserved.push(Serum3Reserved { - all_reserved_as_base, - all_reserved_as_quote, - }); - } - - (token_max_reserved, serum3_reserved) - } - - fn health_sum(&self, health_type: HealthType, mut action: impl FnMut(I80F48)) { - for token_info in self.token_infos.iter() { - let contrib = token_info.health_contribution(health_type); - action(contrib); - } - - let (token_max_reserved, serum3_reserved) = self.compute_serum3_reservations(health_type); - for (serum3_info, reserved) in self.serum3_infos.iter().zip(serum3_reserved.iter()) { - let contrib = serum3_info.health_contribution( - health_type, - &self.token_infos, - &token_max_reserved, - reserved, - ); - action(contrib); - } - - for perp_info in self.perp_infos.iter() { - let contrib = perp_info.health_contribution(health_type); - action(contrib); - } - } - - /// Compute the health when it comes to settling perp pnl - /// - /// Examples: - /// - An account may have maint_health < 0, but settling perp pnl could still be allowed. - /// (+100 USDC health, -50 USDT health, -50 perp health -> allow settling 50 health worth) - /// - Positive health from trusted pnl markets counts - /// - If overall health is 0 with two trusted perp pnl < 0, settling may still be possible. - /// (+100 USDC health, -150 perp1 health, -150 perp2 health -> allow settling 100 health worth) - /// - Positive trusted perp pnl can enable settling. - /// (+100 trusted perp1 health, -100 perp2 health -> allow settling of 100 health worth) - pub fn perp_settle_health(&self) -> I80F48 { - let health_type = HealthType::Maint; - let mut health = I80F48::ZERO; - for token_info in self.token_infos.iter() { - let contrib = token_info.health_contribution(health_type); - cm!(health += contrib); - } - - let (token_max_reserved, serum3_reserved) = self.compute_serum3_reservations(health_type); - for (serum3_info, reserved) in self.serum3_infos.iter().zip(serum3_reserved.iter()) { - let contrib = serum3_info.health_contribution( - health_type, - &self.token_infos, - &token_max_reserved, - reserved, - ); - cm!(health += contrib); - } - - for perp_info in self.perp_infos.iter() { - if perp_info.trusted_market { - let positive_contrib = perp_info.health_contribution(health_type).max(I80F48::ZERO); - cm!(health += positive_contrib); - } - } - health - } - - /// Sum of only the positive health components (assets) and - /// sum of absolute values of all negative health components (liabs, always >= 0) - pub fn health_assets_and_liabs(&self, health_type: HealthType) -> (I80F48, I80F48) { - let mut assets = I80F48::ZERO; - let mut liabs = I80F48::ZERO; - let sum = |contrib| { - if contrib > 0 { - cm!(assets += contrib); - } else { - cm!(liabs -= contrib); - } - }; - self.health_sum(health_type, sum); - (assets, liabs) - } - - /// The health ratio is - /// - 0 if health is 0 - meaning assets = liabs - /// - 100 if there's 2x as many assets as liabs - /// - 200 if there's 3x as many assets as liabs - /// - MAX if liabs = 0 - /// - /// Maybe talking about the collateralization ratio assets/liabs is more intuitive? - #[cfg(feature = "client")] - pub fn health_ratio(&self, health_type: HealthType) -> I80F48 { - let (assets, liabs) = self.health_assets_and_liabs(health_type); - let hundred = I80F48::from(100); - if liabs > 0 { - // feel free to saturate to MAX for tiny liabs - cm!(hundred * (assets - liabs)).saturating_div(liabs) - } else { - I80F48::MAX - } - } - - /// How much source native tokens may be swapped for target tokens while staying - /// above the min_ratio health ratio. - /// - /// `price`: The amount of target native you receive for one source native. So if we - /// swap BTC -> SOL and they're at ui prices of $20000 and $40, that means price - /// should be 500000 native_SOL for a native_BTC. Because 1 BTC gives you 500 SOL - /// so 1e6 native_BTC gives you 500e9 native_SOL. - /// - /// NOTE: keep getMaxSourceForTokenSwap in ts/client in sync with changes here - #[cfg(feature = "client")] - pub fn max_swap_source_for_health_ratio( - &self, - source: TokenIndex, - target: TokenIndex, - price: I80F48, - min_ratio: I80F48, - ) -> Result { - // The health_ratio is nonlinear based on swap amount. - // For large swap amounts the slope is guaranteed to be negative (unless the price - // is extremely good), but small amounts can have positive slope (e.g. using - // source deposits to pay back target borrows). - // - // That means: - // - even if the initial ratio is < min_ratio it can be useful to swap to *increase* health - // - be careful about finding the min_ratio point: the function isn't convex - - let health_type = HealthType::Init; - let initial_ratio = self.health_ratio(health_type); - if initial_ratio < 0 { - return Ok(I80F48::ZERO); - } - - let source_index = find_token_info_index(&self.token_infos, source)?; - let target_index = find_token_info_index(&self.token_infos, target)?; - let source = &self.token_infos[source_index]; - let target = &self.token_infos[target_index]; - - // If the price is sufficiently good, then health will just increase from swapping: - // once we've swapped enough, swapping x reduces health by x * source_liab_weight and - // increases it by x * target_asset_weight * price_factor. - let final_health_slope = -source.init_liab_weight * source.prices.liab(health_type) - + target.init_asset_weight * target.prices.asset(health_type) * price; - if final_health_slope >= 0 { - return Ok(I80F48::MAX); - } - - let cache_after_swap = |amount: I80F48| { - let mut adjusted_cache = self.clone(); - adjusted_cache.token_infos[source_index].balance_native -= amount; - adjusted_cache.token_infos[target_index].balance_native += cm!(amount * price); - adjusted_cache - }; - let health_ratio_after_swap = - |amount| cache_after_swap(amount).health_ratio(HealthType::Init); - - // There are two key slope changes: Assume source.balance > 0 and target.balance < 0. - // When these values flip sign, the health slope decreases, but could still be positive. - // After point1 it's definitely negative (due to final_health_slope check above). - // The maximum health ratio will be at 0 or at one of these points (ignoring serum3 effects). - let source_for_zero_target_balance = -target.balance_native / price; - let point0_amount = source - .balance_native - .min(source_for_zero_target_balance) - .max(I80F48::ZERO); - let point1_amount = source - .balance_native - .max(source_for_zero_target_balance) - .max(I80F48::ZERO); - let point0_ratio = health_ratio_after_swap(point0_amount); - let (point1_ratio, point1_health) = { - let cache = cache_after_swap(point1_amount); - ( - cache.health_ratio(HealthType::Init), - cache.health(HealthType::Init), - ) - }; - - let amount = - if initial_ratio <= min_ratio && point0_ratio < min_ratio && point1_ratio < min_ratio { - // If we have to stay below the target ratio, pick the highest one - if point0_ratio > initial_ratio { - if point1_ratio > point0_ratio { - point1_amount - } else { - point0_amount - } - } else if point1_ratio > initial_ratio { - point1_amount - } else { - I80F48::ZERO - } - } else if point1_ratio >= min_ratio { - // If point1_ratio is still bigger than min_ratio, the target amount must be >point1_amount - // search to the right of point1_amount: but how far? - // At point1, source.balance < 0 and target.balance > 0, so use a simple estimation for - // zero health: health - // - source_liab_weight * source_liab_price * a - // + target_asset_weight * target_asset_price * price * a = 0. - // where a is the source token native amount. - if point1_health <= 0 { - return Ok(I80F48::ZERO); - } - let zero_health_amount = point1_amount - point1_health / final_health_slope; - let zero_health_ratio = health_ratio_after_swap(zero_health_amount); - binary_search( - point1_amount, - point1_ratio, - zero_health_amount, - zero_health_ratio, - min_ratio, - I80F48::ZERO, - health_ratio_after_swap, - )? - } else if point0_ratio >= min_ratio { - // Must be between point0_amount and point1_amount. - binary_search( - point0_amount, - point0_ratio, - point1_amount, - point1_ratio, - min_ratio, - I80F48::ZERO, - health_ratio_after_swap, - )? - } else { - // Must be between 0 and point0_amount - binary_search( - I80F48::ZERO, - initial_ratio, - point0_amount, - point0_ratio, - min_ratio, - I80F48::ZERO, - health_ratio_after_swap, - )? - }; - - Ok(amount) - } - - #[cfg(feature = "client")] - /// NOTE: keep getMaxSourceForTokenSwap in ts/client in sync with changes here - pub fn max_perp_for_health_ratio( - &self, - perp_market_index: PerpMarketIndex, - price: I80F48, - side: PerpOrderSide, - min_ratio: I80F48, - ) -> Result { - let health_type = HealthType::Init; - let initial_ratio = self.health_ratio(health_type); - if initial_ratio < 0 { - return Ok(0); - } - - let direction = match side { - PerpOrderSide::Bid => 1, - PerpOrderSide::Ask => -1, - }; - - let perp_info_index = self.perp_info_index(perp_market_index)?; - let perp_info = &self.perp_infos[perp_info_index]; - let prices = &perp_info.prices; - let base_lot_size = I80F48::from(perp_info.base_lot_size); - - // If the price is sufficiently good then health will just increase from trading - // TODO: This is not actually correct, since perp health for untrusted markets can't go above 0 - let final_health_slope = if direction == 1 { - perp_info.init_asset_weight * prices.asset(health_type) - price - } else { - price - perp_info.init_liab_weight * prices.liab(health_type) - }; - if final_health_slope >= 0 { - return Ok(i64::MAX); - } - - let cache_after_trade = |base_lots: i64| { - let mut adjusted_cache = self.clone(); - adjusted_cache.perp_infos[perp_info_index].base_lots += direction * base_lots; - adjusted_cache.perp_infos[perp_info_index].quote -= - I80F48::from(direction) * I80F48::from(base_lots) * base_lot_size * price; - adjusted_cache - }; - let health_ratio_after_trade = - |base_lots: i64| cache_after_trade(base_lots).health_ratio(HealthType::Init); - let health_ratio_after_trade_trunc = - |base_lots: I80F48| health_ratio_after_trade(base_lots.round_to_zero().to_num()); - - let initial_base_lots = perp_info.base_lots; - - // There are two cases: - // 1. We are increasing abs(base_lots) - // 2. We are bringing the base position to 0, and then going to case 1. - let has_case2 = - initial_base_lots > 0 && direction == -1 || initial_base_lots < 0 && direction == 1; - - let (case1_start, case1_start_ratio) = if has_case2 { - let case1_start = initial_base_lots.abs(); - let case1_start_ratio = health_ratio_after_trade(case1_start); - (case1_start, case1_start_ratio) - } else { - (0, initial_ratio) - }; - let case1_start_i80f48 = I80F48::from(case1_start); - - // If we start out below min_ratio and can't go above, pick the best case - let base_lots = if initial_ratio <= min_ratio && case1_start_ratio < min_ratio { - if case1_start_ratio >= initial_ratio { - case1_start_i80f48 - } else { - I80F48::ZERO - } - } else if case1_start_ratio >= min_ratio { - // Must reach min_ratio to the right of case1_start - let case1_start_health = cache_after_trade(case1_start).health(HealthType::Init); - if case1_start_health <= 0 { - return Ok(0); - } - // We add 1 here because health is computed for truncated base_lots and we want to guarantee - // zero_health_ratio <= 0. - let zero_health_amount = case1_start_i80f48 - - case1_start_health / final_health_slope / base_lot_size - + I80F48::ONE; - let zero_health_ratio = health_ratio_after_trade_trunc(zero_health_amount); - - binary_search( - case1_start_i80f48, - case1_start_ratio, - zero_health_amount, - zero_health_ratio, - min_ratio, - I80F48::ONE, - health_ratio_after_trade_trunc, - )? - } else { - // Between 0 and case1_start - binary_search( - I80F48::ZERO, - initial_ratio, - case1_start_i80f48, - case1_start_ratio, - min_ratio, - I80F48::ONE, - health_ratio_after_trade_trunc, - )? - }; - - Ok(base_lots.round_to_zero().to_num()) - } -} - -#[cfg(feature = "client")] -fn binary_search( - mut left: I80F48, - left_value: I80F48, - mut right: I80F48, - right_value: I80F48, - target_value: I80F48, - min_step: I80F48, - fun: impl Fn(I80F48) -> I80F48, -) -> Result { - let max_iterations = 20; - let target_error = I80F48!(0.1); - require_msg!( - (left_value - target_value).signum() * (right_value - target_value).signum() != I80F48::ONE, - "internal error: left {} and right {} don't contain the target value {}", - left_value, - right_value, - target_value - ); - for _ in 0..max_iterations { - if (right - left).abs() < min_step { - return Ok(left); - } - let new = I80F48::from_num(0.5) * (left + right); - let new_value = fun(new); - println!("l {} r {} v {}", left, right, new_value); - let error = new_value - target_value; - if error > 0 && error < target_error { - return Ok(new); - } - - if (new_value > target_value) ^ (right_value > target_value) { - left = new; - } else { - right = new; - } - } - Err(error_msg!("binary search iterations exhausted")) -} - -fn find_token_info_index(infos: &[TokenInfo], token_index: TokenIndex) -> Result { - infos - .iter() - .position(|ti| ti.token_index == token_index) - .ok_or_else(|| error_msg!("token index {} not found", token_index)) -} - -/// Generate a HealthCache for an account and its health accounts. -pub fn new_health_cache( - account: &MangoAccountRef, - retriever: &impl AccountRetriever, -) -> Result { - // token contribution from token accounts - let mut token_infos = vec![]; - - for (i, position) in account.active_token_positions().enumerate() { - let (bank, oracle_price) = - retriever.bank_and_oracle(&account.fixed.group, i, position.token_index)?; - - let native = position.native(bank); - token_infos.push(TokenInfo { - token_index: bank.token_index, - maint_asset_weight: bank.maint_asset_weight, - init_asset_weight: bank.init_asset_weight, - maint_liab_weight: bank.maint_liab_weight, - init_liab_weight: bank.init_liab_weight, - prices: Prices { - oracle: oracle_price, - stable: bank.stable_price(), - }, - balance_native: native, - }); - } - - // Fill the TokenInfo balance with free funds in serum3 oo accounts and build Serum3Infos. - let mut serum3_infos = vec![]; - for (i, serum_account) in account.active_serum3_orders().enumerate() { - let oo = retriever.serum_oo(i, &serum_account.open_orders)?; - - // find the TokenInfos for the market's base and quote tokens - let base_index = find_token_info_index(&token_infos, serum_account.base_token_index)?; - let quote_index = find_token_info_index(&token_infos, serum_account.quote_token_index)?; - - // add the amounts that are freely settleable immediately to token balances - let base_free = I80F48::from(oo.native_coin_free); - let quote_free = I80F48::from(cm!(oo.native_pc_free + oo.referrer_rebates_accrued)); - let base_info = &mut token_infos[base_index]; - cm!(base_info.balance_native += base_free); - let quote_info = &mut token_infos[quote_index]; - cm!(quote_info.balance_native += quote_free); - - // track the reserved amounts - let reserved_base = I80F48::from(cm!(oo.native_coin_total - oo.native_coin_free)); - let reserved_quote = I80F48::from(cm!(oo.native_pc_total - oo.native_pc_free)); - - serum3_infos.push(Serum3Info { - reserved_base, - reserved_quote, - base_index, - quote_index, - market_index: serum_account.market_index, - }); - } - - // health contribution from perp accounts - let mut perp_infos = Vec::with_capacity(account.active_perp_positions().count()); - for (i, perp_position) in account.active_perp_positions().enumerate() { - let (perp_market, oracle_price) = retriever.perp_market_and_oracle_price( - &account.fixed.group, - i, - perp_position.market_index, - )?; - perp_infos.push(PerpInfo::new( - perp_position, - perp_market, - Prices { - oracle: oracle_price, - stable: perp_market.stable_price(), - }, - )?); - } - - Ok(HealthCache { - token_infos, - serum3_infos, - perp_infos, - being_liquidated: account.fixed.being_liquidated(), - }) -} - -#[cfg(test)] -mod tests { - use super::*; - use crate::state::oracle::StubOracle; - use crate::state::{MangoAccount, MangoAccountValue}; - use std::cell::RefCell; - use std::convert::identity; - use std::mem::size_of; - use std::rc::Rc; - use std::str::FromStr; - - pub const DUMMY_NOW_TS: u64 = 0; - - #[test] - fn test_precision() { - // I80F48 can only represent until 1/2^48 - assert_ne!( - I80F48::from_num(1_u128) / I80F48::from_num(2_u128.pow(48)), - 0 - ); - assert_eq!( - I80F48::from_num(1_u128) / I80F48::from_num(2_u128.pow(49)), - 0 - ); - - // I80F48 can only represent until 14 decimal points - assert_ne!( - I80F48::from_str(format!("0.{}1", "0".repeat(13)).as_str()).unwrap(), - 0 - ); - assert_eq!( - I80F48::from_str(format!("0.{}1", "0".repeat(14)).as_str()).unwrap(), - 0 - ); - } - - // Implementing TestAccount directly for ZeroCopy + Owner leads to a conflict - // because OpenOrders may add impls for those in the future. - trait MyZeroCopy: anchor_lang::ZeroCopy + Owner {} - impl MyZeroCopy for StubOracle {} - impl MyZeroCopy for Bank {} - impl MyZeroCopy for PerpMarket {} - - struct TestAccount { - bytes: Vec, - pubkey: Pubkey, - owner: Pubkey, - lamports: u64, - _phantom: std::marker::PhantomData, - } - - impl TestAccount { - fn new(bytes: Vec, owner: Pubkey) -> Self { - Self { - bytes, - owner, - pubkey: Pubkey::new_unique(), - lamports: 0, - _phantom: std::marker::PhantomData, - } - } - - fn as_account_info(&mut self) -> AccountInfo { - AccountInfo { - key: &self.pubkey, - owner: &self.owner, - lamports: Rc::new(RefCell::new(&mut self.lamports)), - data: Rc::new(RefCell::new(&mut self.bytes)), - is_signer: false, - is_writable: false, - executable: false, - rent_epoch: 0, - } - } - } - - impl TestAccount { - fn new_zeroed() -> Self { - let mut bytes = vec![0u8; 8 + size_of::()]; - bytes[0..8].copy_from_slice(&T::discriminator()); - Self::new(bytes, T::owner()) - } - - fn data(&mut self) -> &mut T { - bytemuck::from_bytes_mut(&mut self.bytes[8..]) - } - } - - impl TestAccount { - fn new_zeroed() -> Self { - let mut bytes = vec![0u8; 12 + size_of::()]; - bytes[0..5].copy_from_slice(b"serum"); - Self::new(bytes, Pubkey::new_unique()) - } - - fn data(&mut self) -> &mut OpenOrders { - bytemuck::from_bytes_mut(&mut self.bytes[5..5 + size_of::()]) - } - } - - fn mock_bank_and_oracle( - group: Pubkey, - token_index: TokenIndex, - price: f64, - init_weights: f64, - maint_weights: f64, - ) -> (TestAccount, TestAccount) { - let mut oracle = TestAccount::::new_zeroed(); - oracle.data().price = I80F48::from_num(price); - let mut bank = TestAccount::::new_zeroed(); - bank.data().token_index = token_index; - bank.data().group = group; - bank.data().oracle = oracle.pubkey; - bank.data().deposit_index = I80F48::from(1_000_000); - bank.data().borrow_index = I80F48::from(1_000_000); - bank.data().init_asset_weight = I80F48::from_num(1.0 - init_weights); - bank.data().init_liab_weight = I80F48::from_num(1.0 + init_weights); - bank.data().maint_asset_weight = I80F48::from_num(1.0 - maint_weights); - bank.data().maint_liab_weight = I80F48::from_num(1.0 + maint_weights); - bank.data().stable_price_model.reset_to_price(price, 0); - bank.data().net_borrows_window_size_ts = 1; // dummy - bank.data().net_borrows_limit_native = i64::MAX; // max since we don't want this to interfere - (bank, oracle) - } - - fn mock_perp_market( - group: Pubkey, - oracle: Pubkey, - price: f64, - market_index: PerpMarketIndex, - init_weights: f64, - maint_weights: f64, - ) -> TestAccount { - let mut pm = TestAccount::::new_zeroed(); - pm.data().group = group; - pm.data().oracle = oracle; - pm.data().perp_market_index = market_index; - pm.data().init_asset_weight = I80F48::from_num(1.0 - init_weights); - pm.data().init_liab_weight = I80F48::from_num(1.0 + init_weights); - pm.data().maint_asset_weight = I80F48::from_num(1.0 - maint_weights); - pm.data().maint_liab_weight = I80F48::from_num(1.0 + maint_weights); - pm.data().quote_lot_size = 100; - pm.data().base_lot_size = 10; - pm.data().stable_price_model.reset_to_price(price, 0); - pm - } - - fn health_eq(a: I80F48, b: f64) -> bool { - if (a - I80F48::from_num(b)).abs() < 0.001 { - true - } else { - println!("health is {}, but expected {}", a, b); - false - } - } - - // Run a health test that includes all the side values (like referrer_rebates_accrued) - #[test] - fn test_health0() { - let buffer = MangoAccount::default_for_tests().try_to_vec().unwrap(); - let mut account = MangoAccountValue::from_bytes(&buffer).unwrap(); - - let group = Pubkey::new_unique(); - - let (mut bank1, mut oracle1) = mock_bank_and_oracle(group, 1, 1.0, 0.2, 0.1); - let (mut bank2, mut oracle2) = mock_bank_and_oracle(group, 4, 5.0, 0.5, 0.3); - bank1 - .data() - .deposit( - account.ensure_token_position(1).unwrap().0, - I80F48::from(100), - DUMMY_NOW_TS, - ) - .unwrap(); - bank2 - .data() - .withdraw_without_fee( - account.ensure_token_position(4).unwrap().0, - I80F48::from(10), - DUMMY_NOW_TS, - ) - .unwrap(); - - let mut oo1 = TestAccount::::new_zeroed(); - let serum3account = account.create_serum3_orders(2).unwrap(); - serum3account.open_orders = oo1.pubkey; - serum3account.base_token_index = 4; - serum3account.quote_token_index = 1; - oo1.data().native_pc_total = 21; - oo1.data().native_coin_total = 18; - oo1.data().native_pc_free = 1; - oo1.data().native_coin_free = 3; - oo1.data().referrer_rebates_accrued = 2; - - let mut perp1 = mock_perp_market(group, oracle2.pubkey, 5.0, 9, 0.2, 0.1); - let perpaccount = account.ensure_perp_position(9, 1).unwrap().0; - perpaccount.record_trade(perp1.data(), 3, -I80F48::from(310u16)); - perpaccount.bids_base_lots = 7; - perpaccount.asks_base_lots = 11; - perpaccount.taker_base_lots = 1; - perpaccount.taker_quote_lots = 2; - - let oracle2_ai = oracle2.as_account_info(); - - let ais = vec![ - bank1.as_account_info(), - bank2.as_account_info(), - oracle1.as_account_info(), - oracle2_ai.clone(), - perp1.as_account_info(), - oracle2_ai, - oo1.as_account_info(), - ]; - - let retriever = ScanningAccountRetriever::new_with_staleness(&ais, &group, None).unwrap(); - - // for bank1/oracle1, including open orders (scenario: bids execute) - let health1 = (100.0 + 1.0 + 2.0 + (20.0 + 15.0 * 5.0)) * 0.8; - // for bank2/oracle2 - let health2 = (-10.0 + 3.0) * 5.0 * 1.5; - // for perp (scenario: bids execute) - let health3 = - (3.0 + 7.0 + 1.0) * 10.0 * 5.0 * 0.8 + (-310.0 + 2.0 * 100.0 - 7.0 * 10.0 * 5.0); - assert!(health_eq( - compute_health(&account.borrow(), HealthType::Init, &retriever).unwrap(), - health1 + health2 + health3 - )); - } - - #[test] - fn test_scanning_account_retriever() { - let oracle1_price = 1.0; - let oracle2_price = 5.0; - let group = Pubkey::new_unique(); - - let (mut bank1, mut oracle1) = mock_bank_and_oracle(group, 1, oracle1_price, 0.2, 0.1); - let (mut bank2, mut oracle2) = mock_bank_and_oracle(group, 4, oracle2_price, 0.5, 0.3); - let (mut bank3, _) = mock_bank_and_oracle(group, 5, 1.0, 0.5, 0.3); - - // bank3 reuses the bank2 oracle, to ensure the ScanningAccountRetriever doesn't choke on that - bank3.data().oracle = oracle2.pubkey; - - let mut oo1 = TestAccount::::new_zeroed(); - let oo1key = oo1.pubkey; - oo1.data().native_pc_total = 20; - - let mut perp1 = mock_perp_market(group, oracle2.pubkey, oracle2_price, 9, 0.2, 0.1); - let mut perp2 = mock_perp_market(group, oracle1.pubkey, oracle1_price, 8, 0.2, 0.1); - - let oracle1_account_info = oracle1.as_account_info(); - let oracle2_account_info = oracle2.as_account_info(); - let ais = vec![ - bank1.as_account_info(), - bank2.as_account_info(), - bank3.as_account_info(), - oracle1_account_info.clone(), - oracle2_account_info.clone(), - oracle2_account_info.clone(), - perp1.as_account_info(), - perp2.as_account_info(), - oracle2_account_info, - oracle1_account_info, - oo1.as_account_info(), - ]; - - let mut retriever = - ScanningAccountRetriever::new_with_staleness(&ais, &group, None).unwrap(); - - assert_eq!(retriever.banks.len(), 3); - assert_eq!(retriever.token_index_map.len(), 3); - assert_eq!(retriever.oracles.len(), 3); - assert_eq!(retriever.perp_markets.len(), 2); - assert_eq!(retriever.perp_oracles.len(), 2); - assert_eq!(retriever.perp_index_map.len(), 2); - assert_eq!(retriever.serum3_oos.len(), 1); - - { - let (b1, o1, opt_b2o2) = retriever.banks_mut_and_oracles(1, 4).unwrap(); - let (b2, o2) = opt_b2o2.unwrap(); - assert_eq!(b1.token_index, 1); - assert_eq!(o1, I80F48::ONE); - assert_eq!(b2.token_index, 4); - assert_eq!(o2, 5 * I80F48::ONE); - } - - { - let (b1, o1, opt_b2o2) = retriever.banks_mut_and_oracles(4, 1).unwrap(); - let (b2, o2) = opt_b2o2.unwrap(); - assert_eq!(b1.token_index, 4); - assert_eq!(o1, 5 * I80F48::ONE); - assert_eq!(b2.token_index, 1); - assert_eq!(o2, I80F48::ONE); - } - - { - let (b1, o1, opt_b2o2) = retriever.banks_mut_and_oracles(4, 4).unwrap(); - assert!(opt_b2o2.is_none()); - assert_eq!(b1.token_index, 4); - assert_eq!(o1, 5 * I80F48::ONE); - } - - retriever.banks_mut_and_oracles(4, 2).unwrap_err(); - - { - let (b, o) = retriever.scanned_bank_and_oracle(5).unwrap(); - assert_eq!(b.token_index, 5); - assert_eq!(o, 5 * I80F48::ONE); - } - - let oo = retriever.serum_oo(0, &oo1key).unwrap(); - assert_eq!(identity(oo.native_pc_total), 20); - - assert!(retriever.serum_oo(1, &Pubkey::default()).is_err()); - - let (perp, oracle_price) = retriever - .perp_market_and_oracle_price(&group, 0, 9) - .unwrap(); - assert_eq!(identity(perp.perp_market_index), 9); - assert_eq!(oracle_price, oracle2_price); - - let (perp, oracle_price) = retriever - .perp_market_and_oracle_price(&group, 1, 8) - .unwrap(); - assert_eq!(identity(perp.perp_market_index), 8); - assert_eq!(oracle_price, oracle1_price); - - assert!(retriever - .perp_market_and_oracle_price(&group, 1, 5) - .is_err()); - } - - #[derive(Default)] - struct TestHealth1Case { - token1: i64, - token2: i64, - token3: i64, - oo_1_2: (u64, u64), - oo_1_3: (u64, u64), - perp1: (i64, i64, i64, i64), - expected_health: f64, - } - fn test_health1_runner(testcase: &TestHealth1Case) { - let buffer = MangoAccount::default_for_tests().try_to_vec().unwrap(); - let mut account = MangoAccountValue::from_bytes(&buffer).unwrap(); - - let group = Pubkey::new_unique(); - - let (mut bank1, mut oracle1) = mock_bank_and_oracle(group, 1, 1.0, 0.2, 0.1); - let (mut bank2, mut oracle2) = mock_bank_and_oracle(group, 4, 5.0, 0.5, 0.3); - let (mut bank3, mut oracle3) = mock_bank_and_oracle(group, 5, 10.0, 0.5, 0.3); - bank1 - .data() - .change_without_fee( - account.ensure_token_position(1).unwrap().0, - I80F48::from(testcase.token1), - DUMMY_NOW_TS, - ) - .unwrap(); - bank2 - .data() - .change_without_fee( - account.ensure_token_position(4).unwrap().0, - I80F48::from(testcase.token2), - DUMMY_NOW_TS, - ) - .unwrap(); - bank3 - .data() - .change_without_fee( - account.ensure_token_position(5).unwrap().0, - I80F48::from(testcase.token3), - DUMMY_NOW_TS, - ) - .unwrap(); - - let mut oo1 = TestAccount::::new_zeroed(); - let serum3account1 = account.create_serum3_orders(2).unwrap(); - serum3account1.open_orders = oo1.pubkey; - serum3account1.base_token_index = 4; - serum3account1.quote_token_index = 1; - oo1.data().native_pc_total = testcase.oo_1_2.0; - oo1.data().native_coin_total = testcase.oo_1_2.1; - - let mut oo2 = TestAccount::::new_zeroed(); - let serum3account2 = account.create_serum3_orders(3).unwrap(); - serum3account2.open_orders = oo2.pubkey; - serum3account2.base_token_index = 5; - serum3account2.quote_token_index = 1; - oo2.data().native_pc_total = testcase.oo_1_3.0; - oo2.data().native_coin_total = testcase.oo_1_3.1; - - let mut perp1 = mock_perp_market(group, oracle2.pubkey, 5.0, 9, 0.2, 0.1); - let perpaccount = account.ensure_perp_position(9, 1).unwrap().0; - perpaccount.record_trade( - perp1.data(), - testcase.perp1.0, - I80F48::from(testcase.perp1.1), - ); - perpaccount.bids_base_lots = testcase.perp1.2; - perpaccount.asks_base_lots = testcase.perp1.3; - - let oracle2_ai = oracle2.as_account_info(); - let ais = vec![ - bank1.as_account_info(), - bank2.as_account_info(), - bank3.as_account_info(), - oracle1.as_account_info(), - oracle2_ai.clone(), - oracle3.as_account_info(), - perp1.as_account_info(), - oracle2_ai, - oo1.as_account_info(), - oo2.as_account_info(), - ]; - - let retriever = ScanningAccountRetriever::new_with_staleness(&ais, &group, None).unwrap(); - - assert!(health_eq( - compute_health(&account.borrow(), HealthType::Init, &retriever).unwrap(), - testcase.expected_health - )); - } - - // Check some specific health constellations - #[test] - fn test_health1() { - let base_price = 5.0; - let base_lots_to_quote = 10.0 * base_price; - let testcases = vec![ - TestHealth1Case { // 0 - token1: 100, - token2: -10, - oo_1_2: (20, 15), - perp1: (3, -131, 7, 11), - expected_health: - // for token1, including open orders (scenario: bids execute) - (100.0 + (20.0 + 15.0 * base_price)) * 0.8 - // for token2 - - 10.0 * base_price * 1.5 - // for perp (scenario: bids execute) - + (3.0 + 7.0) * base_lots_to_quote * 0.8 + (-131.0 - 7.0 * base_lots_to_quote), - ..Default::default() - }, - TestHealth1Case { // 1 - token1: -100, - token2: 10, - oo_1_2: (20, 15), - perp1: (-10, -131, 7, 11), - expected_health: - // for token1 - -100.0 * 1.2 - // for token2, including open orders (scenario: asks execute) - + (10.0 * base_price + (20.0 + 15.0 * base_price)) * 0.5 - // for perp (scenario: asks execute) - + (-10.0 - 11.0) * base_lots_to_quote * 1.2 + (-131.0 + 11.0 * base_lots_to_quote), - ..Default::default() - }, - TestHealth1Case { - // 2 - perp1: (-1, 100, 0, 0), - expected_health: 0.0, - ..Default::default() - }, - TestHealth1Case { - // 3 - perp1: (1, -100, 0, 0), - expected_health: -100.0 + 0.8 * 1.0 * base_lots_to_quote, - ..Default::default() - }, - TestHealth1Case { - // 4 - perp1: (10, 100, 0, 0), - expected_health: 0.0, - ..Default::default() - }, - TestHealth1Case { - // 5 - perp1: (30, -100, 0, 0), - expected_health: 0.0, - ..Default::default() - }, - TestHealth1Case { // 6, reserved oo funds - token1: -100, - token2: -10, - token3: -10, - oo_1_2: (1, 1), - oo_1_3: (1, 1), - expected_health: - // tokens - -100.0 * 1.2 - 10.0 * 5.0 * 1.5 - 10.0 * 10.0 * 1.5 - // oo_1_2 (-> token1) - + (1.0 + 5.0) * 1.2 - // oo_1_3 (-> token1) - + (1.0 + 10.0) * 1.2, - ..Default::default() - }, - TestHealth1Case { // 7, reserved oo funds cross the zero balance level - token1: -14, - token2: -10, - token3: -10, - oo_1_2: (1, 1), - oo_1_3: (1, 1), - expected_health: - // tokens - -14.0 * 1.2 - 10.0 * 5.0 * 1.5 - 10.0 * 10.0 * 1.5 - // oo_1_2 (-> token1) - + 3.0 * 1.2 + 3.0 * 0.8 - // oo_1_3 (-> token1) - + 8.0 * 1.2 + 3.0 * 0.8, - ..Default::default() - }, - TestHealth1Case { // 8, reserved oo funds in a non-quote currency - token1: -100, - token2: -100, - token3: -1, - oo_1_2: (0, 0), - oo_1_3: (10, 1), - expected_health: - // tokens - -100.0 * 1.2 - 100.0 * 5.0 * 1.5 - 10.0 * 1.5 - // oo_1_3 (-> token3) - + 10.0 * 1.5 + 10.0 * 0.5, - ..Default::default() - }, - TestHealth1Case { // 9, like 8 but oo_1_2 flips the oo_1_3 target - token1: -100, - token2: -100, - token3: -1, - oo_1_2: (100, 0), - oo_1_3: (10, 1), - expected_health: - // tokens - -100.0 * 1.2 - 100.0 * 5.0 * 1.5 - 10.0 * 1.5 - // oo_1_2 (-> token1) - + 80.0 * 1.2 + 20.0 * 0.8 - // oo_1_3 (-> token1) - + 20.0 * 0.8, - ..Default::default() - }, - ]; - - for (i, testcase) in testcases.iter().enumerate() { - println!("checking testcase {}", i); - test_health1_runner(testcase); - } - } - - #[test] - fn test_max_swap() { - let default_token_info = |x| TokenInfo { - token_index: 0, - maint_asset_weight: I80F48::from_num(1.0 - x), - init_asset_weight: I80F48::from_num(1.0 - x), - maint_liab_weight: I80F48::from_num(1.0 + x), - init_liab_weight: I80F48::from_num(1.0 + x), - prices: Prices::new_single_price(I80F48::from_num(2.0)), - balance_native: I80F48::ZERO, - }; - - let health_cache = HealthCache { - token_infos: vec![ - TokenInfo { - token_index: 0, - prices: Prices::new_single_price(I80F48::from_num(2.0)), - ..default_token_info(0.1) - }, - TokenInfo { - token_index: 1, - prices: Prices::new_single_price(I80F48::from_num(3.0)), - ..default_token_info(0.2) - }, - TokenInfo { - token_index: 2, - prices: Prices::new_single_price(I80F48::from_num(4.0)), - ..default_token_info(0.3) - }, - ], - serum3_infos: vec![], - perp_infos: vec![], - being_liquidated: false, - }; - - assert_eq!(health_cache.health(HealthType::Init), I80F48::ZERO); - assert_eq!(health_cache.health_ratio(HealthType::Init), I80F48::MAX); - assert_eq!( - health_cache - .max_swap_source_for_health_ratio( - 0, - 1, - I80F48::from_num(2.0 / 3.0), - I80F48::from_num(50.0) - ) - .unwrap(), - I80F48::ZERO - ); - - let adjust_by_usdc = |c: &mut HealthCache, ti: TokenIndex, usdc: f64| { - let ti = &mut c.token_infos[ti as usize]; - ti.balance_native += I80F48::from_num(usdc) / ti.prices.oracle; - }; - let find_max_swap_actual = |c: &HealthCache, - source: TokenIndex, - target: TokenIndex, - ratio: f64, - price_factor: f64| { - let mut c = c.clone(); - let source_price = &c.token_infos[source as usize].prices; - let target_price = &c.token_infos[target as usize].prices; - let swap_price = - I80F48::from_num(price_factor) * source_price.oracle / target_price.oracle; - let source_amount = c - .max_swap_source_for_health_ratio( - source, - target, - swap_price, - I80F48::from_num(ratio), - ) - .unwrap(); - if source_amount == I80F48::MAX { - return (f64::MAX, f64::MAX); - } - c.adjust_token_balance(source, -source_amount).unwrap(); - c.adjust_token_balance(target, source_amount * swap_price) - .unwrap(); - ( - source_amount.to_num::(), - c.health_ratio(HealthType::Init).to_num::(), - ) - }; - let check_max_swap_result = |c: &HealthCache, - source: TokenIndex, - target: TokenIndex, - ratio: f64, - price_factor: f64| { - let (source_amount, actual_ratio) = - find_max_swap_actual(c, source, target, ratio, price_factor); - println!( - "checking {source} to {target} for price_factor: {price_factor}, target ratio {ratio}: actual ratio: {actual_ratio}, amount: {source_amount}", - ); - assert!((ratio - actual_ratio).abs() < 1.0); - }; - - { - println!("test 0"); - let mut health_cache = health_cache.clone(); - adjust_by_usdc(&mut health_cache, 1, 100.0); - - for price_factor in [0.1, 0.9, 1.1] { - for target in 1..100 { - let target = target as f64; - check_max_swap_result(&health_cache, 0, 1, target, price_factor); - check_max_swap_result(&health_cache, 1, 0, target, price_factor); - check_max_swap_result(&health_cache, 0, 2, target, price_factor); - } - } - - // At this unlikely price it's healthy to swap infinitely - assert_eq!( - find_max_swap_actual(&health_cache, 0, 1, 50.0, 1.5).0, - f64::MAX - ); - } - - { - println!("test 1"); - let mut health_cache = health_cache.clone(); - adjust_by_usdc(&mut health_cache, 0, -20.0); - adjust_by_usdc(&mut health_cache, 1, 100.0); - - for price_factor in [0.1, 0.9, 1.1] { - for target in 1..100 { - let target = target as f64; - check_max_swap_result(&health_cache, 0, 1, target, price_factor); - check_max_swap_result(&health_cache, 1, 0, target, price_factor); - check_max_swap_result(&health_cache, 0, 2, target, price_factor); - check_max_swap_result(&health_cache, 2, 0, target, price_factor); - } - } - } - - { - println!("test 2"); - let mut health_cache = health_cache.clone(); - adjust_by_usdc(&mut health_cache, 0, -50.0); - adjust_by_usdc(&mut health_cache, 1, 100.0); - // possible even though the init ratio is <100 - check_max_swap_result(&health_cache, 1, 0, 100.0, 1.0); - } - - { - println!("test 3"); - let mut health_cache = health_cache.clone(); - adjust_by_usdc(&mut health_cache, 0, -30.0); - adjust_by_usdc(&mut health_cache, 1, 100.0); - adjust_by_usdc(&mut health_cache, 2, -30.0); - - // swapping with a high ratio advises paying back all liabs - // and then swapping even more because increasing assets in 0 has better asset weight - let init_ratio = health_cache.health_ratio(HealthType::Init); - let (amount, actual_ratio) = find_max_swap_actual(&health_cache, 1, 0, 100.0, 1.0); - println!( - "init {}, after {}, amount {}", - init_ratio, actual_ratio, amount - ); - assert!(actual_ratio / 2.0 > init_ratio); - assert!((amount - 100.0 / 3.0).abs() < 1.0); - } - - { - println!("test 4"); - let mut health_cache = health_cache.clone(); - adjust_by_usdc(&mut health_cache, 0, 100.0); - adjust_by_usdc(&mut health_cache, 1, -2.0); - adjust_by_usdc(&mut health_cache, 2, -65.0); - - let init_ratio = health_cache.health_ratio(HealthType::Init); - assert!(init_ratio > 3 && init_ratio < 4); - - check_max_swap_result(&health_cache, 0, 1, 1.0, 1.0); - check_max_swap_result(&health_cache, 0, 1, 3.0, 1.0); - check_max_swap_result(&health_cache, 0, 1, 4.0, 1.0); - } - } - - #[test] - fn test_max_perp() { - let default_token_info = |x| TokenInfo { - token_index: 0, - maint_asset_weight: I80F48::from_num(1.0 - x), - init_asset_weight: I80F48::from_num(1.0 - x), - maint_liab_weight: I80F48::from_num(1.0 + x), - init_liab_weight: I80F48::from_num(1.0 + x), - prices: Prices::new_single_price(I80F48::from_num(2.0)), - balance_native: I80F48::ZERO, - }; - let base_lot_size = 100; - let default_perp_info = |x| PerpInfo { - perp_market_index: 0, - maint_asset_weight: I80F48::from_num(1.0 - x), - init_asset_weight: I80F48::from_num(1.0 - x), - maint_liab_weight: I80F48::from_num(1.0 + x), - init_liab_weight: I80F48::from_num(1.0 + x), - base_lot_size, - base_lots: 0, - bids_base_lots: 0, - asks_base_lots: 0, - quote: I80F48::ZERO, - prices: Prices::new_single_price(I80F48::from_num(2.0)), - has_open_orders: false, - trusted_market: false, - }; - - let health_cache = HealthCache { - token_infos: vec![TokenInfo { - token_index: 0, - prices: Prices::new_single_price(I80F48::from_num(1.0)), - balance_native: I80F48::ZERO, - ..default_token_info(0.0) - }], - serum3_infos: vec![], - perp_infos: vec![PerpInfo { - perp_market_index: 0, - ..default_perp_info(0.3) - }], - being_liquidated: false, - }; - - assert_eq!(health_cache.health(HealthType::Init), I80F48::ZERO); - assert_eq!(health_cache.health_ratio(HealthType::Init), I80F48::MAX); - assert_eq!( - health_cache - .max_perp_for_health_ratio( - 0, - I80F48::from(2), - PerpOrderSide::Bid, - I80F48::from_num(50.0) - ) - .unwrap(), - I80F48::ZERO - ); - - let adjust_token = |c: &mut HealthCache, value: f64| { - let ti = &mut c.token_infos[0]; - ti.balance_native += I80F48::from_num(value); - }; - let find_max_trade = - |c: &HealthCache, side: PerpOrderSide, ratio: f64, price_factor: f64| { - let prices = &c.perp_infos[0].prices; - let trade_price = I80F48::from_num(price_factor) * prices.oracle; - let base_lots = c - .max_perp_for_health_ratio(0, trade_price, side, I80F48::from_num(ratio)) - .unwrap(); - if base_lots == i64::MAX { - return (i64::MAX, f64::MAX, f64::MAX); - } - - let direction = match side { - PerpOrderSide::Bid => 1, - PerpOrderSide::Ask => -1, - }; - - // compute the health ratio we'd get when executing the trade - let actual_ratio = { - let base_lots = direction * base_lots; - let base_native = I80F48::from(base_lots * base_lot_size); - let mut c = c.clone(); - c.perp_infos[0].base_lots += base_lots; - c.perp_infos[0].quote -= base_native * trade_price; - c.health_ratio(HealthType::Init).to_num::() - }; - // the ratio for trading just one base lot extra - let plus_ratio = { - let base_lots = direction * (base_lots + 1); - let base_native = I80F48::from(base_lots * base_lot_size); - let mut c = c.clone(); - c.perp_infos[0].base_lots += base_lots; - c.perp_infos[0].quote -= base_native * trade_price; - c.health_ratio(HealthType::Init).to_num::() - }; - (base_lots, actual_ratio, plus_ratio) - }; - let check_max_trade = |c: &HealthCache, - side: PerpOrderSide, - ratio: f64, - price_factor: f64| { - let (base_lots, actual_ratio, plus_ratio) = - find_max_trade(c, side, ratio, price_factor); - println!( - "checking for price_factor: {price_factor}, target ratio {ratio}: actual ratio: {actual_ratio}, plus ratio: {plus_ratio}, base_lots: {base_lots}", - ); - let max_binary_search_error = 0.1; - assert!(ratio <= actual_ratio); - assert!(plus_ratio - max_binary_search_error <= ratio); - }; - - { - let mut health_cache = health_cache.clone(); - adjust_token(&mut health_cache, 3000.0); - - for existing in [-5, 0, 3] { - let mut c = health_cache.clone(); - c.perp_infos[0].base_lots += existing; - c.perp_infos[0].quote -= I80F48::from(existing * base_lot_size * 2); - - for side in [PerpOrderSide::Bid, PerpOrderSide::Ask] { - println!("test 0: existing {existing}, side {side:?}"); - for price_factor in [0.8, 1.0, 1.1] { - for ratio in 1..=100 { - check_max_trade(&health_cache, side, ratio as f64, price_factor); - } - } - } - } - - // check some extremely bad prices - check_max_trade(&health_cache, PerpOrderSide::Bid, 50.0, 2.0); - check_max_trade(&health_cache, PerpOrderSide::Ask, 50.0, 0.1); - - // and extremely good prices - assert_eq!( - find_max_trade(&health_cache, PerpOrderSide::Bid, 50.0, 0.1).0, - i64::MAX - ); - assert_eq!( - find_max_trade(&health_cache, PerpOrderSide::Ask, 50.0, 1.5).0, - i64::MAX - ); - } - } - - #[test] - fn test_health_perp_funding() { - let buffer = MangoAccount::default_for_tests().try_to_vec().unwrap(); - let mut account = MangoAccountValue::from_bytes(&buffer).unwrap(); - - let group = Pubkey::new_unique(); - - let (mut bank1, mut oracle1) = mock_bank_and_oracle(group, 1, 1.0, 0.2, 0.1); - bank1 - .data() - .change_without_fee( - account.ensure_token_position(1).unwrap().0, - I80F48::from(100), - 0, - ) - .unwrap(); - - let mut perp1 = mock_perp_market(group, oracle1.pubkey, 1.0, 9, 0.2, 0.1); - perp1.data().long_funding = I80F48::from_num(10.1); - let perpaccount = account.ensure_perp_position(9, 1).unwrap().0; - perpaccount.record_trade(perp1.data(), 10, I80F48::from(-110)); - perpaccount.long_settled_funding = I80F48::from_num(10.0); - - let oracle1_ai = oracle1.as_account_info(); - let ais = vec![ - bank1.as_account_info(), - oracle1_ai.clone(), - perp1.as_account_info(), - oracle1_ai, - ]; - - let retriever = ScanningAccountRetriever::new_with_staleness(&ais, &group, None).unwrap(); - - assert!(health_eq( - compute_health(&account.borrow(), HealthType::Init, &retriever).unwrap(), - // token - 0.8 * 100.0 - // perp base - + 0.8 * 100.0 - // perp quote - - 110.0 - // perp funding (10 * (10.1 - 10.0)) - - 1.0 - )); - } - - #[test] - fn test_scanning_retreiver_mismatched_oracle_for_perps_throws_error() { - let group = Pubkey::new_unique(); - - let (mut bank1, mut oracle1) = mock_bank_and_oracle(group, 1, 1.0, 0.2, 0.1); - let (mut bank2, mut oracle2) = mock_bank_and_oracle(group, 4, 5.0, 0.5, 0.3); - - let mut oo1 = TestAccount::::new_zeroed(); - - let mut perp1 = mock_perp_market(group, oracle1.pubkey, 1.0, 9, 0.2, 0.1); - let mut perp2 = mock_perp_market(group, oracle2.pubkey, 5.0, 8, 0.2, 0.1); - - let oracle1_account_info = oracle1.as_account_info(); - let oracle2_account_info = oracle2.as_account_info(); - let ais = vec![ - bank1.as_account_info(), - bank2.as_account_info(), - oracle1_account_info.clone(), - oracle2_account_info.clone(), - perp1.as_account_info(), - perp2.as_account_info(), - oracle2_account_info, // Oracles wrong way around - oracle1_account_info, - oo1.as_account_info(), - ]; - - let retriever = ScanningAccountRetriever::new_with_staleness(&ais, &group, None).unwrap(); - let result = retriever.perp_market_and_oracle_price(&group, 0, 9); - assert!(result.is_err()); - } - - #[test] - fn test_health_stable_price_token() { - let buffer = MangoAccount::default_for_tests().try_to_vec().unwrap(); - let mut account = MangoAccountValue::from_bytes(&buffer).unwrap(); - let buffer2 = MangoAccount::default_for_tests().try_to_vec().unwrap(); - let mut account2 = MangoAccountValue::from_bytes(&buffer2).unwrap(); - let buffer3 = MangoAccount::default_for_tests().try_to_vec().unwrap(); - let mut account3 = MangoAccountValue::from_bytes(&buffer3).unwrap(); - - let group = Pubkey::new_unique(); - - let (mut bank1, mut oracle1) = mock_bank_and_oracle(group, 1, 1.0, 0.2, 0.1); - bank1.data().stable_price_model.stable_price = 0.5; - bank1 - .data() - .change_without_fee( - account.ensure_token_position(1).unwrap().0, - I80F48::from(100), - DUMMY_NOW_TS, - ) - .unwrap(); - bank1 - .data() - .change_without_fee( - account2.ensure_token_position(1).unwrap().0, - I80F48::from(-100), - DUMMY_NOW_TS, - ) - .unwrap(); - - let mut perp1 = mock_perp_market(group, oracle1.pubkey, 1.0, 9, 0.2, 0.1); - perp1.data().stable_price_model.stable_price = 0.5; - let perpaccount = account3.ensure_perp_position(9, 1).unwrap().0; - perpaccount.record_trade(perp1.data(), 10, I80F48::from(-100)); - - let oracle1_ai = oracle1.as_account_info(); - let ais = vec![ - bank1.as_account_info(), - oracle1_ai.clone(), - perp1.as_account_info(), - oracle1_ai, - ]; - - let retriever = ScanningAccountRetriever::new_with_staleness(&ais, &group, None).unwrap(); - - assert!(health_eq( - compute_health(&account.borrow(), HealthType::Init, &retriever).unwrap(), - 0.8 * 0.5 * 100.0 - )); - assert!(health_eq( - compute_health(&account.borrow(), HealthType::Maint, &retriever).unwrap(), - 0.9 * 1.0 * 100.0 - )); - assert!(health_eq( - compute_health(&account2.borrow(), HealthType::Init, &retriever).unwrap(), - -1.2 * 1.0 * 100.0 - )); - assert!(health_eq( - compute_health(&account2.borrow(), HealthType::Maint, &retriever).unwrap(), - -1.1 * 1.0 * 100.0 - )); - assert!(health_eq( - compute_health(&account3.borrow(), HealthType::Init, &retriever).unwrap(), - 0.8 * 0.5 * 10.0 * 10.0 - 100.0 - )); - assert!(health_eq( - compute_health(&account3.borrow(), HealthType::Maint, &retriever).unwrap(), - 0.9 * 1.0 * 10.0 * 10.0 - 100.0 - )); - } -} diff --git a/programs/mango-v4/src/state/health/account_retriever.rs b/programs/mango-v4/src/state/health/account_retriever.rs new file mode 100644 index 000000000..4ff05c94b --- /dev/null +++ b/programs/mango-v4/src/state/health/account_retriever.rs @@ -0,0 +1,520 @@ +use anchor_lang::prelude::*; +use anchor_lang::ZeroCopy; + +use fixed::types::I80F48; +use serum_dex::state::OpenOrders; + +use std::cell::Ref; +use std::collections::HashMap; + +use crate::accounts_zerocopy::*; +use crate::error::*; +use crate::serum3_cpi; +use crate::state::{Bank, MangoAccountRef, PerpMarket, PerpMarketIndex, TokenIndex}; +use crate::util::checked_math as cm; + +/// This trait abstracts how to find accounts needed for the health computation. +/// +/// There are different ways they are retrieved from remainingAccounts, based +/// on the instruction: +/// - FixedOrderAccountRetriever requires the remainingAccounts to be in a well +/// defined order and is the fastest. It's used where possible. +/// - ScanningAccountRetriever does a linear scan for each account it needs. +/// It needs more compute, but works when a union of bank/oracle/market accounts +/// are passed because health needs to be computed for different baskets in +/// one instruction (such as for liquidation instructions). +pub trait AccountRetriever { + fn bank_and_oracle( + &self, + group: &Pubkey, + active_token_position_index: usize, + token_index: TokenIndex, + ) -> Result<(&Bank, I80F48)>; + + fn serum_oo(&self, active_serum_oo_index: usize, key: &Pubkey) -> Result<&OpenOrders>; + + fn perp_market_and_oracle_price( + &self, + group: &Pubkey, + active_perp_position_index: usize, + perp_market_index: PerpMarketIndex, + ) -> Result<(&PerpMarket, I80F48)>; +} + +/// Assumes the account infos needed for the health computation follow a strict order. +/// +/// 1. n_banks Bank account, in the order of account.token_iter_active() +/// 2. n_banks oracle accounts, one for each bank in the same order +/// 3. PerpMarket accounts, in the order of account.perps.iter_active_accounts() +/// 4. PerpMarket oracle accounts, in the order of the perp market accounts +/// 5. serum3 OpenOrders accounts, in the order of account.serum3.iter_active() +pub struct FixedOrderAccountRetriever { + pub ais: Vec, + pub n_banks: usize, + pub n_perps: usize, + pub begin_perp: usize, + pub begin_serum3: usize, + pub staleness_slot: Option, +} + +pub fn new_fixed_order_account_retriever<'a, 'info>( + ais: &'a [AccountInfo<'info>], + account: &MangoAccountRef, +) -> Result>> { + let active_token_len = account.active_token_positions().count(); + let active_serum3_len = account.active_serum3_orders().count(); + let active_perp_len = account.active_perp_positions().count(); + let expected_ais = cm!(active_token_len * 2 // banks + oracles + + active_perp_len * 2 // PerpMarkets + Oracles + + active_serum3_len); // open_orders + require_eq!(ais.len(), expected_ais); + + Ok(FixedOrderAccountRetriever { + ais: AccountInfoRef::borrow_slice(ais)?, + n_banks: active_token_len, + n_perps: active_perp_len, + begin_perp: cm!(active_token_len * 2), + begin_serum3: cm!(active_token_len * 2 + active_perp_len * 2), + staleness_slot: Some(Clock::get()?.slot), + }) +} + +impl FixedOrderAccountRetriever { + fn bank(&self, group: &Pubkey, account_index: usize, token_index: TokenIndex) -> Result<&Bank> { + let bank = self.ais[account_index].load::()?; + require_keys_eq!(bank.group, *group); + require_eq!(bank.token_index, token_index); + Ok(bank) + } + + fn perp_market( + &self, + group: &Pubkey, + account_index: usize, + perp_market_index: PerpMarketIndex, + ) -> Result<&PerpMarket> { + let market_ai = &self.ais[account_index]; + let market = market_ai.load::()?; + require_keys_eq!(market.group, *group); + require_eq!(market.perp_market_index, perp_market_index); + Ok(market) + } + + fn oracle_price_bank(&self, account_index: usize, bank: &Bank) -> Result { + let oracle = &self.ais[account_index]; + bank.oracle_price(oracle, self.staleness_slot) + } + + fn oracle_price_perp(&self, account_index: usize, perp_market: &PerpMarket) -> Result { + let oracle = &self.ais[account_index]; + perp_market.oracle_price(oracle, self.staleness_slot) + } +} + +impl AccountRetriever for FixedOrderAccountRetriever { + fn bank_and_oracle( + &self, + group: &Pubkey, + active_token_position_index: usize, + token_index: TokenIndex, + ) -> Result<(&Bank, I80F48)> { + let bank_account_index = active_token_position_index; + let bank = self + .bank(group, bank_account_index, token_index) + .with_context(|| { + format!( + "loading bank with health account index {}, token index {}, passed account {}", + bank_account_index, + token_index, + self.ais[bank_account_index].key(), + ) + })?; + + let oracle_index = cm!(self.n_banks + active_token_position_index); + let oracle_price = self.oracle_price_bank(oracle_index, bank).with_context(|| { + format!( + "getting oracle for bank with health account index {} and token index {}, passed account {}", + bank_account_index, + token_index, + self.ais[oracle_index].key(), + ) + })?; + + Ok((bank, oracle_price)) + } + + fn perp_market_and_oracle_price( + &self, + group: &Pubkey, + active_perp_position_index: usize, + perp_market_index: PerpMarketIndex, + ) -> Result<(&PerpMarket, I80F48)> { + let perp_index = cm!(self.begin_perp + active_perp_position_index); + let perp_market = self + .perp_market(group, perp_index, perp_market_index) + .with_context(|| { + format!( + "loading perp market with health account index {} and perp market index {}, passed account {}", + perp_index, + perp_market_index, + self.ais[perp_index].key(), + ) + })?; + + let oracle_index = cm!(perp_index + self.n_perps); + let oracle_price = self.oracle_price_perp(oracle_index, perp_market).with_context(|| { + format!( + "getting oracle for perp market with health account index {} and perp market index {}, passed account {}", + oracle_index, + perp_market_index, + self.ais[oracle_index].key(), + ) + })?; + Ok((perp_market, oracle_price)) + } + + fn serum_oo(&self, active_serum_oo_index: usize, key: &Pubkey) -> Result<&OpenOrders> { + let serum_oo_index = cm!(self.begin_serum3 + active_serum_oo_index); + let ai = &self.ais[serum_oo_index]; + (|| { + require_keys_eq!(*key, *ai.key()); + serum3_cpi::load_open_orders(ai) + })() + .with_context(|| { + format!( + "loading serum open orders with health account index {}, passed account {}", + serum_oo_index, + ai.key(), + ) + }) + } +} + +/// Takes a list of account infos containing +/// - an unknown number of Banks in any order, followed by +/// - the same number of oracles in the same order as the banks, followed by +/// - an unknown number of PerpMarket accounts +/// - the same number of oracles in the same order as the perp markets +/// - an unknown number of serum3 OpenOrders accounts +/// and retrieves accounts needed for the health computation by doing a linear +/// scan for each request. +pub struct ScanningAccountRetriever<'a, 'info> { + banks: Vec>, + oracles: Vec>, + perp_markets: Vec>, + perp_oracles: Vec>, + serum3_oos: Vec>, + token_index_map: HashMap, + perp_index_map: HashMap, + staleness_slot: Option, +} + +/// Returns None if `ai` doesn't have the owner or discriminator for T. +/// Forwards "can't borrow" error, so it can be raised immediately. +fn can_load_as<'a, T: ZeroCopy + Owner>( + (i, ai): (usize, &'a AccountInfo), +) -> Option<(usize, Result>)> { + let load_result = ai.load::(); + match load_result { + Err(Error::AnchorError(error)) + if error.error_code_number == ErrorCode::AccountDiscriminatorMismatch as u32 + || error.error_code_number == ErrorCode::AccountDiscriminatorNotFound as u32 + || error.error_code_number == ErrorCode::AccountOwnedByWrongProgram as u32 => + { + return None; + } + _ => {} + }; + Some((i, load_result)) +} + +impl<'a, 'info> ScanningAccountRetriever<'a, 'info> { + pub fn new(ais: &'a [AccountInfo<'info>], group: &Pubkey) -> Result { + Self::new_with_staleness(ais, group, Some(Clock::get()?.slot)) + } + + pub fn new_with_staleness( + ais: &'a [AccountInfo<'info>], + group: &Pubkey, + staleness_slot: Option, + ) -> Result { + // find all Bank accounts + let mut token_index_map = HashMap::with_capacity(ais.len() / 2); + ais.iter() + .enumerate() + .map_while(can_load_as::) + .try_for_each(|(i, loaded)| { + (|| { + let bank = loaded?; + require_keys_eq!(bank.group, *group); + let previous = token_index_map.insert(bank.token_index, i); + require_msg!( + previous.is_none(), + "duplicate bank for token index {}", + bank.token_index + ); + Ok(()) + })() + .with_context(|| format!("scanning banks, health account index {}", i)) + })?; + let n_banks = token_index_map.len(); + + // skip all banks and oracles, then find number of PerpMarket accounts + let perps_start = n_banks * 2; + let mut perp_index_map = HashMap::with_capacity(ais.len().saturating_sub(perps_start)); + ais[perps_start..] + .iter() + .enumerate() + .map_while(can_load_as::) + .try_for_each(|(i, loaded)| { + (|| { + let perp_market = loaded?; + require_keys_eq!(perp_market.group, *group); + let previous = perp_index_map.insert(perp_market.perp_market_index, i); + require_msg!( + previous.is_none(), + "duplicate perp market for perp market index {}", + perp_market.perp_market_index + ); + Ok(()) + })() + .with_context(|| { + format!( + "scanning perp markets, health account index {}", + i + perps_start + ) + }) + })?; + let n_perps = perp_index_map.len(); + let perp_oracles_start = perps_start + n_perps; + let serum3_start = perp_oracles_start + n_perps; + + Ok(Self { + banks: AccountInfoRefMut::borrow_slice(&ais[..n_banks])?, + oracles: AccountInfoRef::borrow_slice(&ais[n_banks..perps_start])?, + perp_markets: AccountInfoRef::borrow_slice(&ais[perps_start..perp_oracles_start])?, + perp_oracles: AccountInfoRef::borrow_slice(&ais[perp_oracles_start..serum3_start])?, + serum3_oos: AccountInfoRef::borrow_slice(&ais[serum3_start..])?, + token_index_map, + perp_index_map, + staleness_slot, + }) + } + + #[inline] + fn bank_index(&self, token_index: TokenIndex) -> Result { + Ok(*self + .token_index_map + .get(&token_index) + .ok_or_else(|| error_msg!("token index {} not found", token_index))?) + } + + #[inline] + fn perp_market_index(&self, perp_market_index: PerpMarketIndex) -> Result { + Ok(*self + .perp_index_map + .get(&perp_market_index) + .ok_or_else(|| error_msg!("perp market index {} not found", perp_market_index))?) + } + + #[allow(clippy::type_complexity)] + pub fn banks_mut_and_oracles( + &mut self, + token_index1: TokenIndex, + token_index2: TokenIndex, + ) -> Result<(&mut Bank, I80F48, Option<(&mut Bank, I80F48)>)> { + if token_index1 == token_index2 { + let index = self.bank_index(token_index1)?; + let bank = self.banks[index].load_mut_fully_unchecked::()?; + let oracle = &self.oracles[index]; + let price = bank.oracle_price(oracle, self.staleness_slot)?; + return Ok((bank, price, None)); + } + let index1 = self.bank_index(token_index1)?; + let index2 = self.bank_index(token_index2)?; + let (first, second, swap) = if index1 < index2 { + (index1, index2, false) + } else { + (index2, index1, true) + }; + + // split_at_mut after the first bank and after the second bank + let (first_bank_part, second_bank_part) = self.banks.split_at_mut(first + 1); + + let bank1 = first_bank_part[first].load_mut_fully_unchecked::()?; + let bank2 = second_bank_part[second - (first + 1)].load_mut_fully_unchecked::()?; + let oracle1 = &self.oracles[first]; + let oracle2 = &self.oracles[second]; + let price1 = bank1.oracle_price(oracle1, self.staleness_slot)?; + let price2 = bank2.oracle_price(oracle2, self.staleness_slot)?; + if swap { + Ok((bank2, price2, Some((bank1, price1)))) + } else { + Ok((bank1, price1, Some((bank2, price2)))) + } + } + + pub fn scanned_bank_and_oracle(&self, token_index: TokenIndex) -> Result<(&Bank, I80F48)> { + let index = self.bank_index(token_index)?; + // The account was already loaded successfully during construction + let bank = self.banks[index].load_fully_unchecked::()?; + let oracle = &self.oracles[index]; + let price = bank.oracle_price(oracle, self.staleness_slot)?; + Ok((bank, price)) + } + + pub fn scanned_perp_market_and_oracle( + &self, + perp_market_index: PerpMarketIndex, + ) -> Result<(&PerpMarket, I80F48)> { + let index = self.perp_market_index(perp_market_index)?; + // The account was already loaded successfully during construction + let perp_market = self.perp_markets[index].load_fully_unchecked::()?; + let oracle_acc = &self.perp_oracles[index]; + let price = perp_market.oracle_price(oracle_acc, self.staleness_slot)?; + Ok((perp_market, price)) + } + + pub fn scanned_serum_oo(&self, key: &Pubkey) -> Result<&OpenOrders> { + let oo = self + .serum3_oos + .iter() + .find(|ai| ai.key == key) + .ok_or_else(|| error_msg!("no serum3 open orders for key {}", key))?; + serum3_cpi::load_open_orders(oo) + } +} + +impl<'a, 'info> AccountRetriever for ScanningAccountRetriever<'a, 'info> { + fn bank_and_oracle( + &self, + _group: &Pubkey, + _account_index: usize, + token_index: TokenIndex, + ) -> Result<(&Bank, I80F48)> { + self.scanned_bank_and_oracle(token_index) + } + + fn perp_market_and_oracle_price( + &self, + _group: &Pubkey, + _account_index: usize, + perp_market_index: PerpMarketIndex, + ) -> Result<(&PerpMarket, I80F48)> { + self.scanned_perp_market_and_oracle(perp_market_index) + } + + fn serum_oo(&self, _account_index: usize, key: &Pubkey) -> Result<&OpenOrders> { + self.scanned_serum_oo(key) + } +} + +#[cfg(test)] +mod tests { + use super::super::test::*; + use super::*; + use serum_dex::state::OpenOrders; + use std::convert::identity; + + #[test] + fn test_scanning_account_retriever() { + let oracle1_price = 1.0; + let oracle2_price = 5.0; + let group = Pubkey::new_unique(); + + let (mut bank1, mut oracle1) = mock_bank_and_oracle(group, 1, oracle1_price, 0.2, 0.1); + let (mut bank2, mut oracle2) = mock_bank_and_oracle(group, 4, oracle2_price, 0.5, 0.3); + let (mut bank3, _) = mock_bank_and_oracle(group, 5, 1.0, 0.5, 0.3); + + // bank3 reuses the bank2 oracle, to ensure the ScanningAccountRetriever doesn't choke on that + bank3.data().oracle = oracle2.pubkey; + + let mut oo1 = TestAccount::::new_zeroed(); + let oo1key = oo1.pubkey; + oo1.data().native_pc_total = 20; + + let mut perp1 = mock_perp_market(group, oracle2.pubkey, oracle2_price, 9, 0.2, 0.1); + let mut perp2 = mock_perp_market(group, oracle1.pubkey, oracle1_price, 8, 0.2, 0.1); + + let oracle1_account_info = oracle1.as_account_info(); + let oracle2_account_info = oracle2.as_account_info(); + let ais = vec![ + bank1.as_account_info(), + bank2.as_account_info(), + bank3.as_account_info(), + oracle1_account_info.clone(), + oracle2_account_info.clone(), + oracle2_account_info.clone(), + perp1.as_account_info(), + perp2.as_account_info(), + oracle2_account_info, + oracle1_account_info, + oo1.as_account_info(), + ]; + + let mut retriever = + ScanningAccountRetriever::new_with_staleness(&ais, &group, None).unwrap(); + + assert_eq!(retriever.banks.len(), 3); + assert_eq!(retriever.token_index_map.len(), 3); + assert_eq!(retriever.oracles.len(), 3); + assert_eq!(retriever.perp_markets.len(), 2); + assert_eq!(retriever.perp_oracles.len(), 2); + assert_eq!(retriever.perp_index_map.len(), 2); + assert_eq!(retriever.serum3_oos.len(), 1); + + { + let (b1, o1, opt_b2o2) = retriever.banks_mut_and_oracles(1, 4).unwrap(); + let (b2, o2) = opt_b2o2.unwrap(); + assert_eq!(b1.token_index, 1); + assert_eq!(o1, I80F48::ONE); + assert_eq!(b2.token_index, 4); + assert_eq!(o2, 5 * I80F48::ONE); + } + + { + let (b1, o1, opt_b2o2) = retriever.banks_mut_and_oracles(4, 1).unwrap(); + let (b2, o2) = opt_b2o2.unwrap(); + assert_eq!(b1.token_index, 4); + assert_eq!(o1, 5 * I80F48::ONE); + assert_eq!(b2.token_index, 1); + assert_eq!(o2, I80F48::ONE); + } + + { + let (b1, o1, opt_b2o2) = retriever.banks_mut_and_oracles(4, 4).unwrap(); + assert!(opt_b2o2.is_none()); + assert_eq!(b1.token_index, 4); + assert_eq!(o1, 5 * I80F48::ONE); + } + + retriever.banks_mut_and_oracles(4, 2).unwrap_err(); + + { + let (b, o) = retriever.scanned_bank_and_oracle(5).unwrap(); + assert_eq!(b.token_index, 5); + assert_eq!(o, 5 * I80F48::ONE); + } + + let oo = retriever.serum_oo(0, &oo1key).unwrap(); + assert_eq!(identity(oo.native_pc_total), 20); + + assert!(retriever.serum_oo(1, &Pubkey::default()).is_err()); + + let (perp, oracle_price) = retriever + .perp_market_and_oracle_price(&group, 0, 9) + .unwrap(); + assert_eq!(identity(perp.perp_market_index), 9); + assert_eq!(oracle_price, oracle2_price); + + let (perp, oracle_price) = retriever + .perp_market_and_oracle_price(&group, 1, 8) + .unwrap(); + assert_eq!(identity(perp.perp_market_index), 8); + assert_eq!(oracle_price, oracle1_price); + + assert!(retriever + .perp_market_and_oracle_price(&group, 1, 5) + .is_err()); + } +} diff --git a/programs/mango-v4/src/state/health/cache.rs b/programs/mango-v4/src/state/health/cache.rs new file mode 100644 index 000000000..64dcbf323 --- /dev/null +++ b/programs/mango-v4/src/state/health/cache.rs @@ -0,0 +1,1005 @@ +use anchor_lang::prelude::*; + +use fixed::types::I80F48; +use fixed_macro::types::I80F48; + +use crate::error::*; +use crate::state::{ + MangoAccountFixed, MangoAccountRef, PerpMarket, PerpMarketIndex, PerpPosition, + Serum3MarketIndex, TokenIndex, +}; +use crate::util::checked_math as cm; + +use super::*; + +const ONE_NATIVE_USDC_IN_USD: I80F48 = I80F48!(0.000001); + +/// Information about prices for a bank or perp market. +#[derive(Clone, AnchorDeserialize, AnchorSerialize)] +pub struct Prices { + /// The current oracle price + pub oracle: I80F48, // native/native + + /// A "stable" price, provided by StablePriceModel + pub stable: I80F48, // native/native +} + +impl Prices { + // intended for tests + pub fn new_single_price(price: I80F48) -> Self { + Self { + oracle: price, + stable: price, + } + } + + /// The liability price to use for the given health type + #[inline(always)] + pub fn liab(&self, health_type: HealthType) -> I80F48 { + if health_type == HealthType::Maint { + self.oracle + } else { + self.oracle.max(self.stable) + } + } + + /// The asset price to use for the given health type + #[inline(always)] + pub fn asset(&self, health_type: HealthType) -> I80F48 { + if health_type == HealthType::Maint { + self.oracle + } else { + self.oracle.min(self.stable) + } + } +} + +/// There are two types of health, initial health used for opening new positions and maintenance +/// health used for liquidations. They are both calculated as a weighted sum of the assets +/// minus the liabilities but the maint. health uses slightly larger weights for assets and +/// slightly smaller weights for the liabilities. Zero is used as the bright line for both +/// i.e. if your init health falls below zero, you cannot open new positions and if your maint. health +/// falls below zero you will be liquidated. +#[derive(PartialEq, Copy, Clone, AnchorSerialize, AnchorDeserialize)] +pub enum HealthType { + Init, + Maint, +} + +/// Computes health for a mango account given a set of account infos +/// +/// These account infos must fit the fixed layout defined by FixedOrderAccountRetriever. +pub fn compute_health_from_fixed_accounts( + account: &MangoAccountRef, + health_type: HealthType, + ais: &[AccountInfo], +) -> Result { + let retriever = new_fixed_order_account_retriever(ais, account)?; + Ok(new_health_cache(account, &retriever)?.health(health_type)) +} + +/// Compute health with an arbitrary AccountRetriever +pub fn compute_health( + account: &MangoAccountRef, + health_type: HealthType, + retriever: &impl AccountRetriever, +) -> Result { + Ok(new_health_cache(account, retriever)?.health(health_type)) +} + +#[derive(Clone, AnchorDeserialize, AnchorSerialize)] +pub struct TokenInfo { + pub token_index: TokenIndex, + pub maint_asset_weight: I80F48, + pub init_asset_weight: I80F48, + pub maint_liab_weight: I80F48, + pub init_liab_weight: I80F48, + pub prices: Prices, + pub balance_native: I80F48, +} + +impl TokenInfo { + #[inline(always)] + fn asset_weight(&self, health_type: HealthType) -> I80F48 { + match health_type { + HealthType::Init => self.init_asset_weight, + HealthType::Maint => self.maint_asset_weight, + } + } + + #[inline(always)] + fn liab_weight(&self, health_type: HealthType) -> I80F48 { + match health_type { + HealthType::Init => self.init_liab_weight, + HealthType::Maint => self.maint_liab_weight, + } + } + + #[inline(always)] + fn health_contribution(&self, health_type: HealthType) -> I80F48 { + let (weight, price) = if self.balance_native.is_negative() { + (self.liab_weight(health_type), self.prices.liab(health_type)) + } else { + ( + self.asset_weight(health_type), + self.prices.asset(health_type), + ) + }; + cm!(self.balance_native * price * weight) + } +} + +#[derive(Clone, AnchorDeserialize, AnchorSerialize)] +pub struct Serum3Info { + // reserved amounts as stored on the open orders + pub reserved_base: I80F48, + pub reserved_quote: I80F48, + + pub base_index: usize, + pub quote_index: usize, + pub market_index: Serum3MarketIndex, +} + +impl Serum3Info { + #[inline(always)] + fn health_contribution( + &self, + health_type: HealthType, + token_infos: &[TokenInfo], + token_max_reserved: &[I80F48], + market_reserved: &Serum3Reserved, + ) -> I80F48 { + if market_reserved.all_reserved_as_base.is_zero() + || market_reserved.all_reserved_as_quote.is_zero() + { + return I80F48::ZERO; + } + + let base_info = &token_infos[self.base_index]; + let quote_info = &token_infos[self.quote_index]; + let base_max_reserved = token_max_reserved[self.base_index]; + let quote_max_reserved = token_max_reserved[self.quote_index]; + + // How much would health increase if the reserved balance were applied to the passed + // token info? + let compute_health_effect = + |token_info: &TokenInfo, token_max_reserved: I80F48, market_reserved: I80F48| { + // This balance includes all possible reserved funds from markets that relate to the + // token, including this market itself: `market_reserved` is already included in `token_max_reserved`. + let max_balance = cm!(token_info.balance_native + token_max_reserved); + + // For simplicity, we assume that `market_reserved` was added to `max_balance` last + // (it underestimates health because that gives the smallest effects): how much did + // health change because of it? + let (asset_part, liab_part) = if max_balance >= market_reserved { + (market_reserved, I80F48::ZERO) + } else if max_balance.is_negative() { + (I80F48::ZERO, market_reserved) + } else { + (max_balance, cm!(market_reserved - max_balance)) + }; + + let asset_weight = token_info.asset_weight(health_type); + let liab_weight = token_info.liab_weight(health_type); + let asset_price = token_info.prices.asset(health_type); + let liab_price = token_info.prices.liab(health_type); + cm!(asset_part * asset_weight * asset_price + liab_part * liab_weight * liab_price) + }; + + let health_base = compute_health_effect( + base_info, + base_max_reserved, + market_reserved.all_reserved_as_base, + ); + let health_quote = compute_health_effect( + quote_info, + quote_max_reserved, + market_reserved.all_reserved_as_quote, + ); + health_base.min(health_quote) + } +} + +#[derive(Clone)] +struct Serum3Reserved { + /// base tokens when the serum3info.reserved_quote get converted to base and added to reserved_base + all_reserved_as_base: I80F48, + /// ditto the other way around + all_reserved_as_quote: I80F48, +} + +#[derive(Clone, AnchorDeserialize, AnchorSerialize)] +pub struct PerpInfo { + pub perp_market_index: PerpMarketIndex, + pub maint_asset_weight: I80F48, + pub init_asset_weight: I80F48, + pub maint_liab_weight: I80F48, + pub init_liab_weight: I80F48, + pub base_lot_size: i64, + pub base_lots: i64, + pub bids_base_lots: i64, + pub asks_base_lots: i64, + // in health-reference-token native units, no asset/liab factor needed + pub quote: I80F48, + pub prices: Prices, + pub has_open_orders: bool, + pub trusted_market: bool, +} + +impl PerpInfo { + fn new(perp_position: &PerpPosition, perp_market: &PerpMarket, prices: Prices) -> Result { + let base_lots = cm!(perp_position.base_position_lots() + perp_position.taker_base_lots); + + let unsettled_funding = perp_position.unsettled_funding(&perp_market); + let taker_quote = I80F48::from(cm!( + perp_position.taker_quote_lots * perp_market.quote_lot_size + )); + let quote_current = + cm!(perp_position.quote_position_native() - unsettled_funding + taker_quote); + + Ok(Self { + perp_market_index: perp_market.perp_market_index, + init_asset_weight: perp_market.init_asset_weight, + init_liab_weight: perp_market.init_liab_weight, + maint_asset_weight: perp_market.maint_asset_weight, + maint_liab_weight: perp_market.maint_liab_weight, + base_lot_size: perp_market.base_lot_size, + base_lots, + bids_base_lots: perp_position.bids_base_lots, + asks_base_lots: perp_position.asks_base_lots, + quote: quote_current, + prices, + has_open_orders: perp_position.has_open_orders(), + trusted_market: perp_market.trusted_market(), + }) + } + + /// Total health contribution from perp balances + /// + /// Due to isolation of perp markets, users may never borrow against perp + /// positions in untrusted without settling first: perp health is capped at zero. + /// + /// Users need to settle their perp pnl with other perp market participants + /// in order to realize their gains if they want to use them as collateral. + /// + /// This is because we don't trust the perp's base price to not suddenly jump to + /// zero (if users could borrow against their perp balances they might now + /// be bankrupt) or suddenly increase a lot (if users could borrow against perp + /// balances they could now borrow other assets). + #[inline(always)] + fn health_contribution(&self, health_type: HealthType) -> I80F48 { + let c = self.uncapped_health_contribution(health_type); + + if self.trusted_market { + c + } else { + c.min(I80F48::ZERO) + } + } + + #[inline(always)] + fn uncapped_health_contribution(&self, health_type: HealthType) -> I80F48 { + let order_execution_case = |orders_base_lots: i64, order_price: I80F48| { + let net_base_native = + I80F48::from(cm!((self.base_lots + orders_base_lots) * self.base_lot_size)); + let (weight, base_price) = match (health_type, net_base_native.is_negative()) { + (HealthType::Init, true) => (self.init_liab_weight, self.prices.liab(health_type)), + (HealthType::Init, false) => { + (self.init_asset_weight, self.prices.asset(health_type)) + } + (HealthType::Maint, true) => { + (self.maint_liab_weight, self.prices.liab(health_type)) + } + (HealthType::Maint, false) => { + (self.maint_asset_weight, self.prices.asset(health_type)) + } + }; + // Total value of the order-execution adjusted base position + let base_health = cm!(net_base_native * weight * base_price); + + let orders_base_native = I80F48::from(cm!(orders_base_lots * self.base_lot_size)); + // The quote change from executing the bids/asks + let order_quote = cm!(-orders_base_native * order_price); + + cm!(base_health + order_quote) + }; + + // What is worse: Executing all bids at oracle_price.liab, or executing all asks at oracle_price.asset? + let bids_case = order_execution_case(self.bids_base_lots, self.prices.liab(health_type)); + let asks_case = order_execution_case(-self.asks_base_lots, self.prices.asset(health_type)); + let worst_case = bids_case.min(asks_case); + + cm!(self.quote + worst_case) + } +} + +#[derive(Clone, AnchorDeserialize, AnchorSerialize)] +pub struct HealthCache { + pub(crate) token_infos: Vec, + pub(crate) serum3_infos: Vec, + pub(crate) perp_infos: Vec, + pub(crate) being_liquidated: bool, +} + +impl HealthCache { + pub fn health(&self, health_type: HealthType) -> I80F48 { + let mut health = I80F48::ZERO; + let sum = |contrib| { + cm!(health += contrib); + }; + self.health_sum(health_type, sum); + health + } + + pub fn check_health_pre(&self, account: &mut MangoAccountFixed) -> Result { + let pre_health = self.health(HealthType::Init); + msg!("pre_health: {}", pre_health); + account.maybe_recover_from_being_liquidated(pre_health); + require!(!account.being_liquidated(), MangoError::BeingLiquidated); + Ok(pre_health) + } + + pub fn check_health_post( + &self, + account: &mut MangoAccountFixed, + pre_health: I80F48, + ) -> Result<()> { + let post_health = self.health(HealthType::Init); + msg!("post_health: {}", post_health); + require!( + post_health >= 0 || post_health > pre_health, + MangoError::HealthMustBePositiveOrIncrease + ); + account.maybe_recover_from_being_liquidated(post_health); + Ok(()) + } + + pub fn token_info(&self, token_index: TokenIndex) -> Result<&TokenInfo> { + Ok(&self.token_infos[self.token_info_index(token_index)?]) + } + + fn token_info_index(&self, token_index: TokenIndex) -> Result { + self.token_infos + .iter() + .position(|t| t.token_index == token_index) + .ok_or_else(|| error_msg!("token index {} not found", token_index)) + } + + pub fn adjust_token_balance(&mut self, token_index: TokenIndex, change: I80F48) -> Result<()> { + let entry_index = self.token_info_index(token_index)?; + let mut entry = &mut self.token_infos[entry_index]; + + // Work around the fact that -((-x) * y) == x * y does not hold for I80F48: + // We need to make sure that if balance is before * price, then change = -before + // brings it to exactly zero. + let removed_contribution = -change; + cm!(entry.balance_native -= removed_contribution); + Ok(()) + } + + pub fn adjust_serum3_reserved( + &mut self, + market_index: Serum3MarketIndex, + base_token_index: TokenIndex, + reserved_base_change: I80F48, + free_base_change: I80F48, + quote_token_index: TokenIndex, + reserved_quote_change: I80F48, + free_quote_change: I80F48, + ) -> Result<()> { + let base_entry_index = self.token_info_index(base_token_index)?; + let quote_entry_index = self.token_info_index(quote_token_index)?; + + // Apply it to the tokens + { + let base_entry = &mut self.token_infos[base_entry_index]; + cm!(base_entry.balance_native += free_base_change); + } + { + let quote_entry = &mut self.token_infos[quote_entry_index]; + cm!(quote_entry.balance_native += free_quote_change); + } + + // Apply it to the serum3 info + let market_entry = self + .serum3_infos + .iter_mut() + .find(|m| m.market_index == market_index) + .ok_or_else(|| error_msg!("serum3 market {} not found", market_index))?; + cm!(market_entry.reserved_base += reserved_base_change); + cm!(market_entry.reserved_quote += reserved_quote_change); + Ok(()) + } + + pub fn recompute_perp_info( + &mut self, + perp_position: &PerpPosition, + perp_market: &PerpMarket, + ) -> Result<()> { + let perp_entry = self + .perp_infos + .iter_mut() + .find(|m| m.perp_market_index == perp_market.perp_market_index) + .ok_or_else(|| error_msg!("perp market {} not found", perp_market.perp_market_index))?; + *perp_entry = PerpInfo::new(perp_position, perp_market, perp_entry.prices.clone())?; + Ok(()) + } + + pub fn has_liquidatable_assets(&self) -> bool { + let spot_liquidatable = self.token_infos.iter().any(|ti| { + // can use token_liq_with_token + ti.balance_native.is_positive() + }); + // can use serum3_liq_force_cancel_orders + let serum3_cancelable = self + .serum3_infos + .iter() + .any(|si| si.reserved_base != 0 || si.reserved_quote != 0); + let perp_liquidatable = self.perp_infos.iter().any(|p| { + // can use perp_liq_base_position + p.base_lots != 0 + // can use perp_settle_pnl + || p.quote > ONE_NATIVE_USDC_IN_USD // TODO: we're not guaranteed to be able to settle positive perp pnl! + // can use perp_liq_force_cancel_orders + || p.has_open_orders + }); + spot_liquidatable || serum3_cancelable || perp_liquidatable + } + + pub fn has_spot_borrows(&self) -> bool { + self.token_infos + .iter() + .any(|ti| ti.balance_native.is_negative()) + } + + pub fn has_borrows(&self) -> bool { + let perp_borrows = self + .perp_infos + .iter() + .any(|p| p.quote.is_negative() || p.base_lots != 0); + self.has_spot_borrows() || perp_borrows + } + + fn compute_serum3_reservations( + &self, + health_type: HealthType, + ) -> (Vec, Vec) { + // For each token, compute the sum of serum-reserved amounts over all markets. + let mut token_max_reserved = vec![I80F48::ZERO; self.token_infos.len()]; + + // For each serum market, compute what happened if reserved_base was converted to quote + // or reserved_quote was converted to base. + let mut serum3_reserved = Vec::with_capacity(self.serum3_infos.len()); + + for info in self.serum3_infos.iter() { + let quote = &self.token_infos[info.quote_index]; + let base = &self.token_infos[info.base_index]; + + let reserved_base = info.reserved_base; + let reserved_quote = info.reserved_quote; + + let quote_asset = quote.prices.asset(health_type); + let base_liab = base.prices.liab(health_type); + // OPTIMIZATION: These divisions can be extremely expensive (up to 5k CU each) + let all_reserved_as_base = + cm!(reserved_base + reserved_quote * quote_asset / base_liab); + + let base_asset = base.prices.asset(health_type); + let quote_liab = quote.prices.liab(health_type); + let all_reserved_as_quote = + cm!(reserved_quote + reserved_base * base_asset / quote_liab); + + let base_max_reserved = &mut token_max_reserved[info.base_index]; + // note: cm!() does not work with mutable references + *base_max_reserved = base_max_reserved.checked_add(all_reserved_as_base).unwrap(); + let quote_max_reserved = &mut token_max_reserved[info.quote_index]; + *quote_max_reserved = quote_max_reserved + .checked_add(all_reserved_as_quote) + .unwrap(); + + serum3_reserved.push(Serum3Reserved { + all_reserved_as_base, + all_reserved_as_quote, + }); + } + + (token_max_reserved, serum3_reserved) + } + + fn health_sum(&self, health_type: HealthType, mut action: impl FnMut(I80F48)) { + for token_info in self.token_infos.iter() { + let contrib = token_info.health_contribution(health_type); + action(contrib); + } + + let (token_max_reserved, serum3_reserved) = self.compute_serum3_reservations(health_type); + for (serum3_info, reserved) in self.serum3_infos.iter().zip(serum3_reserved.iter()) { + let contrib = serum3_info.health_contribution( + health_type, + &self.token_infos, + &token_max_reserved, + reserved, + ); + action(contrib); + } + + for perp_info in self.perp_infos.iter() { + let contrib = perp_info.health_contribution(health_type); + action(contrib); + } + } + + /// Compute the health when it comes to settling perp pnl + /// + /// Examples: + /// - An account may have maint_health < 0, but settling perp pnl could still be allowed. + /// (+100 USDC health, -50 USDT health, -50 perp health -> allow settling 50 health worth) + /// - Positive health from trusted pnl markets counts + /// - If overall health is 0 with two trusted perp pnl < 0, settling may still be possible. + /// (+100 USDC health, -150 perp1 health, -150 perp2 health -> allow settling 100 health worth) + /// - Positive trusted perp pnl can enable settling. + /// (+100 trusted perp1 health, -100 perp2 health -> allow settling of 100 health worth) + pub fn perp_settle_health(&self) -> I80F48 { + let health_type = HealthType::Maint; + let mut health = I80F48::ZERO; + for token_info in self.token_infos.iter() { + let contrib = token_info.health_contribution(health_type); + cm!(health += contrib); + } + + let (token_max_reserved, serum3_reserved) = self.compute_serum3_reservations(health_type); + for (serum3_info, reserved) in self.serum3_infos.iter().zip(serum3_reserved.iter()) { + let contrib = serum3_info.health_contribution( + health_type, + &self.token_infos, + &token_max_reserved, + reserved, + ); + cm!(health += contrib); + } + + for perp_info in self.perp_infos.iter() { + if perp_info.trusted_market { + let positive_contrib = perp_info.health_contribution(health_type).max(I80F48::ZERO); + cm!(health += positive_contrib); + } + } + health + } + + /// Sum of only the positive health components (assets) and + /// sum of absolute values of all negative health components (liabs, always >= 0) + pub fn health_assets_and_liabs(&self, health_type: HealthType) -> (I80F48, I80F48) { + let mut assets = I80F48::ZERO; + let mut liabs = I80F48::ZERO; + let sum = |contrib| { + if contrib > 0 { + cm!(assets += contrib); + } else { + cm!(liabs -= contrib); + } + }; + self.health_sum(health_type, sum); + (assets, liabs) + } +} + +pub(crate) fn find_token_info_index(infos: &[TokenInfo], token_index: TokenIndex) -> Result { + infos + .iter() + .position(|ti| ti.token_index == token_index) + .ok_or_else(|| error_msg!("token index {} not found", token_index)) +} + +/// Generate a HealthCache for an account and its health accounts. +pub fn new_health_cache( + account: &MangoAccountRef, + retriever: &impl AccountRetriever, +) -> Result { + // token contribution from token accounts + let mut token_infos = vec![]; + + for (i, position) in account.active_token_positions().enumerate() { + let (bank, oracle_price) = + retriever.bank_and_oracle(&account.fixed.group, i, position.token_index)?; + + let native = position.native(bank); + token_infos.push(TokenInfo { + token_index: bank.token_index, + maint_asset_weight: bank.maint_asset_weight, + init_asset_weight: bank.init_asset_weight, + maint_liab_weight: bank.maint_liab_weight, + init_liab_weight: bank.init_liab_weight, + prices: Prices { + oracle: oracle_price, + stable: bank.stable_price(), + }, + balance_native: native, + }); + } + + // Fill the TokenInfo balance with free funds in serum3 oo accounts and build Serum3Infos. + let mut serum3_infos = vec![]; + for (i, serum_account) in account.active_serum3_orders().enumerate() { + let oo = retriever.serum_oo(i, &serum_account.open_orders)?; + + // find the TokenInfos for the market's base and quote tokens + let base_index = find_token_info_index(&token_infos, serum_account.base_token_index)?; + let quote_index = find_token_info_index(&token_infos, serum_account.quote_token_index)?; + + // add the amounts that are freely settleable immediately to token balances + let base_free = I80F48::from(oo.native_coin_free); + let quote_free = I80F48::from(cm!(oo.native_pc_free + oo.referrer_rebates_accrued)); + let base_info = &mut token_infos[base_index]; + cm!(base_info.balance_native += base_free); + let quote_info = &mut token_infos[quote_index]; + cm!(quote_info.balance_native += quote_free); + + // track the reserved amounts + let reserved_base = I80F48::from(cm!(oo.native_coin_total - oo.native_coin_free)); + let reserved_quote = I80F48::from(cm!(oo.native_pc_total - oo.native_pc_free)); + + serum3_infos.push(Serum3Info { + reserved_base, + reserved_quote, + base_index, + quote_index, + market_index: serum_account.market_index, + }); + } + + // health contribution from perp accounts + let mut perp_infos = Vec::with_capacity(account.active_perp_positions().count()); + for (i, perp_position) in account.active_perp_positions().enumerate() { + let (perp_market, oracle_price) = retriever.perp_market_and_oracle_price( + &account.fixed.group, + i, + perp_position.market_index, + )?; + perp_infos.push(PerpInfo::new( + perp_position, + perp_market, + Prices { + oracle: oracle_price, + stable: perp_market.stable_price(), + }, + )?); + } + + Ok(HealthCache { + token_infos, + serum3_infos, + perp_infos, + being_liquidated: account.fixed.being_liquidated(), + }) +} + +#[cfg(test)] +mod tests { + use super::super::test::*; + use super::*; + use crate::state::*; + use serum_dex::state::OpenOrders; + use std::str::FromStr; + + #[test] + fn test_precision() { + // I80F48 can only represent until 1/2^48 + assert_ne!( + I80F48::from_num(1_u128) / I80F48::from_num(2_u128.pow(48)), + 0 + ); + assert_eq!( + I80F48::from_num(1_u128) / I80F48::from_num(2_u128.pow(49)), + 0 + ); + + // I80F48 can only represent until 14 decimal points + assert_ne!( + I80F48::from_str(format!("0.{}1", "0".repeat(13)).as_str()).unwrap(), + 0 + ); + assert_eq!( + I80F48::from_str(format!("0.{}1", "0".repeat(14)).as_str()).unwrap(), + 0 + ); + } + + fn health_eq(a: I80F48, b: f64) -> bool { + if (a - I80F48::from_num(b)).abs() < 0.001 { + true + } else { + println!("health is {}, but expected {}", a, b); + false + } + } + + // Run a health test that includes all the side values (like referrer_rebates_accrued) + #[test] + fn test_health0() { + let buffer = MangoAccount::default_for_tests().try_to_vec().unwrap(); + let mut account = MangoAccountValue::from_bytes(&buffer).unwrap(); + + let group = Pubkey::new_unique(); + + let (mut bank1, mut oracle1) = mock_bank_and_oracle(group, 1, 1.0, 0.2, 0.1); + let (mut bank2, mut oracle2) = mock_bank_and_oracle(group, 4, 5.0, 0.5, 0.3); + bank1 + .data() + .deposit( + account.ensure_token_position(1).unwrap().0, + I80F48::from(100), + DUMMY_NOW_TS, + ) + .unwrap(); + bank2 + .data() + .withdraw_without_fee( + account.ensure_token_position(4).unwrap().0, + I80F48::from(10), + DUMMY_NOW_TS, + ) + .unwrap(); + + let mut oo1 = TestAccount::::new_zeroed(); + let serum3account = account.create_serum3_orders(2).unwrap(); + serum3account.open_orders = oo1.pubkey; + serum3account.base_token_index = 4; + serum3account.quote_token_index = 1; + oo1.data().native_pc_total = 21; + oo1.data().native_coin_total = 18; + oo1.data().native_pc_free = 1; + oo1.data().native_coin_free = 3; + oo1.data().referrer_rebates_accrued = 2; + + let mut perp1 = mock_perp_market(group, oracle2.pubkey, 5.0, 9, 0.2, 0.1); + let perpaccount = account.ensure_perp_position(9, 1).unwrap().0; + perpaccount.record_trade(perp1.data(), 3, -I80F48::from(310u16)); + perpaccount.bids_base_lots = 7; + perpaccount.asks_base_lots = 11; + perpaccount.taker_base_lots = 1; + perpaccount.taker_quote_lots = 2; + + let oracle2_ai = oracle2.as_account_info(); + + let ais = vec![ + bank1.as_account_info(), + bank2.as_account_info(), + oracle1.as_account_info(), + oracle2_ai.clone(), + perp1.as_account_info(), + oracle2_ai, + oo1.as_account_info(), + ]; + + let retriever = ScanningAccountRetriever::new_with_staleness(&ais, &group, None).unwrap(); + + // for bank1/oracle1, including open orders (scenario: bids execute) + let health1 = (100.0 + 1.0 + 2.0 + (20.0 + 15.0 * 5.0)) * 0.8; + // for bank2/oracle2 + let health2 = (-10.0 + 3.0) * 5.0 * 1.5; + // for perp (scenario: bids execute) + let health3 = + (3.0 + 7.0 + 1.0) * 10.0 * 5.0 * 0.8 + (-310.0 + 2.0 * 100.0 - 7.0 * 10.0 * 5.0); + assert!(health_eq( + compute_health(&account.borrow(), HealthType::Init, &retriever).unwrap(), + health1 + health2 + health3 + )); + } + + #[derive(Default)] + struct TestHealth1Case { + token1: i64, + token2: i64, + token3: i64, + oo_1_2: (u64, u64), + oo_1_3: (u64, u64), + perp1: (i64, i64, i64, i64), + expected_health: f64, + } + fn test_health1_runner(testcase: &TestHealth1Case) { + let buffer = MangoAccount::default_for_tests().try_to_vec().unwrap(); + let mut account = MangoAccountValue::from_bytes(&buffer).unwrap(); + + let group = Pubkey::new_unique(); + + let (mut bank1, mut oracle1) = mock_bank_and_oracle(group, 1, 1.0, 0.2, 0.1); + let (mut bank2, mut oracle2) = mock_bank_and_oracle(group, 4, 5.0, 0.5, 0.3); + let (mut bank3, mut oracle3) = mock_bank_and_oracle(group, 5, 10.0, 0.5, 0.3); + bank1 + .data() + .change_without_fee( + account.ensure_token_position(1).unwrap().0, + I80F48::from(testcase.token1), + DUMMY_NOW_TS, + ) + .unwrap(); + bank2 + .data() + .change_without_fee( + account.ensure_token_position(4).unwrap().0, + I80F48::from(testcase.token2), + DUMMY_NOW_TS, + ) + .unwrap(); + bank3 + .data() + .change_without_fee( + account.ensure_token_position(5).unwrap().0, + I80F48::from(testcase.token3), + DUMMY_NOW_TS, + ) + .unwrap(); + + let mut oo1 = TestAccount::::new_zeroed(); + let serum3account1 = account.create_serum3_orders(2).unwrap(); + serum3account1.open_orders = oo1.pubkey; + serum3account1.base_token_index = 4; + serum3account1.quote_token_index = 1; + oo1.data().native_pc_total = testcase.oo_1_2.0; + oo1.data().native_coin_total = testcase.oo_1_2.1; + + let mut oo2 = TestAccount::::new_zeroed(); + let serum3account2 = account.create_serum3_orders(3).unwrap(); + serum3account2.open_orders = oo2.pubkey; + serum3account2.base_token_index = 5; + serum3account2.quote_token_index = 1; + oo2.data().native_pc_total = testcase.oo_1_3.0; + oo2.data().native_coin_total = testcase.oo_1_3.1; + + let mut perp1 = mock_perp_market(group, oracle2.pubkey, 5.0, 9, 0.2, 0.1); + let perpaccount = account.ensure_perp_position(9, 1).unwrap().0; + perpaccount.record_trade( + perp1.data(), + testcase.perp1.0, + I80F48::from(testcase.perp1.1), + ); + perpaccount.bids_base_lots = testcase.perp1.2; + perpaccount.asks_base_lots = testcase.perp1.3; + + let oracle2_ai = oracle2.as_account_info(); + let ais = vec![ + bank1.as_account_info(), + bank2.as_account_info(), + bank3.as_account_info(), + oracle1.as_account_info(), + oracle2_ai.clone(), + oracle3.as_account_info(), + perp1.as_account_info(), + oracle2_ai, + oo1.as_account_info(), + oo2.as_account_info(), + ]; + + let retriever = ScanningAccountRetriever::new_with_staleness(&ais, &group, None).unwrap(); + + assert!(health_eq( + compute_health(&account.borrow(), HealthType::Init, &retriever).unwrap(), + testcase.expected_health + )); + } + + // Check some specific health constellations + #[test] + fn test_health1() { + let base_price = 5.0; + let base_lots_to_quote = 10.0 * base_price; + let testcases = vec![ + TestHealth1Case { // 0 + token1: 100, + token2: -10, + oo_1_2: (20, 15), + perp1: (3, -131, 7, 11), + expected_health: + // for token1, including open orders (scenario: bids execute) + (100.0 + (20.0 + 15.0 * base_price)) * 0.8 + // for token2 + - 10.0 * base_price * 1.5 + // for perp (scenario: bids execute) + + (3.0 + 7.0) * base_lots_to_quote * 0.8 + (-131.0 - 7.0 * base_lots_to_quote), + ..Default::default() + }, + TestHealth1Case { // 1 + token1: -100, + token2: 10, + oo_1_2: (20, 15), + perp1: (-10, -131, 7, 11), + expected_health: + // for token1 + -100.0 * 1.2 + // for token2, including open orders (scenario: asks execute) + + (10.0 * base_price + (20.0 + 15.0 * base_price)) * 0.5 + // for perp (scenario: asks execute) + + (-10.0 - 11.0) * base_lots_to_quote * 1.2 + (-131.0 + 11.0 * base_lots_to_quote), + ..Default::default() + }, + TestHealth1Case { + // 2 + perp1: (-1, 100, 0, 0), + expected_health: 0.0, + ..Default::default() + }, + TestHealth1Case { + // 3 + perp1: (1, -100, 0, 0), + expected_health: -100.0 + 0.8 * 1.0 * base_lots_to_quote, + ..Default::default() + }, + TestHealth1Case { + // 4 + perp1: (10, 100, 0, 0), + expected_health: 0.0, + ..Default::default() + }, + TestHealth1Case { + // 5 + perp1: (30, -100, 0, 0), + expected_health: 0.0, + ..Default::default() + }, + TestHealth1Case { // 6, reserved oo funds + token1: -100, + token2: -10, + token3: -10, + oo_1_2: (1, 1), + oo_1_3: (1, 1), + expected_health: + // tokens + -100.0 * 1.2 - 10.0 * 5.0 * 1.5 - 10.0 * 10.0 * 1.5 + // oo_1_2 (-> token1) + + (1.0 + 5.0) * 1.2 + // oo_1_3 (-> token1) + + (1.0 + 10.0) * 1.2, + ..Default::default() + }, + TestHealth1Case { // 7, reserved oo funds cross the zero balance level + token1: -14, + token2: -10, + token3: -10, + oo_1_2: (1, 1), + oo_1_3: (1, 1), + expected_health: + // tokens + -14.0 * 1.2 - 10.0 * 5.0 * 1.5 - 10.0 * 10.0 * 1.5 + // oo_1_2 (-> token1) + + 3.0 * 1.2 + 3.0 * 0.8 + // oo_1_3 (-> token1) + + 8.0 * 1.2 + 3.0 * 0.8, + ..Default::default() + }, + TestHealth1Case { // 8, reserved oo funds in a non-quote currency + token1: -100, + token2: -100, + token3: -1, + oo_1_2: (0, 0), + oo_1_3: (10, 1), + expected_health: + // tokens + -100.0 * 1.2 - 100.0 * 5.0 * 1.5 - 10.0 * 1.5 + // oo_1_3 (-> token3) + + 10.0 * 1.5 + 10.0 * 0.5, + ..Default::default() + }, + TestHealth1Case { // 9, like 8 but oo_1_2 flips the oo_1_3 target + token1: -100, + token2: -100, + token3: -1, + oo_1_2: (100, 0), + oo_1_3: (10, 1), + expected_health: + // tokens + -100.0 * 1.2 - 100.0 * 5.0 * 1.5 - 10.0 * 1.5 + // oo_1_2 (-> token1) + + 80.0 * 1.2 + 20.0 * 0.8 + // oo_1_3 (-> token1) + + 20.0 * 0.8, + ..Default::default() + }, + ]; + + for (i, testcase) in testcases.iter().enumerate() { + println!("checking testcase {}", i); + test_health1_runner(testcase); + } + } +} diff --git a/programs/mango-v4/src/state/health/client.rs b/programs/mango-v4/src/state/health/client.rs new file mode 100644 index 000000000..089d49673 --- /dev/null +++ b/programs/mango-v4/src/state/health/client.rs @@ -0,0 +1,827 @@ +#![cfg(feature = "client")] + +use anchor_lang::prelude::*; + +use fixed::types::I80F48; +use fixed_macro::types::I80F48; + +use crate::error::*; +use crate::state::{PerpMarketIndex, TokenIndex}; +use crate::util::checked_math as cm; + +use super::*; +use crate::state::orderbook::Side as PerpOrderSide; + +impl HealthCache { + pub fn can_call_spot_bankruptcy(&self) -> bool { + !self.has_liquidatable_assets() && self.has_spot_borrows() + } + + pub fn is_liquidatable(&self) -> bool { + if self.being_liquidated { + self.health(HealthType::Init).is_negative() + } else { + self.health(HealthType::Maint).is_negative() + } + } + + /// The health ratio is + /// - 0 if health is 0 - meaning assets = liabs + /// - 100 if there's 2x as many assets as liabs + /// - 200 if there's 3x as many assets as liabs + /// - MAX if liabs = 0 + /// + /// Maybe talking about the collateralization ratio assets/liabs is more intuitive? + pub fn health_ratio(&self, health_type: HealthType) -> I80F48 { + let (assets, liabs) = self.health_assets_and_liabs(health_type); + let hundred = I80F48::from(100); + if liabs > 0 { + // feel free to saturate to MAX for tiny liabs + cm!(hundred * (assets - liabs)).saturating_div(liabs) + } else { + I80F48::MAX + } + } + + /// How much source native tokens may be swapped for target tokens while staying + /// above the min_ratio health ratio. + /// + /// `price`: The amount of target native you receive for one source native. So if we + /// swap BTC -> SOL and they're at ui prices of $20000 and $40, that means price + /// should be 500000 native_SOL for a native_BTC. Because 1 BTC gives you 500 SOL + /// so 1e6 native_BTC gives you 500e9 native_SOL. + /// + /// NOTE: keep getMaxSourceForTokenSwap in ts/client in sync with changes here + pub fn max_swap_source_for_health_ratio( + &self, + source: TokenIndex, + target: TokenIndex, + price: I80F48, + min_ratio: I80F48, + ) -> Result { + // The health_ratio is nonlinear based on swap amount. + // For large swap amounts the slope is guaranteed to be negative (unless the price + // is extremely good), but small amounts can have positive slope (e.g. using + // source deposits to pay back target borrows). + // + // That means: + // - even if the initial ratio is < min_ratio it can be useful to swap to *increase* health + // - be careful about finding the min_ratio point: the function isn't convex + + let health_type = HealthType::Init; + let initial_ratio = self.health_ratio(health_type); + if initial_ratio < 0 { + return Ok(I80F48::ZERO); + } + + let source_index = find_token_info_index(&self.token_infos, source)?; + let target_index = find_token_info_index(&self.token_infos, target)?; + let source = &self.token_infos[source_index]; + let target = &self.token_infos[target_index]; + + // If the price is sufficiently good, then health will just increase from swapping: + // once we've swapped enough, swapping x reduces health by x * source_liab_weight and + // increases it by x * target_asset_weight * price_factor. + let final_health_slope = -source.init_liab_weight * source.prices.liab(health_type) + + target.init_asset_weight * target.prices.asset(health_type) * price; + if final_health_slope >= 0 { + return Ok(I80F48::MAX); + } + + let cache_after_swap = |amount: I80F48| { + let mut adjusted_cache = self.clone(); + adjusted_cache.token_infos[source_index].balance_native -= amount; + adjusted_cache.token_infos[target_index].balance_native += cm!(amount * price); + adjusted_cache + }; + let health_ratio_after_swap = + |amount| cache_after_swap(amount).health_ratio(HealthType::Init); + + // There are two key slope changes: Assume source.balance > 0 and target.balance < 0. + // When these values flip sign, the health slope decreases, but could still be positive. + // After point1 it's definitely negative (due to final_health_slope check above). + // The maximum health ratio will be at 0 or at one of these points (ignoring serum3 effects). + let source_for_zero_target_balance = -target.balance_native / price; + let point0_amount = source + .balance_native + .min(source_for_zero_target_balance) + .max(I80F48::ZERO); + let point1_amount = source + .balance_native + .max(source_for_zero_target_balance) + .max(I80F48::ZERO); + let point0_ratio = health_ratio_after_swap(point0_amount); + let (point1_ratio, point1_health) = { + let cache = cache_after_swap(point1_amount); + ( + cache.health_ratio(HealthType::Init), + cache.health(HealthType::Init), + ) + }; + + let amount = + if initial_ratio <= min_ratio && point0_ratio < min_ratio && point1_ratio < min_ratio { + // If we have to stay below the target ratio, pick the highest one + if point0_ratio > initial_ratio { + if point1_ratio > point0_ratio { + point1_amount + } else { + point0_amount + } + } else if point1_ratio > initial_ratio { + point1_amount + } else { + I80F48::ZERO + } + } else if point1_ratio >= min_ratio { + // If point1_ratio is still bigger than min_ratio, the target amount must be >point1_amount + // search to the right of point1_amount: but how far? + // At point1, source.balance < 0 and target.balance > 0, so use a simple estimation for + // zero health: health + // - source_liab_weight * source_liab_price * a + // + target_asset_weight * target_asset_price * price * a = 0. + // where a is the source token native amount. + if point1_health <= 0 { + return Ok(I80F48::ZERO); + } + let zero_health_amount = point1_amount - point1_health / final_health_slope; + let zero_health_ratio = health_ratio_after_swap(zero_health_amount); + binary_search( + point1_amount, + point1_ratio, + zero_health_amount, + zero_health_ratio, + min_ratio, + I80F48::ZERO, + health_ratio_after_swap, + )? + } else if point0_ratio >= min_ratio { + // Must be between point0_amount and point1_amount. + binary_search( + point0_amount, + point0_ratio, + point1_amount, + point1_ratio, + min_ratio, + I80F48::ZERO, + health_ratio_after_swap, + )? + } else { + // Must be between 0 and point0_amount + binary_search( + I80F48::ZERO, + initial_ratio, + point0_amount, + point0_ratio, + min_ratio, + I80F48::ZERO, + health_ratio_after_swap, + )? + }; + + Ok(amount) + } + + fn perp_info_index(&self, perp_market_index: PerpMarketIndex) -> Result { + self.perp_infos + .iter() + .position(|pi| pi.perp_market_index == perp_market_index) + .ok_or_else(|| error_msg!("perp market index {} not found", perp_market_index)) + } + + /// NOTE: keep getMaxSourceForTokenSwap in ts/client in sync with changes here + pub fn max_perp_for_health_ratio( + &self, + perp_market_index: PerpMarketIndex, + price: I80F48, + side: PerpOrderSide, + min_ratio: I80F48, + ) -> Result { + let health_type = HealthType::Init; + let initial_ratio = self.health_ratio(health_type); + if initial_ratio < 0 { + return Ok(0); + } + + let direction = match side { + PerpOrderSide::Bid => 1, + PerpOrderSide::Ask => -1, + }; + + let perp_info_index = self.perp_info_index(perp_market_index)?; + let perp_info = &self.perp_infos[perp_info_index]; + let prices = &perp_info.prices; + let base_lot_size = I80F48::from(perp_info.base_lot_size); + + // If the price is sufficiently good then health will just increase from trading + // TODO: This is not actually correct, since perp health for untrusted markets can't go above 0 + let final_health_slope = if direction == 1 { + perp_info.init_asset_weight * prices.asset(health_type) - price + } else { + price - perp_info.init_liab_weight * prices.liab(health_type) + }; + if final_health_slope >= 0 { + return Ok(i64::MAX); + } + + let cache_after_trade = |base_lots: i64| { + let mut adjusted_cache = self.clone(); + adjusted_cache.perp_infos[perp_info_index].base_lots += direction * base_lots; + adjusted_cache.perp_infos[perp_info_index].quote -= + I80F48::from(direction) * I80F48::from(base_lots) * base_lot_size * price; + adjusted_cache + }; + let health_ratio_after_trade = + |base_lots: i64| cache_after_trade(base_lots).health_ratio(HealthType::Init); + let health_ratio_after_trade_trunc = + |base_lots: I80F48| health_ratio_after_trade(base_lots.round_to_zero().to_num()); + + let initial_base_lots = perp_info.base_lots; + + // There are two cases: + // 1. We are increasing abs(base_lots) + // 2. We are bringing the base position to 0, and then going to case 1. + let has_case2 = + initial_base_lots > 0 && direction == -1 || initial_base_lots < 0 && direction == 1; + + let (case1_start, case1_start_ratio) = if has_case2 { + let case1_start = initial_base_lots.abs(); + let case1_start_ratio = health_ratio_after_trade(case1_start); + (case1_start, case1_start_ratio) + } else { + (0, initial_ratio) + }; + let case1_start_i80f48 = I80F48::from(case1_start); + + // If we start out below min_ratio and can't go above, pick the best case + let base_lots = if initial_ratio <= min_ratio && case1_start_ratio < min_ratio { + if case1_start_ratio >= initial_ratio { + case1_start_i80f48 + } else { + I80F48::ZERO + } + } else if case1_start_ratio >= min_ratio { + // Must reach min_ratio to the right of case1_start + let case1_start_health = cache_after_trade(case1_start).health(HealthType::Init); + if case1_start_health <= 0 { + return Ok(0); + } + // We add 1 here because health is computed for truncated base_lots and we want to guarantee + // zero_health_ratio <= 0. + let zero_health_amount = case1_start_i80f48 + - case1_start_health / final_health_slope / base_lot_size + + I80F48::ONE; + let zero_health_ratio = health_ratio_after_trade_trunc(zero_health_amount); + + binary_search( + case1_start_i80f48, + case1_start_ratio, + zero_health_amount, + zero_health_ratio, + min_ratio, + I80F48::ONE, + health_ratio_after_trade_trunc, + )? + } else { + // Between 0 and case1_start + binary_search( + I80F48::ZERO, + initial_ratio, + case1_start_i80f48, + case1_start_ratio, + min_ratio, + I80F48::ONE, + health_ratio_after_trade_trunc, + )? + }; + + Ok(base_lots.round_to_zero().to_num()) + } +} + +fn binary_search( + mut left: I80F48, + left_value: I80F48, + mut right: I80F48, + right_value: I80F48, + target_value: I80F48, + min_step: I80F48, + fun: impl Fn(I80F48) -> I80F48, +) -> Result { + let max_iterations = 20; + let target_error = I80F48!(0.1); + require_msg!( + (left_value - target_value).signum() * (right_value - target_value).signum() != I80F48::ONE, + "internal error: left {} and right {} don't contain the target value {}", + left_value, + right_value, + target_value + ); + for _ in 0..max_iterations { + if (right - left).abs() < min_step { + return Ok(left); + } + let new = I80F48::from_num(0.5) * (left + right); + let new_value = fun(new); + println!("l {} r {} v {}", left, right, new_value); + let error = new_value - target_value; + if error > 0 && error < target_error { + return Ok(new); + } + + if (new_value > target_value) ^ (right_value > target_value) { + left = new; + } else { + right = new; + } + } + Err(error_msg!("binary search iterations exhausted")) +} + +#[cfg(test)] +mod tests { + use super::super::test::*; + use super::*; + use crate::state::*; + use serum_dex::state::OpenOrders; + + fn health_eq(a: I80F48, b: f64) -> bool { + if (a - I80F48::from_num(b)).abs() < 0.001 { + true + } else { + println!("health is {}, but expected {}", a, b); + false + } + } + + #[test] + fn test_max_swap() { + let default_token_info = |x| TokenInfo { + token_index: 0, + maint_asset_weight: I80F48::from_num(1.0 - x), + init_asset_weight: I80F48::from_num(1.0 - x), + maint_liab_weight: I80F48::from_num(1.0 + x), + init_liab_weight: I80F48::from_num(1.0 + x), + prices: Prices::new_single_price(I80F48::from_num(2.0)), + balance_native: I80F48::ZERO, + }; + + let health_cache = HealthCache { + token_infos: vec![ + TokenInfo { + token_index: 0, + prices: Prices::new_single_price(I80F48::from_num(2.0)), + ..default_token_info(0.1) + }, + TokenInfo { + token_index: 1, + prices: Prices::new_single_price(I80F48::from_num(3.0)), + ..default_token_info(0.2) + }, + TokenInfo { + token_index: 2, + prices: Prices::new_single_price(I80F48::from_num(4.0)), + ..default_token_info(0.3) + }, + ], + serum3_infos: vec![], + perp_infos: vec![], + being_liquidated: false, + }; + + assert_eq!(health_cache.health(HealthType::Init), I80F48::ZERO); + assert_eq!(health_cache.health_ratio(HealthType::Init), I80F48::MAX); + assert_eq!( + health_cache + .max_swap_source_for_health_ratio( + 0, + 1, + I80F48::from_num(2.0 / 3.0), + I80F48::from_num(50.0) + ) + .unwrap(), + I80F48::ZERO + ); + + let adjust_by_usdc = |c: &mut HealthCache, ti: TokenIndex, usdc: f64| { + let ti = &mut c.token_infos[ti as usize]; + ti.balance_native += I80F48::from_num(usdc) / ti.prices.oracle; + }; + let find_max_swap_actual = |c: &HealthCache, + source: TokenIndex, + target: TokenIndex, + ratio: f64, + price_factor: f64| { + let mut c = c.clone(); + let source_price = &c.token_infos[source as usize].prices; + let target_price = &c.token_infos[target as usize].prices; + let swap_price = + I80F48::from_num(price_factor) * source_price.oracle / target_price.oracle; + let source_amount = c + .max_swap_source_for_health_ratio( + source, + target, + swap_price, + I80F48::from_num(ratio), + ) + .unwrap(); + if source_amount == I80F48::MAX { + return (f64::MAX, f64::MAX); + } + c.adjust_token_balance(source, -source_amount).unwrap(); + c.adjust_token_balance(target, source_amount * swap_price) + .unwrap(); + ( + source_amount.to_num::(), + c.health_ratio(HealthType::Init).to_num::(), + ) + }; + let check_max_swap_result = |c: &HealthCache, + source: TokenIndex, + target: TokenIndex, + ratio: f64, + price_factor: f64| { + let (source_amount, actual_ratio) = + find_max_swap_actual(c, source, target, ratio, price_factor); + println!( + "checking {source} to {target} for price_factor: {price_factor}, target ratio {ratio}: actual ratio: {actual_ratio}, amount: {source_amount}", + ); + assert!((ratio - actual_ratio).abs() < 1.0); + }; + + { + println!("test 0"); + let mut health_cache = health_cache.clone(); + adjust_by_usdc(&mut health_cache, 1, 100.0); + + for price_factor in [0.1, 0.9, 1.1] { + for target in 1..100 { + let target = target as f64; + check_max_swap_result(&health_cache, 0, 1, target, price_factor); + check_max_swap_result(&health_cache, 1, 0, target, price_factor); + check_max_swap_result(&health_cache, 0, 2, target, price_factor); + } + } + + // At this unlikely price it's healthy to swap infinitely + assert_eq!( + find_max_swap_actual(&health_cache, 0, 1, 50.0, 1.5).0, + f64::MAX + ); + } + + { + println!("test 1"); + let mut health_cache = health_cache.clone(); + adjust_by_usdc(&mut health_cache, 0, -20.0); + adjust_by_usdc(&mut health_cache, 1, 100.0); + + for price_factor in [0.1, 0.9, 1.1] { + for target in 1..100 { + let target = target as f64; + check_max_swap_result(&health_cache, 0, 1, target, price_factor); + check_max_swap_result(&health_cache, 1, 0, target, price_factor); + check_max_swap_result(&health_cache, 0, 2, target, price_factor); + check_max_swap_result(&health_cache, 2, 0, target, price_factor); + } + } + } + + { + println!("test 2"); + let mut health_cache = health_cache.clone(); + adjust_by_usdc(&mut health_cache, 0, -50.0); + adjust_by_usdc(&mut health_cache, 1, 100.0); + // possible even though the init ratio is <100 + check_max_swap_result(&health_cache, 1, 0, 100.0, 1.0); + } + + { + println!("test 3"); + let mut health_cache = health_cache.clone(); + adjust_by_usdc(&mut health_cache, 0, -30.0); + adjust_by_usdc(&mut health_cache, 1, 100.0); + adjust_by_usdc(&mut health_cache, 2, -30.0); + + // swapping with a high ratio advises paying back all liabs + // and then swapping even more because increasing assets in 0 has better asset weight + let init_ratio = health_cache.health_ratio(HealthType::Init); + let (amount, actual_ratio) = find_max_swap_actual(&health_cache, 1, 0, 100.0, 1.0); + println!( + "init {}, after {}, amount {}", + init_ratio, actual_ratio, amount + ); + assert!(actual_ratio / 2.0 > init_ratio); + assert!((amount - 100.0 / 3.0).abs() < 1.0); + } + + { + println!("test 4"); + let mut health_cache = health_cache.clone(); + adjust_by_usdc(&mut health_cache, 0, 100.0); + adjust_by_usdc(&mut health_cache, 1, -2.0); + adjust_by_usdc(&mut health_cache, 2, -65.0); + + let init_ratio = health_cache.health_ratio(HealthType::Init); + assert!(init_ratio > 3 && init_ratio < 4); + + check_max_swap_result(&health_cache, 0, 1, 1.0, 1.0); + check_max_swap_result(&health_cache, 0, 1, 3.0, 1.0); + check_max_swap_result(&health_cache, 0, 1, 4.0, 1.0); + } + } + + #[test] + fn test_max_perp() { + let default_token_info = |x| TokenInfo { + token_index: 0, + maint_asset_weight: I80F48::from_num(1.0 - x), + init_asset_weight: I80F48::from_num(1.0 - x), + maint_liab_weight: I80F48::from_num(1.0 + x), + init_liab_weight: I80F48::from_num(1.0 + x), + prices: Prices::new_single_price(I80F48::from_num(2.0)), + balance_native: I80F48::ZERO, + }; + let base_lot_size = 100; + let default_perp_info = |x| PerpInfo { + perp_market_index: 0, + maint_asset_weight: I80F48::from_num(1.0 - x), + init_asset_weight: I80F48::from_num(1.0 - x), + maint_liab_weight: I80F48::from_num(1.0 + x), + init_liab_weight: I80F48::from_num(1.0 + x), + base_lot_size, + base_lots: 0, + bids_base_lots: 0, + asks_base_lots: 0, + quote: I80F48::ZERO, + prices: Prices::new_single_price(I80F48::from_num(2.0)), + has_open_orders: false, + trusted_market: false, + }; + + let health_cache = HealthCache { + token_infos: vec![TokenInfo { + token_index: 0, + prices: Prices::new_single_price(I80F48::from_num(1.0)), + balance_native: I80F48::ZERO, + ..default_token_info(0.0) + }], + serum3_infos: vec![], + perp_infos: vec![PerpInfo { + perp_market_index: 0, + ..default_perp_info(0.3) + }], + being_liquidated: false, + }; + + assert_eq!(health_cache.health(HealthType::Init), I80F48::ZERO); + assert_eq!(health_cache.health_ratio(HealthType::Init), I80F48::MAX); + assert_eq!( + health_cache + .max_perp_for_health_ratio( + 0, + I80F48::from(2), + PerpOrderSide::Bid, + I80F48::from_num(50.0) + ) + .unwrap(), + I80F48::ZERO + ); + + let adjust_token = |c: &mut HealthCache, value: f64| { + let ti = &mut c.token_infos[0]; + ti.balance_native += I80F48::from_num(value); + }; + let find_max_trade = + |c: &HealthCache, side: PerpOrderSide, ratio: f64, price_factor: f64| { + let prices = &c.perp_infos[0].prices; + let trade_price = I80F48::from_num(price_factor) * prices.oracle; + let base_lots = c + .max_perp_for_health_ratio(0, trade_price, side, I80F48::from_num(ratio)) + .unwrap(); + if base_lots == i64::MAX { + return (i64::MAX, f64::MAX, f64::MAX); + } + + let direction = match side { + PerpOrderSide::Bid => 1, + PerpOrderSide::Ask => -1, + }; + + // compute the health ratio we'd get when executing the trade + let actual_ratio = { + let base_lots = direction * base_lots; + let base_native = I80F48::from(base_lots * base_lot_size); + let mut c = c.clone(); + c.perp_infos[0].base_lots += base_lots; + c.perp_infos[0].quote -= base_native * trade_price; + c.health_ratio(HealthType::Init).to_num::() + }; + // the ratio for trading just one base lot extra + let plus_ratio = { + let base_lots = direction * (base_lots + 1); + let base_native = I80F48::from(base_lots * base_lot_size); + let mut c = c.clone(); + c.perp_infos[0].base_lots += base_lots; + c.perp_infos[0].quote -= base_native * trade_price; + c.health_ratio(HealthType::Init).to_num::() + }; + (base_lots, actual_ratio, plus_ratio) + }; + let check_max_trade = |c: &HealthCache, + side: PerpOrderSide, + ratio: f64, + price_factor: f64| { + let (base_lots, actual_ratio, plus_ratio) = + find_max_trade(c, side, ratio, price_factor); + println!( + "checking for price_factor: {price_factor}, target ratio {ratio}: actual ratio: {actual_ratio}, plus ratio: {plus_ratio}, base_lots: {base_lots}", + ); + let max_binary_search_error = 0.1; + assert!(ratio <= actual_ratio); + assert!(plus_ratio - max_binary_search_error <= ratio); + }; + + { + let mut health_cache = health_cache.clone(); + adjust_token(&mut health_cache, 3000.0); + + for existing in [-5, 0, 3] { + let mut c = health_cache.clone(); + c.perp_infos[0].base_lots += existing; + c.perp_infos[0].quote -= I80F48::from(existing * base_lot_size * 2); + + for side in [PerpOrderSide::Bid, PerpOrderSide::Ask] { + println!("test 0: existing {existing}, side {side:?}"); + for price_factor in [0.8, 1.0, 1.1] { + for ratio in 1..=100 { + check_max_trade(&health_cache, side, ratio as f64, price_factor); + } + } + } + } + + // check some extremely bad prices + check_max_trade(&health_cache, PerpOrderSide::Bid, 50.0, 2.0); + check_max_trade(&health_cache, PerpOrderSide::Ask, 50.0, 0.1); + + // and extremely good prices + assert_eq!( + find_max_trade(&health_cache, PerpOrderSide::Bid, 50.0, 0.1).0, + i64::MAX + ); + assert_eq!( + find_max_trade(&health_cache, PerpOrderSide::Ask, 50.0, 1.5).0, + i64::MAX + ); + } + } + + #[test] + fn test_health_perp_funding() { + let buffer = MangoAccount::default_for_tests().try_to_vec().unwrap(); + let mut account = MangoAccountValue::from_bytes(&buffer).unwrap(); + + let group = Pubkey::new_unique(); + + let (mut bank1, mut oracle1) = mock_bank_and_oracle(group, 1, 1.0, 0.2, 0.1); + bank1 + .data() + .change_without_fee( + account.ensure_token_position(1).unwrap().0, + I80F48::from(100), + DUMMY_NOW_TS, + ) + .unwrap(); + + let mut perp1 = mock_perp_market(group, oracle1.pubkey, 1.0, 9, 0.2, 0.1); + perp1.data().long_funding = I80F48::from_num(10.1); + let perpaccount = account.ensure_perp_position(9, 1).unwrap().0; + perpaccount.record_trade(perp1.data(), 10, I80F48::from(-110)); + perpaccount.long_settled_funding = I80F48::from_num(10.0); + + let oracle1_ai = oracle1.as_account_info(); + let ais = vec![ + bank1.as_account_info(), + oracle1_ai.clone(), + perp1.as_account_info(), + oracle1_ai, + ]; + + let retriever = ScanningAccountRetriever::new_with_staleness(&ais, &group, None).unwrap(); + + assert!(health_eq( + compute_health(&account.borrow(), HealthType::Init, &retriever).unwrap(), + // token + 0.8 * 100.0 + // perp base + + 0.8 * 100.0 + // perp quote + - 110.0 + // perp funding (10 * (10.1 - 10.0)) + - 1.0 + )); + } + + #[test] + fn test_scanning_retreiver_mismatched_oracle_for_perps_throws_error() { + let group = Pubkey::new_unique(); + + let (mut bank1, mut oracle1) = mock_bank_and_oracle(group, 1, 1.0, 0.2, 0.1); + let (mut bank2, mut oracle2) = mock_bank_and_oracle(group, 4, 5.0, 0.5, 0.3); + + let mut oo1 = TestAccount::::new_zeroed(); + + let mut perp1 = mock_perp_market(group, oracle1.pubkey, 1.0, 9, 0.2, 0.1); + let mut perp2 = mock_perp_market(group, oracle2.pubkey, 5.0, 8, 0.2, 0.1); + + let oracle1_account_info = oracle1.as_account_info(); + let oracle2_account_info = oracle2.as_account_info(); + let ais = vec![ + bank1.as_account_info(), + bank2.as_account_info(), + oracle1_account_info.clone(), + oracle2_account_info.clone(), + perp1.as_account_info(), + perp2.as_account_info(), + oracle2_account_info, // Oracles wrong way around + oracle1_account_info, + oo1.as_account_info(), + ]; + + let retriever = ScanningAccountRetriever::new_with_staleness(&ais, &group, None).unwrap(); + let result = retriever.perp_market_and_oracle_price(&group, 0, 9); + assert!(result.is_err()); + } + + #[test] + fn test_health_stable_price_token() { + let buffer = MangoAccount::default_for_tests().try_to_vec().unwrap(); + let mut account = MangoAccountValue::from_bytes(&buffer).unwrap(); + let buffer2 = MangoAccount::default_for_tests().try_to_vec().unwrap(); + let mut account2 = MangoAccountValue::from_bytes(&buffer2).unwrap(); + let buffer3 = MangoAccount::default_for_tests().try_to_vec().unwrap(); + let mut account3 = MangoAccountValue::from_bytes(&buffer3).unwrap(); + + let group = Pubkey::new_unique(); + + let (mut bank1, mut oracle1) = mock_bank_and_oracle(group, 1, 1.0, 0.2, 0.1); + bank1.data().stable_price_model.stable_price = 0.5; + bank1 + .data() + .change_without_fee( + account.ensure_token_position(1).unwrap().0, + I80F48::from(100), + DUMMY_NOW_TS, + ) + .unwrap(); + bank1 + .data() + .change_without_fee( + account2.ensure_token_position(1).unwrap().0, + I80F48::from(-100), + DUMMY_NOW_TS, + ) + .unwrap(); + + let mut perp1 = mock_perp_market(group, oracle1.pubkey, 1.0, 9, 0.2, 0.1); + perp1.data().stable_price_model.stable_price = 0.5; + let perpaccount = account3.ensure_perp_position(9, 1).unwrap().0; + perpaccount.record_trade(perp1.data(), 10, I80F48::from(-100)); + + let oracle1_ai = oracle1.as_account_info(); + let ais = vec![ + bank1.as_account_info(), + oracle1_ai.clone(), + perp1.as_account_info(), + oracle1_ai, + ]; + + let retriever = ScanningAccountRetriever::new_with_staleness(&ais, &group, None).unwrap(); + + assert!(health_eq( + compute_health(&account.borrow(), HealthType::Init, &retriever).unwrap(), + 0.8 * 0.5 * 100.0 + )); + assert!(health_eq( + compute_health(&account.borrow(), HealthType::Maint, &retriever).unwrap(), + 0.9 * 1.0 * 100.0 + )); + assert!(health_eq( + compute_health(&account2.borrow(), HealthType::Init, &retriever).unwrap(), + -1.2 * 1.0 * 100.0 + )); + assert!(health_eq( + compute_health(&account2.borrow(), HealthType::Maint, &retriever).unwrap(), + -1.1 * 1.0 * 100.0 + )); + assert!(health_eq( + compute_health(&account3.borrow(), HealthType::Init, &retriever).unwrap(), + 0.8 * 0.5 * 10.0 * 10.0 - 100.0 + )); + assert!(health_eq( + compute_health(&account3.borrow(), HealthType::Maint, &retriever).unwrap(), + 0.9 * 1.0 * 10.0 * 10.0 - 100.0 + )); + } +} diff --git a/programs/mango-v4/src/state/health/mod.rs b/programs/mango-v4/src/state/health/mod.rs new file mode 100644 index 000000000..59589281f --- /dev/null +++ b/programs/mango-v4/src/state/health/mod.rs @@ -0,0 +1,9 @@ +pub use account_retriever::*; +pub use cache::*; +#[cfg(feature = "client")] +pub use client::*; + +mod account_retriever; +mod cache; +mod client; +mod test; diff --git a/programs/mango-v4/src/state/health/test.rs b/programs/mango-v4/src/state/health/test.rs new file mode 100644 index 000000000..b02569e33 --- /dev/null +++ b/programs/mango-v4/src/state/health/test.rs @@ -0,0 +1,123 @@ +#![cfg(test)] + +use anchor_lang::prelude::*; +use fixed::types::I80F48; +use serum_dex::state::OpenOrders; +use std::cell::RefCell; +use std::mem::size_of; +use std::rc::Rc; + +use crate::state::*; + +pub const DUMMY_NOW_TS: u64 = 0; + +// Implementing TestAccount directly for ZeroCopy + Owner leads to a conflict +// because OpenOrders may add impls for those in the future. +pub trait MyZeroCopy: anchor_lang::ZeroCopy + Owner {} +impl MyZeroCopy for StubOracle {} +impl MyZeroCopy for Bank {} +impl MyZeroCopy for PerpMarket {} + +pub struct TestAccount { + pub bytes: Vec, + pub pubkey: Pubkey, + pub owner: Pubkey, + pub lamports: u64, + _phantom: std::marker::PhantomData, +} + +impl TestAccount { + pub fn new(bytes: Vec, owner: Pubkey) -> Self { + Self { + bytes, + owner, + pubkey: Pubkey::new_unique(), + lamports: 0, + _phantom: std::marker::PhantomData, + } + } + + pub fn as_account_info(&mut self) -> AccountInfo { + AccountInfo { + key: &self.pubkey, + owner: &self.owner, + lamports: Rc::new(RefCell::new(&mut self.lamports)), + data: Rc::new(RefCell::new(&mut self.bytes)), + is_signer: false, + is_writable: false, + executable: false, + rent_epoch: 0, + } + } +} + +impl TestAccount { + pub fn new_zeroed() -> Self { + let mut bytes = vec![0u8; 8 + size_of::()]; + bytes[0..8].copy_from_slice(&T::discriminator()); + Self::new(bytes, T::owner()) + } + + pub fn data(&mut self) -> &mut T { + bytemuck::from_bytes_mut(&mut self.bytes[8..]) + } +} + +impl TestAccount { + pub fn new_zeroed() -> Self { + let mut bytes = vec![0u8; 12 + size_of::()]; + bytes[0..5].copy_from_slice(b"serum"); + Self::new(bytes, Pubkey::new_unique()) + } + + pub fn data(&mut self) -> &mut OpenOrders { + bytemuck::from_bytes_mut(&mut self.bytes[5..5 + size_of::()]) + } +} + +pub fn mock_bank_and_oracle( + group: Pubkey, + token_index: TokenIndex, + price: f64, + init_weights: f64, + maint_weights: f64, +) -> (TestAccount, TestAccount) { + let mut oracle = TestAccount::::new_zeroed(); + oracle.data().price = I80F48::from_num(price); + let mut bank = TestAccount::::new_zeroed(); + bank.data().token_index = token_index; + bank.data().group = group; + bank.data().oracle = oracle.pubkey; + bank.data().deposit_index = I80F48::from(1_000_000); + bank.data().borrow_index = I80F48::from(1_000_000); + bank.data().init_asset_weight = I80F48::from_num(1.0 - init_weights); + bank.data().init_liab_weight = I80F48::from_num(1.0 + init_weights); + bank.data().maint_asset_weight = I80F48::from_num(1.0 - maint_weights); + bank.data().maint_liab_weight = I80F48::from_num(1.0 + maint_weights); + bank.data().stable_price_model.reset_to_price(price, 0); + bank.data().net_borrows_window_size_ts = 1; // dummy + bank.data().net_borrows_limit_native = i64::MAX; // max since we don't want this to interfere + (bank, oracle) +} + +pub fn mock_perp_market( + group: Pubkey, + oracle: Pubkey, + price: f64, + market_index: PerpMarketIndex, + init_weights: f64, + maint_weights: f64, +) -> TestAccount { + let mut pm = TestAccount::::new_zeroed(); + pm.data().group = group; + pm.data().oracle = oracle; + pm.data().perp_market_index = market_index; + pm.data().init_asset_weight = I80F48::from_num(1.0 - init_weights); + pm.data().init_liab_weight = I80F48::from_num(1.0 + init_weights); + pm.data().maint_asset_weight = I80F48::from_num(1.0 - maint_weights); + pm.data().maint_liab_weight = I80F48::from_num(1.0 + maint_weights); + pm.data().quote_lot_size = 100; + pm.data().base_lot_size = 10; + pm.data().stable_price_model.reset_to_price(price, 0); + pm +}