mango-v4/programs/mango-v4/src/health/cache.rs

1883 lines
73 KiB
Rust

/*!
* 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<I80F48> {
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<I80F48> {
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<I80F48> {
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<I80F48> {
// 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<Self> {
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<TokenInfo>,
pub(crate) serum3_infos: Vec<Serum3Info>,
pub(crate) perp_infos: Vec<PerpInfo>,
#[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<usize> {
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<usize> {
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<TokenMaxReserved>, Vec<Serum3Reserved>) {
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<TokenBalance> {
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<TokenBalance> {
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<I80F48> {
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<I80F48> {
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<usize> {
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<HealthCache> {
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<HealthCache> {
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<HealthCache> {
// 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::<OpenOrders>::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(&mut MangoAccountValue)>,
}
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::<OpenOrders>::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::<OpenOrders>::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);
}
}
}