/*! * This module deals with computing different types of health for a mango account. * * Health is a number in USD and represents a risk-engine assessment of the account's * positions and open orders. The larger the health the better. Negative health * often means some action is necessary or a limitation is placed on the user. * * The different types of health are described in the HealthType enum. * * The key struct in this module is HealthCache, typically constructed by the * new_health_cache() function. With it, the different health types can be * computed. * * The HealthCache holds the data it needs in TokenInfo, Serum3Info and PerpInfo. */ use anchor_lang::prelude::*; use fixed::types::I80F48; use crate::error::*; use crate::i80f48::LowPrecisionDivision; use crate::serum3_cpi::{OpenOrdersAmounts, OpenOrdersSlim}; use crate::state::{ Bank, MangoAccountRef, PerpMarket, PerpMarketIndex, PerpPosition, Serum3MarketIndex, Serum3Orders, TokenIndex, }; use super::*; /// Information about prices for a bank or perp market. #[derive(Clone, Debug)] 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 { match health_type { HealthType::Maint | HealthType::LiquidationEnd => self.oracle, HealthType::Init => 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 { match health_type { HealthType::Maint | HealthType::LiquidationEnd => self.oracle, HealthType::Init => self.oracle.min(self.stable), } } } /// There are three types of health: /// - initial health ("init"): users can only open new positions if it's >= 0 /// - maintenance health ("maint"): users get liquidated if it's < 0 /// - liquidation end health: once liquidation started (see being_liquidated), it /// only stops once this is >= 0 /// /// The ordering is /// init health <= liquidation end health <= maint health /// /// The different health types are realized by using different weights and prices: /// - init health: init weights with scaling, stable-price adjusted prices /// - liq end health: init weights without scaling, oracle prices /// - maint health: maint weights, oracle prices /// #[derive(PartialEq, Copy, Clone, AnchorSerialize, AnchorDeserialize)] pub enum HealthType { Init, Maint, // aka LiquidationStart LiquidationEnd, } /// 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], now_ts: u64, ) -> Result { let retriever = new_fixed_order_account_retriever(ais, account)?; Ok(new_health_cache(account, &retriever, now_ts)?.health(health_type)) } /// Compute health with an arbitrary AccountRetriever pub fn compute_health( account: &MangoAccountRef, health_type: HealthType, retriever: &impl AccountRetriever, now_ts: u64, ) -> Result { Ok(new_health_cache(account, retriever, now_ts)?.health(health_type)) } /// How much of a token can be taken away before health decreases to zero? /// /// If health is negative, returns 0. pub fn spot_amount_taken_for_health_zero( mut health: I80F48, starting_spot: I80F48, asset_weighted_price: I80F48, liab_weighted_price: I80F48, ) -> Result { if health <= 0 { return Ok(I80F48::ZERO); } let mut taken_spot = I80F48::ZERO; if starting_spot > 0 { if asset_weighted_price > 0 { let asset_max = health / asset_weighted_price; if asset_max <= starting_spot { return Ok(asset_max); } } taken_spot = starting_spot; health -= starting_spot * asset_weighted_price; } if health > 0 { require_gt!(liab_weighted_price, 0); taken_spot += health / liab_weighted_price; } Ok(taken_spot) } /// How much of a token can be gained before health increases to zero? /// /// Returns 0 if health is positive. pub fn spot_amount_given_for_health_zero( health: I80F48, starting_spot: I80F48, asset_weighted_price: I80F48, liab_weighted_price: I80F48, ) -> Result { // asset/liab prices are reversed intentionally spot_amount_taken_for_health_zero( -health, -starting_spot, liab_weighted_price, asset_weighted_price, ) } #[derive(Clone, Debug)] pub struct TokenInfo { pub token_index: TokenIndex, pub maint_asset_weight: I80F48, pub init_asset_weight: I80F48, pub init_scaled_asset_weight: I80F48, pub maint_liab_weight: I80F48, pub init_liab_weight: I80F48, pub init_scaled_liab_weight: I80F48, pub prices: Prices, /// Freely available spot balance for the token. /// /// Includes TokenPosition and free Serum3OpenOrders balances. /// Does not include perp upnl or Serum3 reserved amounts. pub balance_spot: I80F48, pub allow_asset_liquidation: bool, } /// Temporary value used during health computations #[derive(Clone, Default)] pub struct TokenBalance { /// Sum of token_info.balance_spot and perp health_unsettled_pnl balances pub spot_and_perp: I80F48, } #[derive(Clone, Default)] pub struct TokenMaxReserved { /// The sum of serum-reserved amounts over all markets pub max_serum_reserved: I80F48, } impl TokenInfo { #[inline(always)] fn asset_weight(&self, health_type: HealthType) -> I80F48 { match health_type { HealthType::Init => self.init_scaled_asset_weight, HealthType::LiquidationEnd => self.init_asset_weight, HealthType::Maint => self.maint_asset_weight, } } #[inline(always)] pub fn asset_weighted_price(&self, health_type: HealthType) -> I80F48 { self.asset_weight(health_type) * self.prices.asset(health_type) } #[inline(always)] fn liab_weight(&self, health_type: HealthType) -> I80F48 { match health_type { HealthType::Init => self.init_scaled_liab_weight, HealthType::LiquidationEnd => self.init_liab_weight, HealthType::Maint => self.maint_liab_weight, } } #[inline(always)] pub fn liab_weighted_price(&self, health_type: HealthType) -> I80F48 { self.liab_weight(health_type) * self.prices.liab(health_type) } #[inline(always)] pub fn health_contribution(&self, health_type: HealthType, balance: I80F48) -> I80F48 { let weighted_price = if balance.is_negative() { self.liab_weighted_price(health_type) } else { self.asset_weighted_price(health_type) }; balance * weighted_price } } /// Information about reserved funds on Serum3 open orders accounts. /// /// Note that all "free" funds on open orders accounts are added directly /// to the token info. This is only about dealing with the reserved funds /// that might end up as base OR quote tokens, depending on whether the /// open orders execute on not. #[derive(Clone, Debug)] pub struct Serum3Info { // reserved amounts as stored on the open orders pub reserved_base: I80F48, pub reserved_quote: I80F48, // Reserved amounts, converted to the opposite token, while using the most extreme order price // May be zero if the extreme bid/ask price is not available (for orders placed in the past) pub reserved_base_as_quote_lowest_ask: I80F48, pub reserved_quote_as_base_highest_bid: I80F48, // Index into TokenInfos _not_ a TokenIndex pub base_info_index: usize, pub quote_info_index: usize, pub market_index: Serum3MarketIndex, /// The open orders account has no free or reserved funds pub has_zero_funds: bool, } impl Serum3Info { fn new( serum_account: &Serum3Orders, open_orders: &impl OpenOrdersAmounts, base_info_index: usize, quote_info_index: usize, ) -> Self { // track the reserved amounts let reserved_base = I80F48::from(open_orders.native_base_reserved()); let reserved_quote = I80F48::from(open_orders.native_quote_reserved()); let reserved_base_as_quote_lowest_ask = reserved_base * I80F48::from_num(serum_account.lowest_placed_ask); let reserved_quote_as_base_highest_bid = reserved_quote * I80F48::from_num(serum_account.highest_placed_bid_inv); Self { reserved_base, reserved_quote, reserved_base_as_quote_lowest_ask, reserved_quote_as_base_highest_bid, base_info_index, quote_info_index, market_index: serum_account.market_index, has_zero_funds: open_orders.native_base_total() == 0 && open_orders.native_quote_total() == 0 && open_orders.native_rebates() == 0, } } #[inline(always)] fn all_reserved_as_base( &self, health_type: HealthType, quote_info: &TokenInfo, base_info: &TokenInfo, ) -> I80F48 { let quote_asset = quote_info.prices.asset(health_type); let base_liab = base_info.prices.liab(health_type); let reserved_quote_as_base_oracle = (self.reserved_quote * quote_asset) .checked_div_f64_precision(base_liab) .unwrap(); if self.reserved_quote_as_base_highest_bid != 0 { self.reserved_base + reserved_quote_as_base_oracle.min(self.reserved_quote_as_base_highest_bid) } else { self.reserved_base + reserved_quote_as_base_oracle } } #[inline(always)] fn all_reserved_as_quote( &self, health_type: HealthType, quote_info: &TokenInfo, base_info: &TokenInfo, ) -> I80F48 { let base_asset = base_info.prices.asset(health_type); let quote_liab = quote_info.prices.liab(health_type); let reserved_base_as_quote_oracle = (self.reserved_base * base_asset) .checked_div_f64_precision(quote_liab) .unwrap(); if self.reserved_base_as_quote_lowest_ask != 0 { self.reserved_quote + reserved_base_as_quote_oracle.min(self.reserved_base_as_quote_lowest_ask) } else { self.reserved_quote + reserved_base_as_quote_oracle } } /// Compute the health contribution from active open orders. /// /// For open orders, health is about the worst-case outcome: Consider the scenarios: /// - all reserved base tokens convert to quote tokens /// - all reserved quote tokens convert to base tokens /// Which would lead to the smaller token health? /// /// Answering this question isn't straightforward for two reasons: /// 1. We don't have information about the actual open orders here. Just about the amount /// of reserved tokens. Hence we assume base/quote conversion would happen at current /// asset/liab prices. /// 2. Technically, there are interaction effects between multiple spot markets. If the /// account has open orders on SOL/USDC, BTC/USDC and SOL/BTC, then the worst case for /// SOL/USDC might be dependent on what happens with the open orders on the other two /// markets. /// /// To simplify 2, we give up on computing the actual worst-case and instead compute something /// that's guaranteed to be less: Get the worst case for each market independently while /// assuming all other market open orders resolved maximally unfavorably. /// /// To be able to do that, we compute `token_max_reserved` for each token, which is the maximum /// token amount that would be generated if open orders in all markets that deal with the token /// turn its way. (in the example above: the open orders in the SOL/USDC and SOL/BTC market /// both produce SOL) See `compute_serum3_reservations()` below. #[inline(always)] fn health_contribution( &self, health_type: HealthType, token_infos: &[TokenInfo], token_balances: &[TokenBalance], token_max_reserved: &[TokenMaxReserved], 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_info_index]; let quote_info = &token_infos[self.quote_info_index]; // How much would health increase if the reserved balance were applied to the passed // token info? let compute_health_effect = |token_info: &TokenInfo, balance: &TokenBalance, max_reserved: &TokenMaxReserved, 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 `max_serum_reserved`. let max_balance = balance.spot_and_perp + max_reserved.max_serum_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, 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); asset_part * asset_weight * asset_price + liab_part * liab_weight * liab_price }; let health_base = compute_health_effect( base_info, &token_balances[self.base_info_index], &token_max_reserved[self.base_info_index], market_reserved.all_reserved_as_base, ); let health_quote = compute_health_effect( quote_info, &token_balances[self.quote_info_index], &token_max_reserved[self.quote_info_index], market_reserved.all_reserved_as_quote, ); health_base.min(health_quote) } } #[derive(Clone)] pub(crate) 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, } /// Stores information about perp market positions and their open orders. /// /// Perp markets affect account health indirectly, though the token balance in the /// perp market's settle token. See `effective_token_balances()`. #[derive(Clone, Debug)] pub struct PerpInfo { pub perp_market_index: PerpMarketIndex, pub settle_token_index: TokenIndex, pub maint_base_asset_weight: I80F48, pub init_base_asset_weight: I80F48, pub maint_base_liab_weight: I80F48, pub init_base_liab_weight: I80F48, pub maint_overall_asset_weight: I80F48, pub init_overall_asset_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 base_prices: Prices, pub has_open_orders: bool, pub has_open_fills: bool, } impl PerpInfo { fn new( perp_position: &PerpPosition, perp_market: &PerpMarket, base_prices: Prices, ) -> Result { let base_lots = perp_position.base_position_lots() + perp_position.taker_base_lots; let unsettled_funding = perp_position.unsettled_funding(perp_market); let taker_quote = I80F48::from(perp_position.taker_quote_lots * perp_market.quote_lot_size); let quote_current = perp_position.quote_position_native() - unsettled_funding + taker_quote; Ok(Self { perp_market_index: perp_market.perp_market_index, settle_token_index: perp_market.settle_token_index, init_base_asset_weight: perp_market.init_base_asset_weight, init_base_liab_weight: perp_market.init_base_liab_weight, maint_base_asset_weight: perp_market.maint_base_asset_weight, maint_base_liab_weight: perp_market.maint_base_liab_weight, init_overall_asset_weight: perp_market.init_overall_asset_weight, maint_overall_asset_weight: perp_market.maint_overall_asset_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, base_prices, has_open_orders: perp_position.has_open_orders(), has_open_fills: perp_position.has_open_taker_fills(), }) } /// The perp-risk (but not token-risk) adjusted upnl. Also called "hupnl". /// /// In settle token native units. /// /// This is what gets added to effective_token_balances() and then contributes /// to account health. /// /// For fully isolated perp markets, users may never borrow against unsettled /// positive perp pnl, there overall_asset_weight == 0 and there can't be positive /// health contributions from these perp market. We sometimes call these markets /// "untrusted markets". /// /// In these, 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). /// /// Other markets may be liquid enough that we have enough confidence to allow /// users to borrow against unsettled positive pnl to some extend. In these cases, /// the overall asset weights would be >0. #[inline(always)] pub fn health_unsettled_pnl(&self, health_type: HealthType) -> I80F48 { let contribution = self.unweighted_health_unsettled_pnl(health_type); self.weigh_uhupnl_overall(contribution, health_type) } /// Convert uhupnl to hupnl by applying the overall weight. In settle token native units. #[inline(always)] fn weigh_uhupnl_overall(&self, unweighted: I80F48, health_type: HealthType) -> I80F48 { if unweighted > 0 { let overall_weight = match health_type { HealthType::Init | HealthType::LiquidationEnd => self.init_overall_asset_weight, HealthType::Maint => self.maint_overall_asset_weight, }; overall_weight * unweighted } else { unweighted } } /// Settle token native provided by perp position and open orders, without the overall asset weight. /// /// Also called "uhupnl". /// /// For open orders, this computes the worst-case amount by considering the scenario where all /// bids execute and the one where all asks execute. /// /// It's always less than the PerpPosition's `unsettled_pnl()` for two reasons: The open orders /// are taken into account and the base weight is applied to the base position. /// /// Generally: hupnl <= uhupnl <= upnl #[inline(always)] pub fn unweighted_health_unsettled_pnl(&self, health_type: HealthType) -> I80F48 { let order_execution_case = |orders_base_lots: i64, order_price: I80F48| { let net_base_native = I80F48::from((self.base_lots + orders_base_lots) * self.base_lot_size); let weight = match (health_type, net_base_native.is_negative()) { (HealthType::Init, true) | (HealthType::LiquidationEnd, true) => { self.init_base_liab_weight } (HealthType::Init, false) | (HealthType::LiquidationEnd, false) => { self.init_base_asset_weight } (HealthType::Maint, true) => self.maint_base_liab_weight, (HealthType::Maint, false) => self.maint_base_asset_weight, }; let base_price = if net_base_native.is_negative() { self.base_prices.liab(health_type) } else { self.base_prices.asset(health_type) }; // Total value of the order-execution adjusted base position let base_health = net_base_native * weight * base_price; let orders_base_native = I80F48::from(orders_base_lots * self.base_lot_size); // The quote change from executing the bids/asks let order_quote = -orders_base_native * order_price; 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.base_prices.liab(health_type)); let asks_case = order_execution_case(-self.asks_base_lots, self.base_prices.asset(health_type)); let worst_case = bids_case.min(asks_case); self.quote + worst_case } } /// Store information needed to compute account health /// /// This is called a cache, because it extracts information from a MangoAccount and /// the Bank, Perp, oracle accounts once and then allows computing different types /// of health. /// /// For compute-saving reasons, it also allows applying adjustments to the extracted /// positions. That's often helpful for instructions that want to re-compute health /// after having made small, well-known changes to an account. Recomputing the /// HealthCache from scratch would be significantly more expensive. /// /// However, there's a real risk of getting the adjustments wrong and computing an /// inconsistent result, so particular care needs to be taken when this is done. #[allow(unused)] #[derive(Clone, Debug)] pub struct HealthCache { pub token_infos: Vec, pub(crate) serum3_infos: Vec, pub(crate) perp_infos: Vec, #[allow(unused)] pub(crate) being_liquidated: bool, } impl HealthCache { pub fn health(&self, health_type: HealthType) -> I80F48 { let token_balances = self.effective_token_balances(health_type); let mut health = I80F48::ZERO; let sum = |contrib| { health += contrib; }; self.health_sum(health_type, sum, &token_balances); health } /// 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_stable_liabs(health_type); let hundred = I80F48::from(100); if liabs > 0 { // feel free to saturate to MAX for tiny liabs (hundred * (assets - liabs)).saturating_div(liabs) } else { I80F48::MAX } } pub fn health_assets_and_liabs_stable_assets( &self, health_type: HealthType, ) -> (I80F48, I80F48) { self.health_assets_and_liabs(health_type, true) } pub fn health_assets_and_liabs_stable_liabs( &self, health_type: HealthType, ) -> (I80F48, I80F48) { self.health_assets_and_liabs(health_type, false) } /// Loop over the token, perp, serum contributions and add up all positive values into `assets` /// and (the abs) of negative values separately into `liabs`. Return (assets, liabs). /// /// Due to the way token and perp positions sum before being weighted, there's some flexibility /// in how the sum is split up. It can either be split up such that the amount of liabs stays /// constant when assets change, or the other way around. /// /// For example, if assets are held stable: An account with $10 in SOL and -$12 hupnl in a /// SOL-settled perp market would have: /// - assets: $10 * SOL_asset_weight /// - liabs: $10 * SOL_asset_weight + $2 * SOL_liab_weight /// because some of the liabs are weighted lower as they are just compensating the assets. /// /// Same example if liabs are held stable: /// - liabs: $12 * SOL_liab_weight /// - assets: $10 * SOL_liab_weight /// /// The value `assets - liabs` is the health and the same in both cases. fn health_assets_and_liabs( &self, health_type: HealthType, stable_assets: bool, ) -> (I80F48, I80F48) { let mut total_assets = I80F48::ZERO; let mut total_liabs = I80F48::ZERO; let add = |assets: &mut I80F48, liabs: &mut I80F48, value: I80F48| { if value > 0 { *assets += value; } else { *liabs += -value; } }; for token_info in self.token_infos.iter() { // For each token, health only considers the effective token position. But for // this function we want to distinguish the contribution from token deposits from // contributions by perp markets. // However, the overall weight is determined by the sum, so first collect all // assets parts and all liab parts and then determine the actual values. let mut asset_balance = I80F48::ZERO; let mut liab_balance = I80F48::ZERO; add( &mut asset_balance, &mut liab_balance, token_info.balance_spot, ); for perp_info in self.perp_infos.iter() { if perp_info.settle_token_index != token_info.token_index { continue; } let health_unsettled = perp_info.health_unsettled_pnl(health_type); add(&mut asset_balance, &mut liab_balance, health_unsettled); } // The assignment to total_assets and total_liabs is a bit arbitrary. // As long as the (added_assets - added_liabs) = weighted(asset_balance - liab_balance), // the result will be consistent. if stable_assets { let asset_weighted_price = token_info.asset_weighted_price(health_type); let assets = asset_balance * asset_weighted_price; total_assets += assets; if asset_balance >= liab_balance { // liabs partially compensate total_liabs += liab_balance * asset_weighted_price; } else { let liab_weighted_price = token_info.liab_weighted_price(health_type); // the liabs fully compensate the assets and even add something extra total_liabs += assets + (liab_balance - asset_balance) * liab_weighted_price; } } else { let liab_weighted_price = token_info.liab_weighted_price(health_type); let liabs = liab_balance * liab_weighted_price; total_liabs += liabs; if asset_balance >= liab_balance { let asset_weighted_price = token_info.asset_weighted_price(health_type); // the assets fully compensate the liabs and even add something extra total_assets += liabs + (asset_balance - liab_balance) * asset_weighted_price; } else { // assets partially compensate total_assets += asset_balance * liab_weighted_price; } } } let token_balances = self.effective_token_balances(health_type); 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_balances, &token_max_reserved, reserved, ); add(&mut total_assets, &mut total_liabs, contrib); } (total_assets, total_liabs) } /// Computes the account assets and liabilities marked to market. /// /// Contrary to health_assets_and_liabs, there's no health weighing or adjustment /// for stable prices. It uses oracle prices directly. /// /// Returns (assets, liabilities) pub fn assets_and_liabs(&self) -> (I80F48, I80F48) { let mut assets = I80F48::ZERO; let mut liabs = I80F48::ZERO; for token_info in self.token_infos.iter() { if token_info.balance_spot.is_negative() { liabs -= token_info.balance_spot * token_info.prices.oracle; } else { assets += token_info.balance_spot * token_info.prices.oracle; } } for serum_info in self.serum3_infos.iter() { let quote = &self.token_infos[serum_info.quote_info_index]; let base = &self.token_infos[serum_info.base_info_index]; assets += serum_info.reserved_base * base.prices.oracle; assets += serum_info.reserved_quote * quote.prices.oracle; } for perp_info in self.perp_infos.iter() { let quote_price = self.token_infos[perp_info.settle_token_index as usize] .prices .oracle; let quote_position_value = perp_info.quote * quote_price; if perp_info.quote.is_negative() { liabs -= quote_position_value; } else { assets += quote_position_value; } let base_position_value = I80F48::from(perp_info.base_lots * perp_info.base_lot_size) * perp_info.base_prices.oracle * quote_price; if base_position_value.is_negative() { liabs -= base_position_value; } else { assets += base_position_value; } } return (assets, liabs); } /// Computes the account leverage as ratio of liabs / (assets - liabs) /// /// The goal of this function is to provide a quick overview over the accounts balance sheet. /// It's not actually used to make any margin decisions internally and doesn't account for /// open orders or stable / oracle price differences. Use health_ratio to make risk decisions. pub fn leverage(&self) -> I80F48 { let (assets, liabs) = self.assets_and_liabs(); let equity = assets - liabs; liabs / equity.max(I80F48::from_num(0.001)) } pub fn token_info(&self, token_index: TokenIndex) -> Result<&TokenInfo> { Ok(&self.token_infos[self.token_info_index(token_index)?]) } pub 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_typed!( MangoError::TokenPositionDoesNotExist, "token index {} not found", token_index ) }) } pub fn perp_info(&self, perp_market_index: PerpMarketIndex) -> Result<&PerpInfo> { Ok(&self.perp_infos[self.perp_info_index(perp_market_index)?]) } pub(crate) fn perp_info_index(&self, perp_market_index: PerpMarketIndex) -> Result { self.perp_infos .iter() .position(|t| t.perp_market_index == perp_market_index) .ok_or_else(|| { error_msg_typed!( MangoError::PerpPositionDoesNotExist, "perp market index {} not found", perp_market_index ) }) } /// Changes the cached user account token balance. pub fn adjust_token_balance(&mut self, bank: &Bank, change: I80F48) -> Result<()> { let entry_index = self.token_info_index(bank.token_index)?; let mut entry = &mut self.token_infos[entry_index]; // Note: resetting the weights here assumes that the change has been applied to // the passed in bank already entry.init_scaled_asset_weight = bank.scaled_init_asset_weight(entry.prices.asset(HealthType::Init)); entry.init_scaled_liab_weight = bank.scaled_init_liab_weight(entry.prices.liab(HealthType::Init)); // 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; entry.balance_spot -= removed_contribution; Ok(()) } /// Recompute the cached information about a serum market. /// /// WARNING: You must also call recompute_token_weights() after all bank /// deposit/withdraw changes! pub fn recompute_serum3_info( &mut self, serum_account: &Serum3Orders, open_orders: &OpenOrdersSlim, free_base_change: I80F48, free_quote_change: I80F48, ) -> Result<()> { let serum_info_index = self .serum3_infos .iter_mut() .position(|m| m.market_index == serum_account.market_index) .ok_or_else(|| error_msg!("serum3 market {} not found", serum_account.market_index))?; let serum_info = &self.serum3_infos[serum_info_index]; { let base_entry = &mut self.token_infos[serum_info.base_info_index]; base_entry.balance_spot += free_base_change; } { let quote_entry = &mut self.token_infos[serum_info.quote_info_index]; quote_entry.balance_spot += free_quote_change; } let serum_info = &mut self.serum3_infos[serum_info_index]; *serum_info = Serum3Info::new( serum_account, open_orders, serum_info.base_info_index, serum_info.quote_info_index, ); 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.base_prices.clone())?; Ok(()) } /// Liquidatable spot assets mean: actual token deposits and also a positive effective token balance /// and is available for asset liquidation pub fn has_liq_spot_assets(&self) -> bool { let health_token_balances = self.effective_token_balances(HealthType::LiquidationEnd); self.token_infos .iter() .zip(health_token_balances.iter()) .any(|(ti, b)| { // need 1 native token to use token_liq_with_token ti.balance_spot >= 1 && b.spot_and_perp >= 1 && ti.allow_asset_liquidation }) } /// Liquidatable spot borrows mean: actual token borrows plus a negative effective token balance pub fn has_liq_spot_borrows(&self) -> bool { let health_token_balances = self.effective_token_balances(HealthType::LiquidationEnd); self.token_infos .iter() .zip(health_token_balances.iter()) .any(|(ti, b)| ti.balance_spot < 0 && b.spot_and_perp < 0) } // This function exists separately from has_liq_spot_assets and has_liq_spot_borrows for performance reasons pub fn has_possible_spot_liquidations(&self) -> bool { let health_token_balances = self.effective_token_balances(HealthType::LiquidationEnd); let all_iter = || self.token_infos.iter().zip(health_token_balances.iter()); all_iter().any(|(ti, b)| ti.balance_spot < 0 && b.spot_and_perp < 0) && all_iter().any(|(ti, b)| { ti.balance_spot >= 1 && b.spot_and_perp >= 1 && ti.allow_asset_liquidation }) } pub fn has_serum3_open_orders_funds(&self) -> bool { self.serum3_infos.iter().any(|si| !si.has_zero_funds) } pub fn has_perp_open_orders(&self) -> bool { self.perp_infos.iter().any(|p| p.has_open_orders) } pub fn has_perp_base_positions(&self) -> bool { self.perp_infos.iter().any(|p| p.base_lots != 0) } pub fn has_perp_open_fills(&self) -> bool { self.perp_infos.iter().any(|p| p.has_open_fills) } pub fn has_perp_positive_pnl_no_base(&self) -> bool { self.perp_infos .iter() .any(|p| p.base_lots == 0 && p.quote > 0) } pub fn has_perp_negative_pnl_no_base(&self) -> bool { self.perp_infos .iter() .any(|p| p.base_lots == 0 && p.quote < 0) } /// Phase1 is spot/perp order cancellation and spot settlement since /// neither of these come at a cost to the liqee pub fn has_phase1_liquidatable(&self) -> bool { self.has_serum3_open_orders_funds() || self.has_perp_open_orders() } pub fn require_after_phase1_liquidation(&self) -> Result<()> { require!( !self.has_serum3_open_orders_funds(), MangoError::HasOpenOrUnsettledSerum3Orders ); require!(!self.has_perp_open_orders(), MangoError::HasOpenPerpOrders); Ok(()) } pub fn in_phase1_liquidation(&self) -> bool { self.has_phase1_liquidatable() } /// Phase2 is for: /// - token-token liquidation /// - liquidation of perp base positions (an open fill isn't liquidatable, but /// it changes the base position, so need to wait for it to be processed...) /// - bringing positive trusted perp pnl into the spot realm pub fn has_phase2_liquidatable(&self) -> bool { self.has_possible_spot_liquidations() || self.has_perp_base_positions() || self.has_perp_open_fills() || self.has_perp_positive_pnl_no_base() } pub fn require_after_phase2_liquidation(&self) -> Result<()> { self.require_after_phase1_liquidation()?; require!( !self.has_possible_spot_liquidations(), MangoError::HasLiquidatableTokenPosition ); require!( !self.has_perp_base_positions(), MangoError::HasLiquidatablePerpBasePosition ); require!( !self.has_perp_open_fills(), MangoError::HasOpenPerpTakerFills ); require!( !self.has_perp_positive_pnl_no_base(), MangoError::HasLiquidatablePositivePerpPnl ); Ok(()) } pub fn in_phase2_liquidation(&self) -> bool { !self.has_phase1_liquidatable() && self.has_phase2_liquidatable() } /// Phase3 is bankruptcy: /// - token bankruptcy /// - perp bankruptcy pub fn has_phase3_liquidatable(&self) -> bool { self.has_liq_spot_borrows() || self.has_perp_negative_pnl_no_base() } pub fn in_phase3_liquidation(&self) -> bool { !self.has_phase1_liquidatable() && !self.has_phase2_liquidatable() && self.has_phase3_liquidatable() } pub(crate) fn compute_serum3_reservations( &self, health_type: HealthType, ) -> (Vec, Vec) { let mut token_max_reserved = vec![TokenMaxReserved::default(); 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_info = &self.token_infos[info.quote_info_index]; let base_info = &self.token_infos[info.base_info_index]; let all_reserved_as_base = info.all_reserved_as_base(health_type, quote_info, base_info); let all_reserved_as_quote = info.all_reserved_as_quote(health_type, quote_info, base_info); token_max_reserved[info.base_info_index].max_serum_reserved += all_reserved_as_base; token_max_reserved[info.quote_info_index].max_serum_reserved += all_reserved_as_quote; serum3_reserved.push(Serum3Reserved { all_reserved_as_base, all_reserved_as_quote, }); } (token_max_reserved, serum3_reserved) } /// Returns token balances that account for spot and perp contributions /// /// Spot contributions are just the regular deposits or borrows, as well as from free /// funds on serum3 open orders accounts. /// /// Perp contributions come from perp positions in markets that use the token as a settle token: /// For these the hupnl is added to the total because that's the risk-adjusted expected to be /// gained or lost from settlement. pub fn effective_token_balances(&self, health_type: HealthType) -> Vec { self.effective_token_balances_internal(health_type, false) } /// Implementation of effective_token_balances() /// /// The ignore_negative_perp flag exists for perp_max_settle(). When it is enabled, all negative /// token contributions from perp markets are ignored. That's useful for knowing how much token /// collateral is available when limiting negative upnl settlement. fn effective_token_balances_internal( &self, health_type: HealthType, ignore_negative_perp: bool, ) -> Vec { let mut token_balances = vec![TokenBalance::default(); self.token_infos.len()]; for perp_info in self.perp_infos.iter() { let settle_token_index = self.token_info_index(perp_info.settle_token_index).unwrap(); let perp_settle_token = &mut token_balances[settle_token_index]; let health_unsettled = perp_info.health_unsettled_pnl(health_type); if !ignore_negative_perp || health_unsettled > 0 { perp_settle_token.spot_and_perp += health_unsettled; } } for (token_info, token_balance) in self.token_infos.iter().zip(token_balances.iter_mut()) { token_balance.spot_and_perp += token_info.balance_spot; } token_balances } pub(crate) fn health_sum( &self, health_type: HealthType, mut action: impl FnMut(I80F48), token_balances: &[TokenBalance], ) { for (token_info, token_balance) in self.token_infos.iter().zip(token_balances.iter()) { let contrib = token_info.health_contribution(health_type, token_balance.spot_and_perp); 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_balances, &token_max_reserved, reserved, ); action(contrib); } } /// Returns how much pnl is settleable for a given settle token. /// /// The idea of this limit is that settlement is only permissible as long as there are /// non-perp assets that back it. If an account with 1 USD deposited somehow gets /// a large negative perp upnl, it should not be allowed to settle that perp loss into /// the spot world fully (because of perp/spot isolation, translating perp losses and /// gains into tokens is restricted). Only 1 USD worth would be allowed. /// /// Effectively, there's a health variant "perp settle health" that ignores negative /// token contributions from perp markets. Settlement is allowed as long as perp settle /// health remains >= 0. /// /// For example, if perp_settle_health is 50 USD, then the settleable amount in SOL /// would depend on the SOL price, the user's current spot balance and the SOL weights: /// We need to compute how much the user's spot SOL balance may decrease before the /// perp_settle_health becomes zero. /// /// Note that the account's actual health would not change during settling negative upnl: /// the spot balance goes down but the perp hupnl goes up accordingly. /// /// 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_max_settle(&self, settle_token_index: TokenIndex) -> Result { let maint_type = HealthType::Maint; let token_balances = self.effective_token_balances_internal(maint_type, true); let mut perp_settle_health = I80F48::ZERO; let sum = |contrib| { perp_settle_health += contrib; }; self.health_sum(maint_type, sum, &token_balances); let token_info_index = self.token_info_index(settle_token_index)?; let token = &self.token_infos[token_info_index]; spot_amount_taken_for_health_zero( perp_settle_health, token_balances[token_info_index].spot_and_perp, token.asset_weighted_price(maint_type), token.liab_weighted_price(maint_type), ) } pub fn total_serum3_potential( &self, health_type: HealthType, token_index: TokenIndex, ) -> Result { let target_token_info_index = self.token_info_index(token_index)?; let total_reserved = self .serum3_infos .iter() .filter_map(|info| { if info.quote_info_index == target_token_info_index { Some(info.all_reserved_as_quote( health_type, &self.token_infos[info.quote_info_index], &self.token_infos[info.base_info_index], )) } else if info.base_info_index == target_token_info_index { Some(info.all_reserved_as_base( health_type, &self.token_infos[info.quote_info_index], &self.token_infos[info.base_info_index], )) } else { None } }) .sum(); Ok(total_reserved) } } 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_typed!( MangoError::TokenPositionDoesNotExist, "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, now_ts: u64, ) -> Result { new_health_cache_impl(account, retriever, now_ts, false) } /// Generate a special HealthCache for an account and its health accounts /// where nonnegative token positions for bad oracles are skipped. /// /// This health cache must be used carefully, since it doesn't provide the actual /// account health, just a value that is guaranteed to be less than it. pub fn new_health_cache_skipping_bad_oracles( account: &MangoAccountRef, retriever: &impl AccountRetriever, now_ts: u64, ) -> Result { new_health_cache_impl(account, retriever, now_ts, true) } fn new_health_cache_impl( account: &MangoAccountRef, retriever: &impl AccountRetriever, now_ts: u64, // If an oracle is stale or inconfident and the health contribution would // not be negative, skip it. This decreases health, but maybe overall it's // still positive? skip_bad_oracles: bool, ) -> Result { // token contribution from token accounts let mut token_infos = Vec::with_capacity(account.active_token_positions().count()); for (i, position) in account.active_token_positions().enumerate() { let bank_oracle_result = retriever.bank_and_oracle(&account.fixed.group, i, position.token_index); if skip_bad_oracles && bank_oracle_result.is_oracle_error() && position.indexed_position >= 0 { // Ignore the asset because the oracle is bad, decreasing total health continue; } let (bank, oracle_price) = bank_oracle_result?; let native = position.native(bank); let prices = Prices { oracle: oracle_price, stable: bank.stable_price(), }; // Use the liab price for computing weight scaling, because it's pessimistic and // causes the most unfavorable scaling. let liab_price = prices.liab(HealthType::Init); let (maint_asset_weight, maint_liab_weight) = bank.maint_weights(now_ts); token_infos.push(TokenInfo { token_index: bank.token_index, maint_asset_weight, init_asset_weight: bank.init_asset_weight, init_scaled_asset_weight: bank.scaled_init_asset_weight(liab_price), maint_liab_weight, init_liab_weight: bank.init_liab_weight, init_scaled_liab_weight: bank.scaled_init_liab_weight(liab_price), prices, balance_spot: native, allow_asset_liquidation: bank.allows_asset_liquidation(), }); } // Fill the TokenInfo balance with free funds in serum3 oo accounts and build Serum3Infos. let mut serum3_infos = Vec::with_capacity(account.active_serum3_orders().count()); 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_info_index = find_token_info_index(&token_infos, serum_account.base_token_index)?; let quote_info_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(oo.native_pc_free); let base_info = &mut token_infos[base_info_index]; base_info.balance_spot += base_free; let quote_info = &mut token_infos[quote_info_index]; quote_info.balance_spot += quote_free; serum3_infos.push(Serum3Info::new( serum_account, oo, base_info_index, quote_info_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, 0, 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(0).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 = 0; 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), (0.05, 0.02)); let perpaccount = account.ensure_perp_position(9, 0).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 serum1 = 1.0 + (20.0 + 15.0 * 5.0); // and perp (scenario: bids execute) let perp1 = (3.0 + 7.0 + 1.0) * 10.0 * 5.0 * 0.8 + (-310.0 + 2.0 * 100.0 - 7.0 * 10.0 * 5.0); let health1 = (100.0 + serum1 + perp1) * 0.8; // for bank2/oracle2 let health2 = (-10.0 + 3.0) * 5.0 * 1.5; assert!(health_eq( compute_health(&account.borrow(), HealthType::Init, &retriever, 0).unwrap(), health1 + health2 )); } #[derive(Default)] struct BankSettings { deposits: u64, borrows: u64, deposit_weight_scale_start_quote: u64, borrow_weight_scale_start_quote: u64, potential_serum_tokens: u64, } #[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, bank_settings: [BankSettings; 3], extra: Option, } 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, 0, 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(0).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(); for (settings, bank) in testcase .bank_settings .iter() .zip([&mut bank1, &mut bank2, &mut bank3].iter_mut()) { let bank = bank.data(); bank.indexed_deposits = I80F48::from(settings.deposits) / bank.deposit_index; bank.indexed_borrows = I80F48::from(settings.borrows) / bank.borrow_index; bank.potential_serum_tokens = settings.potential_serum_tokens; if settings.deposit_weight_scale_start_quote > 0 { bank.deposit_weight_scale_start_quote = settings.deposit_weight_scale_start_quote as f64; } if settings.borrow_weight_scale_start_quote > 0 { bank.borrow_weight_scale_start_quote = settings.borrow_weight_scale_start_quote as f64; } } 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 = 0; 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 = 0; 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), (0.05, 0.02)); let perpaccount = account.ensure_perp_position(9, 0).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; if let Some(extra_fn) = testcase.extra { extra_fn(&mut account); } 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, 0).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 0.8 * (100.0 // including open orders (scenario: bids execute) + (20.0 + 15.0 * base_price) // including perp (scenario: bids execute) + (3.0 + 7.0) * base_lots_to_quote * 0.8 + (-131.0 - 7.0 * base_lots_to_quote)) // for token2 - 10.0 * base_price * 1.5, ..Default::default() }, TestHealth1Case { // 1 token1: -100, token2: 10, oo_1_2: (20, 15), perp1: (-10, -131, 7, 11), expected_health: // for token1 1.2 * (-100.0 // for perp (scenario: asks execute) + (-10.0 - 11.0) * base_lots_to_quote * 1.2 + (-131.0 + 11.0 * base_lots_to_quote)) // for token2, including open orders (scenario: asks execute) + (10.0 * base_price + (20.0 + 15.0 * base_price)) * 0.5, ..Default::default() }, TestHealth1Case { // 2: weighted positive perp pnl perp1: (-1, 100, 0, 0), expected_health: 0.8 * 0.95 * (100.0 - 1.2 * 1.0 * base_lots_to_quote), ..Default::default() }, TestHealth1Case { // 3: negative perp pnl is not weighted (only the settle token weight) perp1: (1, -100, 0, 0), expected_health: 1.2 * (-100.0 + 0.8 * 1.0 * base_lots_to_quote), ..Default::default() }, TestHealth1Case { // 4: perp health perp1: (10, 100, 0, 0), expected_health: 0.8 * 0.95 * (100.0 + 0.8 * 10.0 * base_lots_to_quote), ..Default::default() }, TestHealth1Case { // 5: perp health perp1: (30, -100, 0, 0), expected_health: 0.8 * 0.95 * (-100.0 + 0.8 * 30.0 * base_lots_to_quote), ..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() }, TestHealth1Case { // 10, checking collateral limit token1: 100, token2: 100, token3: 100, bank_settings: [ BankSettings { deposits: 100, deposit_weight_scale_start_quote: 1000, ..BankSettings::default() }, BankSettings { deposits: 1500, deposit_weight_scale_start_quote: 1000 * 5, ..BankSettings::default() }, BankSettings { deposits: 10000, deposit_weight_scale_start_quote: 1000 * 10, ..BankSettings::default() }, ], expected_health: // token1 0.8 * 100.0 // token2 + 0.5 * 100.0 * 5.0 * (5000.0 / (1500.0 * 5.0)) // token3 + 0.5 * 100.0 * 10.0 * (10000.0 / (10000.0 * 10.0)), ..Default::default() }, TestHealth1Case { // 11, checking borrow limit token1: -100, token2: -100, token3: -100, bank_settings: [ BankSettings { borrows: 100, borrow_weight_scale_start_quote: 1000, ..BankSettings::default() }, BankSettings { borrows: 1500, borrow_weight_scale_start_quote: 1000 * 5, ..BankSettings::default() }, BankSettings { borrows: 10000, borrow_weight_scale_start_quote: 1000 * 10, ..BankSettings::default() }, ], expected_health: // token1 -1.2 * 100.0 // token2 - 1.5 * 100.0 * 5.0 * (1500.0 * 5.0 / 5000.0) // token3 - 1.5 * 100.0 * 10.0 * (10000.0 * 10.0 / 10000.0), ..Default::default() }, TestHealth1Case { // 12: positive perp health offsets token borrow token1: -100, perp1: (1, 100, 0, 0), expected_health: 0.8 * (-100.0 + 0.95 * (100.0 + 0.8 * 1.0 * base_lots_to_quote)), ..Default::default() }, TestHealth1Case { // 13: negative perp health offsets token deposit token1: 100, perp1: (-1, -100, 0, 0), expected_health: 1.2 * (100.0 - 100.0 - 1.2 * 1.0 * base_lots_to_quote), ..Default::default() }, TestHealth1Case { // 14, reserved oo funds with max bid/min ask token1: -100, token2: -10, token3: 0, oo_1_2: (1, 1), oo_1_3: (11, 1), expected_health: // tokens -100.0 * 1.2 - 10.0 * 5.0 * 1.5 // oo_1_2 (-> token1) + (1.0 + 3.0) * 1.2 // oo_1_3 (-> token3) + (11.0 / 12.0 + 1.0) * 10.0 * 0.5, extra: Some(|account: &mut MangoAccountValue| { let s2 = account.serum3_orders_mut(2).unwrap(); s2.lowest_placed_ask = 3.0; let s3 = account.serum3_orders_mut(3).unwrap(); s3.highest_placed_bid_inv = 1.0 / 12.0; }), ..Default::default() }, TestHealth1Case { // 15, reserved oo funds with max bid/min ask not crossing oracle token1: -100, token2: -10, token3: 0, oo_1_2: (1, 1), oo_1_3: (11, 1), expected_health: // tokens -100.0 * 1.2 - 10.0 * 5.0 * 1.5 // oo_1_2 (-> token1) + (1.0 + 5.0) * 1.2 // oo_1_3 (-> token3) + (11.0 / 10.0 + 1.0) * 10.0 * 0.5, extra: Some(|account: &mut MangoAccountValue| { let s2 = account.serum3_orders_mut(2).unwrap(); s2.lowest_placed_ask = 6.0; let s3 = account.serum3_orders_mut(3).unwrap(); s3.highest_placed_bid_inv = 1.0 / 9.0; }), ..Default::default() }, TestHealth1Case { // 16, base case for 17 token1: 100, token2: 100, token3: 100, oo_1_2: (0, 100), oo_1_3: (0, 100), expected_health: // tokens 100.0 * 0.8 + 100.0 * 5.0 * 0.5 + 100.0 * 10.0 * 0.5 // oo_1_2 (-> token2) + 100.0 * 5.0 * 0.5 // oo_1_3 (-> token1) + 100.0 * 10.0 * 0.5, ..Default::default() }, TestHealth1Case { // 17, potential_serum_tokens counts for deposit weight scaling token1: 100, token2: 100, token3: 100, oo_1_2: (0, 100), oo_1_3: (0, 100), bank_settings: [ BankSettings { ..BankSettings::default() }, BankSettings { deposits: 100, deposit_weight_scale_start_quote: 100 * 5, potential_serum_tokens: 100, ..BankSettings::default() }, BankSettings { deposits: 600, deposit_weight_scale_start_quote: 500 * 10, potential_serum_tokens: 100, ..BankSettings::default() }, ], expected_health: // tokens 100.0 * 0.8 + 100.0 * 5.0 * 0.5 * (100.0 / 200.0) + 100.0 * 10.0 * 0.5 * (500.0 / 700.0) // oo_1_2 (-> token2) + 100.0 * 5.0 * 0.5 * (100.0 / 200.0) // oo_1_3 (-> token1) + 100.0 * 10.0 * 0.5 * (500.0 / 700.0), ..Default::default() }, ]; for (i, testcase) in testcases.iter().enumerate() { println!("checking testcase {}", i); test_health1_runner(testcase); } } }