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

1153 lines
43 KiB
Rust

use anchor_lang::prelude::*;
use fixed::types::I80F48;
use serum_dex::state::OpenOrders;
use std::collections::HashMap;
use crate::accounts_zerocopy::*;
use crate::error::MangoError;
use crate::serum3_cpi;
use crate::state::{oracle_price, Bank, MangoAccount, PerpMarket, PerpMarketIndex, TokenIndex};
use crate::util::checked_math as cm;
/// This trait abstracts how to find accounts needed for the health computation.
///
/// There are different ways they are retrieved from remainingAccounts, based
/// on the instruction:
/// - FixedOrderAccountRetriever requires the remainingAccounts to be in a well
/// defined order and is the fastest. It's used where possible.
/// - ScanningAccountRetriever does a linear scan for each account it needs.
/// It needs more compute, but works when a union of bank/oracle/market accounts
/// are passed because health needs to be computed for different baskets in
/// one instruction (such as for liquidation instructions).
pub trait AccountRetriever {
fn bank_and_oracle(
&self,
group: &Pubkey,
account_index: usize,
token_index: TokenIndex,
) -> Result<(&Bank, I80F48)>;
fn serum_oo(&self, account_index: usize, key: &Pubkey) -> Result<&OpenOrders>;
fn perp_market(
&self,
group: &Pubkey,
account_index: usize,
perp_market_index: PerpMarketIndex,
) -> Result<&PerpMarket>;
}
/// Assumes the account infos needed for the health computation follow a strict order.
///
/// 1. n_banks Bank account, in the order of account.tokens.iter_active()
/// 2. n_banks oracle accounts, one for each bank in the same order
/// 3. PerpMarket accounts, in the order of account.perps.iter_active_accounts()
/// 4. serum3 OpenOrders accounts, in the order of account.serum3.iter_active()
pub struct FixedOrderAccountRetriever<T: KeyedAccountReader> {
pub ais: Vec<T>,
pub n_banks: usize,
pub begin_perp: usize,
pub begin_serum3: usize,
}
pub fn new_fixed_order_account_retriever<'a, 'info>(
ais: &'a [AccountInfo<'info>],
account: &MangoAccount,
) -> Result<FixedOrderAccountRetriever<AccountInfoRef<'a, 'info>>> {
let active_token_len = account.tokens.iter_active().count();
let active_serum3_len = account.serum3.iter_active().count();
let active_perp_len = account.perps.iter_active_accounts().count();
let expected_ais = cm!(active_token_len * 2 // banks + oracles
+ active_perp_len // PerpMarkets
+ active_serum3_len); // open_orders
require!(ais.len() == expected_ais, MangoError::SomeError);
Ok(FixedOrderAccountRetriever {
ais: ais
.into_iter()
.map(|ai| AccountInfoRef::borrow(ai))
.collect::<Result<Vec<_>>>()?,
n_banks: active_token_len,
begin_perp: cm!(active_token_len * 2),
begin_serum3: cm!(active_token_len * 2 + active_perp_len),
})
}
impl<T: KeyedAccountReader> FixedOrderAccountRetriever<T> {
fn bank(&self, group: &Pubkey, account_index: usize) -> Result<&Bank> {
let bank = self.ais[account_index].load::<Bank>()?;
require!(&bank.group == group, MangoError::SomeError);
Ok(bank)
}
}
impl<T: KeyedAccountReader> AccountRetriever for FixedOrderAccountRetriever<T> {
fn bank_and_oracle(
&self,
group: &Pubkey,
account_index: usize,
token_index: TokenIndex,
) -> Result<(&Bank, I80F48)> {
let bank = self.bank(group, account_index)?;
require!(bank.token_index == token_index, MangoError::SomeError);
let oracle = &self.ais[cm!(self.n_banks + account_index)];
require!(&bank.oracle == oracle.key(), MangoError::SomeError);
Ok((
bank,
oracle_price(oracle, bank.oracle_config.conf_filter, bank.mint_decimals)?,
))
}
fn perp_market(
&self,
group: &Pubkey,
account_index: usize,
perp_market_index: PerpMarketIndex,
) -> Result<&PerpMarket> {
let ai = &self.ais[cm!(self.begin_perp + account_index)];
let market = ai.load::<PerpMarket>()?;
require!(&market.group == group, MangoError::SomeError);
require!(
market.perp_market_index == perp_market_index,
MangoError::SomeError
);
Ok(market)
}
fn serum_oo(&self, account_index: usize, key: &Pubkey) -> Result<&OpenOrders> {
let ai = &self.ais[cm!(self.begin_serum3 + account_index)];
require!(key == ai.key(), MangoError::SomeError);
serum3_cpi::load_open_orders(ai)
}
}
/// Takes a list of account infos containing
/// - an unknown number of Banks in any order, followed by
/// - the same number of oracles in the same order as the banks, followed by
/// - an unknown number of PerpMarket accounts
/// - an unknown number of serum3 OpenOrders accounts
/// and retrieves accounts needed for the health computation by doing a linear
/// scan for each request.
pub struct ScanningAccountRetriever<'a, 'info> {
ais: Vec<AccountInfoRefMut<'a, 'info>>,
token_index_map: HashMap<TokenIndex, usize>,
perp_index_map: HashMap<PerpMarketIndex, usize>,
}
impl<'a, 'info> ScanningAccountRetriever<'a, 'info> {
pub fn new(ais: &'a [AccountInfo<'info>], group: &Pubkey) -> Result<Self> {
let mut token_index_map = HashMap::with_capacity(ais.len() / 2);
for (i, ai) in ais.iter().enumerate() {
match ai.load::<Bank>() {
Ok(bank) => {
require!(&bank.group == group, MangoError::SomeError);
token_index_map.insert(bank.token_index, i);
}
Err(Error::AnchorError(error))
if error.error_code_number
== ErrorCode::AccountDiscriminatorMismatch as u32
|| error.error_code_number
== ErrorCode::AccountOwnedByWrongProgram as u32 =>
{
break;
}
Err(error) => return Err(error),
};
}
// skip all banks and oracles, then find number of PerpMarket accounts
let skip = token_index_map.len() * 2;
let mut perp_index_map = HashMap::with_capacity(ais.len() - skip);
for (i, ai) in ais[skip..].iter().enumerate() {
match ai.load::<PerpMarket>() {
Ok(perp_market) => {
require!(&perp_market.group == group, MangoError::SomeError);
perp_index_map.insert(perp_market.perp_market_index, cm!(skip + i));
}
Err(Error::AnchorError(error))
if error.error_code_number
== ErrorCode::AccountDiscriminatorMismatch as u32
|| error.error_code_number
== ErrorCode::AccountOwnedByWrongProgram as u32 =>
{
break;
}
Err(error) => return Err(error),
};
}
Ok(Self {
ais: ais
.into_iter()
.map(|ai| AccountInfoRefMut::borrow(ai))
.collect::<Result<Vec<_>>>()?,
token_index_map,
perp_index_map,
})
}
fn n_banks(&self) -> usize {
self.token_index_map.len()
}
fn begin_serum3(&self) -> usize {
2 * self.token_index_map.len() + self.perp_index_map.len()
}
#[inline]
fn bank_index(&self, token_index: TokenIndex) -> Result<usize> {
Ok(*self
.token_index_map
.get(&token_index)
.ok_or_else(|| error!(MangoError::SomeError))?)
}
#[inline]
fn perp_market_index(&self, perp_market_index: PerpMarketIndex) -> Result<usize> {
Ok(*self
.perp_index_map
.get(&perp_market_index)
.ok_or_else(|| error!(MangoError::SomeError))?)
}
pub fn banks_mut_and_oracles(
&mut self,
token_index1: TokenIndex,
token_index2: TokenIndex,
) -> Result<(&mut Bank, &mut Bank, I80F48, I80F48)> {
let index1 = self.bank_index(token_index1)?;
let index2 = self.bank_index(token_index2)?;
let (first, second, swap) = if index1 < index2 {
(index1, index2, false)
} else {
(index2, index1, true)
};
let n_banks = self.n_banks();
// split_at_mut after the first bank and after the second bank
let (first_bank_part, second_part) = self.ais.split_at_mut(first + 1);
let (second_bank_part, oracles_part) = second_part.split_at_mut(second - first);
let bank1 = first_bank_part[first].load_mut_fully_unchecked::<Bank>()?;
let bank2 = second_bank_part[second - (first + 1)].load_mut_fully_unchecked::<Bank>()?;
let oracle1 = &oracles_part[cm!(n_banks + first - (second + 1))];
let oracle2 = &oracles_part[cm!(n_banks + second - (second + 1))];
require!(&bank1.oracle == oracle1.key, MangoError::SomeError);
require!(&bank2.oracle == oracle2.key, MangoError::SomeError);
let mint_decimals1 = bank1.mint_decimals;
let mint_decimals2 = bank2.mint_decimals;
let price1 = oracle_price(oracle1, bank1.oracle_config.conf_filter, mint_decimals1)?;
let price2 = oracle_price(oracle2, bank2.oracle_config.conf_filter, mint_decimals2)?;
if swap {
Ok((bank2, bank1, price2, price1))
} else {
Ok((bank1, bank2, price1, price2))
}
}
}
impl<'a, 'info> AccountRetriever for ScanningAccountRetriever<'a, 'info> {
fn bank_and_oracle(
&self,
_group: &Pubkey,
_account_index: usize,
token_index: TokenIndex,
) -> Result<(&Bank, I80F48)> {
let index = self.bank_index(token_index)?;
let bank = self.ais[index].load_fully_unchecked::<Bank>()?;
let oracle = &self.ais[cm!(self.n_banks() + index)];
require!(&bank.oracle == oracle.key, MangoError::SomeError);
Ok((
bank,
oracle_price(oracle, bank.oracle_config.conf_filter, bank.mint_decimals)?,
))
}
fn perp_market(
&self,
_group: &Pubkey,
_account_index: usize,
perp_market_index: PerpMarketIndex,
) -> Result<&PerpMarket> {
let index = self.perp_market_index(perp_market_index)?;
self.ais[index].load_fully_unchecked::<PerpMarket>()
}
fn serum_oo(&self, _account_index: usize, key: &Pubkey) -> Result<&OpenOrders> {
let oo = self.ais[self.begin_serum3()..]
.iter()
.find(|ai| ai.key == key)
.ok_or_else(|| error!(MangoError::SomeError))?;
serum3_cpi::load_open_orders(oo)
}
}
/// There are two types of health, initial health used for opening new positions and maintenance
/// health used for liquidations. They are both calculated as a weighted sum of the assets
/// minus the liabilities but the maint. health uses slightly larger weights for assets and
/// slightly smaller weights for the liabilities. Zero is used as the bright line for both
/// i.e. if your init health falls below zero, you cannot open new positions and if your maint. health
/// falls below zero you will be liquidated.
#[derive(PartialEq, Copy, Clone, AnchorSerialize, AnchorDeserialize)]
pub enum HealthType {
Init,
Maint,
}
/// Computes health for a mango account given a set of account infos
///
/// These account infos must fit the fixed layout defined by FixedOrderAccountRetriever.
pub fn compute_health_from_fixed_accounts(
account: &MangoAccount,
health_type: HealthType,
ais: &[AccountInfo],
) -> Result<I80F48> {
let active_token_len = account.tokens.iter_active().count();
let active_serum3_len = account.serum3.iter_active().count();
let active_perp_len = account.perps.iter_active_accounts().count();
let expected_ais = cm!(active_token_len * 2 // banks + oracles
+ active_perp_len // PerpMarkets
+ active_serum3_len); // open_orders
require!(ais.len() == expected_ais, MangoError::SomeError);
let retriever = FixedOrderAccountRetriever {
ais: ais
.into_iter()
.map(|ai| AccountInfoRef::borrow(ai))
.collect::<Result<Vec<_>>>()?,
n_banks: active_token_len,
begin_perp: cm!(active_token_len * 2),
begin_serum3: cm!(active_token_len * 2 + active_perp_len),
};
new_health_cache(account, &retriever)?.health(health_type)
}
/// Compute health with an arbitrary AccountRetriever
pub fn compute_health(
account: &MangoAccount,
health_type: HealthType,
retriever: &impl AccountRetriever,
) -> Result<I80F48> {
new_health_cache(account, retriever)?.health(health_type)
}
struct TokenInfo {
token_index: TokenIndex,
maint_asset_weight: I80F48,
init_asset_weight: I80F48,
maint_liab_weight: I80F48,
init_liab_weight: I80F48,
oracle_price: I80F48, // native/native
// in health-reference-token native units
balance: I80F48,
// in health-reference-token native units
serum3_max_reserved: I80F48,
}
impl TokenInfo {
#[inline(always)]
fn asset_weight(&self, health_type: HealthType) -> I80F48 {
match health_type {
HealthType::Init => self.init_asset_weight,
HealthType::Maint => self.maint_asset_weight,
}
}
#[inline(always)]
fn liab_weight(&self, health_type: HealthType) -> I80F48 {
match health_type {
HealthType::Init => self.init_liab_weight,
HealthType::Maint => self.maint_liab_weight,
}
}
}
struct Serum3Info {
reserved: I80F48,
base_index: usize,
quote_index: usize,
}
impl Serum3Info {
#[inline(always)]
fn health_contribution(&self, health_type: HealthType, token_infos: &[TokenInfo]) -> I80F48 {
let base_info = &token_infos[self.base_index];
let quote_info = &token_infos[self.quote_index];
let reserved = self.reserved;
if reserved.is_zero() {
return I80F48::ZERO;
}
// How much the health would increase if the reserved balance were applied to the passed
// token info?
let compute_health_effect = |token_info: &TokenInfo| {
// This balance includes all possible reserved funds from markets that relate to the
// token, including this market itself: `reserved` is already included in `max_balance`.
let max_balance = cm!(token_info.balance + token_info.serum3_max_reserved);
// Assuming `reserved` was added to `max_balance` last (because that gives the smallest
// health effects): how much did health change because of it?
let (asset_part, liab_part) = if max_balance >= reserved {
(reserved, I80F48::ZERO)
} else if max_balance.is_negative() {
(I80F48::ZERO, reserved)
} else {
(max_balance, cm!(reserved - max_balance))
};
let asset_weight = token_info.asset_weight(health_type);
let liab_weight = token_info.liab_weight(health_type);
cm!(asset_weight * asset_part + liab_weight * liab_part)
};
let reserved_as_base = compute_health_effect(base_info);
let reserved_as_quote = compute_health_effect(quote_info);
reserved_as_base.min(reserved_as_quote)
}
}
struct PerpInfo {
maint_asset_weight: I80F48,
init_asset_weight: I80F48,
maint_liab_weight: I80F48,
init_liab_weight: I80F48,
// in health-reference-token native units, needs scaling by asset/liab
base: I80F48,
// in health-reference-token native units, no asset/liab factor needed
quote: I80F48,
}
impl PerpInfo {
/// Total health contribution from perp balances
///
/// Due to isolation of perp markets, users may never borrow against perp
/// positions without settling first: perp health is capped at zero.
///
/// Users need to settle their perp pnl with other perp market participants
/// in order to realize their gains if they want to use them as collateral.
///
/// This is because we don't trust the perp's base price to not suddenly jump to
/// zero (if users could borrow against their perp balances they might now
/// be bankrupt) or suddenly increase a lot (if users could borrow against perp
/// balances they could now borrow other assets).
#[inline(always)]
fn health_contribution(&self, health_type: HealthType) -> I80F48 {
let weight = match (health_type, self.base.is_negative()) {
(HealthType::Init, true) => self.init_liab_weight,
(HealthType::Init, false) => self.init_asset_weight,
(HealthType::Maint, true) => self.maint_liab_weight,
(HealthType::Maint, false) => self.maint_asset_weight,
};
// FUTURE: Allow v3-style "reliable" markets where we can return
// `self.quote + weight * self.base` here
cm!(self.quote + weight * self.base).min(I80F48::ZERO)
}
}
pub struct HealthCache {
token_infos: Vec<TokenInfo>,
serum3_infos: Vec<Serum3Info>,
perp_infos: Vec<PerpInfo>,
}
impl HealthCache {
pub fn health(&self, health_type: HealthType) -> Result<I80F48> {
let mut health = I80F48::ZERO;
for token_info in self.token_infos.iter() {
let contrib = health_contribution(health_type, token_info, token_info.balance)?;
health = cm!(health + contrib);
}
for serum3_info in self.serum3_infos.iter() {
let contrib = serum3_info.health_contribution(health_type, &self.token_infos);
health = cm!(health + contrib);
}
for perp_info in self.perp_infos.iter() {
let contrib = perp_info.health_contribution(health_type);
health = cm!(health + contrib);
}
Ok(health)
}
pub fn adjust_token_balance(&mut self, token_index: TokenIndex, change: I80F48) -> Result<()> {
let mut entry = self
.token_infos
.iter_mut()
.find(|t| t.token_index == token_index)
.ok_or_else(|| error!(MangoError::SomeError))?;
entry.balance = cm!(entry.balance + change * entry.oracle_price);
Ok(())
}
}
/// Compute health contribution for a given balance
/// wart: independent of the balance stored in TokenInfo
/// balance is in health-reference-token native units
#[inline(always)]
fn health_contribution(
health_type: HealthType,
info: &TokenInfo,
balance: I80F48,
) -> Result<I80F48> {
let weight = if balance.is_negative() {
info.liab_weight(health_type)
} else {
info.asset_weight(health_type)
};
Ok(cm!(balance * weight))
}
/// Generate a HealthCache for an account and its health accounts.
pub fn new_health_cache(
account: &MangoAccount,
retriever: &impl AccountRetriever,
) -> Result<HealthCache> {
// token contribution from token accounts
let mut token_infos = vec![];
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!(MangoError::SomeError))
}
for (i, position) in account.tokens.iter_active().enumerate() {
let (bank, oracle_price) =
retriever.bank_and_oracle(&account.group, i, position.token_index)?;
// converts the token value to the basis token value for health computations
// TODO: health basis token == USDC?
let native = position.native(&bank);
token_infos.push(TokenInfo {
token_index: bank.token_index,
maint_asset_weight: bank.maint_asset_weight,
init_asset_weight: bank.init_asset_weight,
maint_liab_weight: bank.maint_liab_weight,
init_liab_weight: bank.init_liab_weight,
oracle_price,
balance: cm!(native * oracle_price),
serum3_max_reserved: I80F48::ZERO,
});
}
// Fill the TokenInfo balance with free funds in serum3 oo accounts, and fill
// the serum3_max_reserved with their reserved funds. Also build Serum3Infos.
let mut serum3_infos = vec![];
for (i, serum_account) in account.serum3.iter_active().enumerate() {
let oo = retriever.serum_oo(i, &serum_account.open_orders)?;
// find the TokenInfos for the market's base and quote tokens
let base_index = find_token_info_index(&token_infos, serum_account.base_token_index)?;
let quote_index = find_token_info_index(&token_infos, serum_account.quote_token_index)?;
let (base_info, quote_info) = if base_index < quote_index {
let (l, r) = token_infos.split_at_mut(quote_index);
(&mut l[base_index], &mut r[0])
} else {
let (l, r) = token_infos.split_at_mut(base_index);
(&mut r[0], &mut l[quote_index])
};
// add the amounts that are freely settleable
let base_free = I80F48::from_num(oo.native_coin_free);
let quote_free = I80F48::from_num(cm!(oo.native_pc_free + oo.referrer_rebates_accrued));
base_info.balance = cm!(base_info.balance + base_free * base_info.oracle_price);
quote_info.balance = cm!(quote_info.balance + quote_free * quote_info.oracle_price);
// add the reserved amount to both sides, to have the worst-case covered
let reserved_base = I80F48::from_num(cm!(oo.native_coin_total - oo.native_coin_free));
let reserved_quote = I80F48::from_num(cm!(oo.native_pc_total - oo.native_pc_free));
let reserved_balance =
cm!(reserved_base * base_info.oracle_price + reserved_quote * quote_info.oracle_price);
base_info.serum3_max_reserved = cm!(base_info.serum3_max_reserved + reserved_balance);
quote_info.serum3_max_reserved = cm!(quote_info.serum3_max_reserved + reserved_balance);
serum3_infos.push(Serum3Info {
reserved: reserved_balance,
base_index,
quote_index,
});
}
// health contribution from perp accounts
let mut perp_infos = Vec::with_capacity(account.perps.iter_active_accounts().count());
for (i, perp_account) in account.perps.iter_active_accounts().enumerate() {
let perp_market = retriever.perp_market(&account.group, i, perp_account.market_index)?;
// find the TokenInfos for the market's base and quote tokens
let base_index = find_token_info_index(&token_infos, perp_market.base_token_index)?;
let base_info = &token_infos[base_index];
let base_lot_size = I80F48::from(perp_market.base_lot_size);
let base_lots = cm!(perp_account.base_position_lots + perp_account.taker_base_lots);
let taker_quote = I80F48::from(cm!(
perp_account.taker_quote_lots * perp_market.quote_lot_size
));
let quote_current = cm!(perp_account.quote_position_native + taker_quote);
// Two scenarios:
// 1. The price goes low and all bids execute, converting to base.
// That means the perp position is increased by `bids` and the quote position
// is decreased by `bids * base_lot_size * price`.
// The health for this case is:
// (weighted(base_lots + bids) - bids) * base_lot_size * price + quote
// 2. The price goes high and all asks execute, converting to quote.
// The health for this case is:
// (weighted(base_lots - asks) + asks) * base_lot_size * price + quote
//
// Comparing these makes it clear we need to pick the worse subfactor
// weighted(base_lots + bids) - bids =: scenario1
// or
// weighted(base_lots - asks) + asks =: scenario2
//
// Additionally, we want this scenario choice to be the same no matter whether we're
// computing init or maint health. This can be guaranteed by requiring the weights
// to satisfy the property (P):
//
// (1 - init_asset_weight) / (init_liab_weight - 1)
// == (1 - maint_asset_weight) / (maint_liab_weight - 1)
//
// Derivation:
// Set asks_net_lots := base_lots - asks, bids_net_lots := base_lots + bids.
// Now
// scenario1 = weighted(bids_net_lots) - bids_net_lots + base_lots and
// scenario2 = weighted(asks_net_lots) - asks_net_lots + base_lots
// So with expanding weigthed(a) = weight_factor_for_a * a, the question
// scenario1 < scenario2
// becomes:
// (weight_factor_for_bids_net_lots - 1) * bids_net_lots
// < (weight_factor_for_asks_net_lots - 1) * asks_net_lots
// Since asks_net_lots < 0 and bids_net_lots > 0 is the only interesting case, (P) follows.
//
// We satisfy (P) by requiring
// asset_weight = 1 - x and liab_weight = 1 + x
//
// And with that assumption the scenario choice condition further simplifies to:
// scenario1 < scenario2
// iff abs(bids_net_lots) > abs(asks_net_lots)
let bids_net_lots = cm!(base_lots + perp_account.bids_base_lots);
let asks_net_lots = cm!(base_lots - perp_account.asks_base_lots);
let lots_to_quote = base_lot_size * base_info.oracle_price;
let base;
let quote;
if cm!(bids_net_lots.abs()) > cm!(asks_net_lots.abs()) {
let bids_net_lots = I80F48::from(bids_net_lots);
let bids_base_lots = I80F48::from(perp_account.bids_base_lots);
base = cm!(bids_net_lots * lots_to_quote);
quote = cm!(quote_current - bids_base_lots * lots_to_quote);
} else {
let asks_net_lots = I80F48::from(asks_net_lots);
let asks_base_lots = I80F48::from(perp_account.asks_base_lots);
base = cm!(asks_net_lots * lots_to_quote);
quote = cm!(quote_current + asks_base_lots * lots_to_quote);
};
perp_infos.push(PerpInfo {
init_asset_weight: perp_market.init_asset_weight,
init_liab_weight: perp_market.init_liab_weight,
maint_asset_weight: perp_market.maint_asset_weight,
maint_liab_weight: perp_market.maint_liab_weight,
base,
quote,
});
}
Ok(HealthCache {
token_infos,
serum3_infos,
perp_infos,
})
}
#[cfg(test)]
mod tests {
use super::*;
use crate::state::oracle::StubOracle;
use std::cell::RefCell;
use std::convert::identity;
use std::mem::size_of;
use std::rc::Rc;
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
);
}
// Implementing TestAccount directly for ZeroCopy + Owner leads to a conflict
// because OpenOrders may add impls for those in the future.
trait MyZeroCopy: anchor_lang::ZeroCopy + Owner {}
impl MyZeroCopy for StubOracle {}
impl MyZeroCopy for Bank {}
impl MyZeroCopy for PerpMarket {}
struct TestAccount<T> {
bytes: Vec<u8>,
pubkey: Pubkey,
owner: Pubkey,
lamports: u64,
_phantom: std::marker::PhantomData<T>,
}
impl<T> TestAccount<T> {
fn new(bytes: Vec<u8>, owner: Pubkey) -> Self {
Self {
bytes,
owner,
pubkey: Pubkey::new_unique(),
lamports: 0,
_phantom: std::marker::PhantomData,
}
}
fn as_account_info(&mut self) -> AccountInfo {
AccountInfo {
key: &self.pubkey,
owner: &self.owner,
lamports: Rc::new(RefCell::new(&mut self.lamports)),
data: Rc::new(RefCell::new(&mut self.bytes)),
is_signer: false,
is_writable: false,
executable: false,
rent_epoch: 0,
}
}
}
impl<T: MyZeroCopy> TestAccount<T> {
fn new_zeroed() -> Self {
let mut bytes = vec![0u8; 8 + size_of::<T>()];
bytes[0..8].copy_from_slice(&T::discriminator());
Self::new(bytes, T::owner())
}
fn data(&mut self) -> &mut T {
bytemuck::from_bytes_mut(&mut self.bytes[8..])
}
}
impl TestAccount<OpenOrders> {
fn new_zeroed() -> Self {
let mut bytes = vec![0u8; 12 + size_of::<OpenOrders>()];
bytes[0..5].copy_from_slice(b"serum");
Self::new(bytes, Pubkey::new_unique())
}
fn data(&mut self) -> &mut OpenOrders {
bytemuck::from_bytes_mut(&mut self.bytes[5..5 + size_of::<OpenOrders>()])
}
}
fn mock_bank_and_oracle(
group: Pubkey,
token_index: TokenIndex,
price: f64,
init_weights: f64,
maint_weights: f64,
) -> (TestAccount<Bank>, TestAccount<StubOracle>) {
let mut oracle = TestAccount::<StubOracle>::new_zeroed();
oracle.data().price = I80F48::from_num(price);
let mut bank = TestAccount::<Bank>::new_zeroed();
bank.data().token_index = token_index;
bank.data().group = group;
bank.data().oracle = oracle.pubkey;
bank.data().deposit_index = I80F48::from(1_000_000);
bank.data().borrow_index = I80F48::from(1_000_000);
bank.data().init_asset_weight = I80F48::from_num(1.0 - init_weights);
bank.data().init_liab_weight = I80F48::from_num(1.0 + init_weights);
bank.data().maint_asset_weight = I80F48::from_num(1.0 - maint_weights);
bank.data().maint_liab_weight = I80F48::from_num(1.0 + maint_weights);
(bank, oracle)
}
// Run a health test that includes all the side values (like referrer_rebates_accrued)
#[test]
fn test_health0() {
let mut account = MangoAccount::default();
let group = Pubkey::new_unique();
let (mut bank1, mut oracle1) = mock_bank_and_oracle(group, 1, 1.0, 0.2, 0.1);
let (mut bank2, mut oracle2) = mock_bank_and_oracle(group, 4, 5.0, 0.5, 0.3);
bank1
.data()
.deposit(
account.tokens.get_mut_or_create(1).unwrap().0,
I80F48::from(100),
)
.unwrap();
bank2
.data()
.withdraw_without_fee(
account.tokens.get_mut_or_create(4).unwrap().0,
I80F48::from(10),
)
.unwrap();
let mut oo1 = TestAccount::<OpenOrders>::new_zeroed();
let serum3account = account.serum3.create(2).unwrap();
serum3account.open_orders = oo1.pubkey;
serum3account.base_token_index = 4;
serum3account.quote_token_index = 1;
oo1.data().native_pc_total = 21;
oo1.data().native_coin_total = 18;
oo1.data().native_pc_free = 1;
oo1.data().native_coin_free = 3;
oo1.data().referrer_rebates_accrued = 2;
let mut perp1 = TestAccount::<PerpMarket>::new_zeroed();
perp1.data().group = group;
perp1.data().perp_market_index = 9;
perp1.data().base_token_index = 4;
perp1.data().quote_token_index = 1;
perp1.data().init_asset_weight = I80F48::from_num(1.0 - 0.2f64);
perp1.data().init_liab_weight = I80F48::from_num(1.0 + 0.2f64);
perp1.data().maint_asset_weight = I80F48::from_num(1.0 - 0.1f64);
perp1.data().maint_liab_weight = I80F48::from_num(1.0 + 0.1f64);
perp1.data().quote_lot_size = 100;
perp1.data().base_lot_size = 10;
let perpaccount = account.perps.get_account_mut_or_create(9).unwrap().0;
perpaccount.base_position_lots = 3;
perpaccount.quote_position_native = -I80F48::from(310u16);
perpaccount.bids_base_lots = 7;
perpaccount.asks_base_lots = 11;
perpaccount.taker_base_lots = 1;
perpaccount.taker_quote_lots = 2;
let ais = vec![
bank1.as_account_info(),
bank2.as_account_info(),
oracle1.as_account_info(),
oracle2.as_account_info(),
perp1.as_account_info(),
oo1.as_account_info(),
];
let retriever = ScanningAccountRetriever::new(&ais, &group).unwrap();
let health_eq = |a: I80F48, b: f64| {
if (a - I80F48::from_num(b)).abs() < 0.001 {
true
} else {
println!("health is {}, but expected {}", a, b);
false
}
};
// for bank1/oracle1, including open orders (scenario: bids execute)
let health1 = (100.0 + 1.0 + 2.0 + (20.0 + 15.0 * 5.0)) * 0.8;
// for bank2/oracle2
let health2 = (-10.0 + 3.0) * 5.0 * 1.5;
// for perp (scenario: bids execute)
let health3 =
(3.0 + 7.0 + 1.0) * 10.0 * 5.0 * 0.8 + (-310.0 + 2.0 * 100.0 - 7.0 * 10.0 * 5.0);
assert!(health_eq(
compute_health(&account, HealthType::Init, &retriever).unwrap(),
health1 + health2 + health3
));
}
#[test]
fn test_scanning_account_retriever() {
let group = Pubkey::new_unique();
let (mut bank1, mut oracle1) = mock_bank_and_oracle(group, 1, 1.0, 0.2, 0.1);
let (mut bank2, mut oracle2) = mock_bank_and_oracle(group, 4, 5.0, 0.5, 0.3);
let mut oo1 = TestAccount::<OpenOrders>::new_zeroed();
let oo1key = oo1.pubkey;
oo1.data().native_pc_total = 20;
let mut perp1 = TestAccount::<PerpMarket>::new_zeroed();
perp1.data().group = group;
perp1.data().perp_market_index = 9;
let ais = vec![
bank1.as_account_info(),
bank2.as_account_info(),
oracle1.as_account_info(),
oracle2.as_account_info(),
perp1.as_account_info(),
oo1.as_account_info(),
];
let mut retriever = ScanningAccountRetriever::new(&ais, &group).unwrap();
assert_eq!(retriever.n_banks(), 2);
assert_eq!(retriever.begin_serum3(), 5);
assert_eq!(retriever.perp_index_map.len(), 1);
{
let (b1, b2, o1, o2) = retriever.banks_mut_and_oracles(1, 4).unwrap();
assert_eq!(b1.token_index, 1);
assert_eq!(o1, I80F48::ONE);
assert_eq!(b2.token_index, 4);
assert_eq!(o2, 5 * I80F48::ONE);
}
{
let (b1, b2, o1, o2) = retriever.banks_mut_and_oracles(4, 1).unwrap();
assert_eq!(b1.token_index, 4);
assert_eq!(o1, 5 * I80F48::ONE);
assert_eq!(b2.token_index, 1);
assert_eq!(o2, I80F48::ONE);
}
retriever.banks_mut_and_oracles(4, 2).unwrap_err();
let oo = retriever.serum_oo(0, &oo1key).unwrap();
assert_eq!(identity(oo.native_pc_total), 20);
assert!(retriever.serum_oo(1, &Pubkey::default()).is_err());
let perp = retriever.perp_market(&group, 0, 9).unwrap();
assert_eq!(identity(perp.perp_market_index), 9);
assert!(retriever.perp_market(&group, 1, 5).is_err());
}
#[derive(Default)]
struct TestHealth1Case {
token1: i64,
token2: i64,
token3: i64,
oo_1_2: (u64, u64),
oo_1_3: (u64, u64),
perp1: (i64, i64, i64, i64),
expected_health: f64,
}
fn test_health1_runner(testcase: &TestHealth1Case) {
let mut account = MangoAccount::default();
let group = Pubkey::new_unique();
let (mut bank1, mut oracle1) = mock_bank_and_oracle(group, 1, 1.0, 0.2, 0.1);
let (mut bank2, mut oracle2) = mock_bank_and_oracle(group, 4, 5.0, 0.5, 0.3);
let (mut bank3, mut oracle3) = mock_bank_and_oracle(group, 5, 10.0, 0.5, 0.3);
bank1
.data()
.change_without_fee(
account.tokens.get_mut_or_create(1).unwrap().0,
I80F48::from(testcase.token1),
)
.unwrap();
bank2
.data()
.change_without_fee(
account.tokens.get_mut_or_create(4).unwrap().0,
I80F48::from(testcase.token2),
)
.unwrap();
bank3
.data()
.change_without_fee(
account.tokens.get_mut_or_create(5).unwrap().0,
I80F48::from(testcase.token3),
)
.unwrap();
let mut oo1 = TestAccount::<OpenOrders>::new_zeroed();
let serum3account1 = account.serum3.create(2).unwrap();
serum3account1.open_orders = oo1.pubkey;
serum3account1.base_token_index = 4;
serum3account1.quote_token_index = 1;
oo1.data().native_pc_total = testcase.oo_1_2.0;
oo1.data().native_coin_total = testcase.oo_1_2.1;
let mut oo2 = TestAccount::<OpenOrders>::new_zeroed();
let serum3account2 = account.serum3.create(3).unwrap();
serum3account2.open_orders = oo2.pubkey;
serum3account2.base_token_index = 5;
serum3account2.quote_token_index = 1;
oo2.data().native_pc_total = testcase.oo_1_3.0;
oo2.data().native_coin_total = testcase.oo_1_3.1;
let mut perp1 = TestAccount::<PerpMarket>::new_zeroed();
perp1.data().group = group;
perp1.data().perp_market_index = 9;
perp1.data().base_token_index = 4;
perp1.data().quote_token_index = 1;
perp1.data().init_asset_weight = I80F48::from_num(1.0 - 0.2f64);
perp1.data().init_liab_weight = I80F48::from_num(1.0 + 0.2f64);
perp1.data().maint_asset_weight = I80F48::from_num(1.0 - 0.1f64);
perp1.data().maint_liab_weight = I80F48::from_num(1.0 + 0.1f64);
perp1.data().quote_lot_size = 100;
perp1.data().base_lot_size = 10;
let perpaccount = account.perps.get_account_mut_or_create(9).unwrap().0;
perpaccount.base_position_lots = testcase.perp1.0;
perpaccount.quote_position_native = I80F48::from(testcase.perp1.1);
perpaccount.bids_base_lots = testcase.perp1.2;
perpaccount.asks_base_lots = testcase.perp1.3;
let ais = vec![
bank1.as_account_info(),
bank2.as_account_info(),
bank3.as_account_info(),
oracle1.as_account_info(),
oracle2.as_account_info(),
oracle3.as_account_info(),
perp1.as_account_info(),
oo1.as_account_info(),
oo2.as_account_info(),
];
let retriever = ScanningAccountRetriever::new(&ais, &group).unwrap();
let health_eq = |a: I80F48, b: f64| {
if (a - I80F48::from_num(b)).abs() < 0.001 {
true
} else {
println!("health is {}, but expected {}", a, b);
false
}
};
assert!(health_eq(
compute_health(&account, HealthType::Init, &retriever).unwrap(),
testcase.expected_health
));
}
// Check some specific health constellations
#[test]
fn test_health1() {
let base_price = 5.0;
let base_lots_to_quote = 10.0 * base_price;
let testcases = vec![
TestHealth1Case { // 0
token1: 100,
token2: -10,
oo_1_2: (20, 15),
perp1: (3, -131, 7, 11),
expected_health:
// for token1, including open orders (scenario: bids execute)
(100.0 + (20.0 + 15.0 * base_price)) * 0.8
// for token2
- 10.0 * base_price * 1.5
// for perp (scenario: bids execute)
+ (3.0 + 7.0) * base_lots_to_quote * 0.8 + (-131.0 - 7.0 * base_lots_to_quote),
..Default::default()
},
TestHealth1Case { // 1
token1: -100,
token2: 10,
oo_1_2: (20, 15),
perp1: (-10, -131, 7, 11),
expected_health:
// for token1
-100.0 * 1.2
// for token2, including open orders (scenario: asks execute)
+ (10.0 * base_price + (20.0 + 15.0 * base_price)) * 0.5
// for perp (scenario: asks execute)
+ (-10.0 - 11.0) * base_lots_to_quote * 1.2 + (-131.0 + 11.0 * base_lots_to_quote),
..Default::default()
},
TestHealth1Case {
// 2
perp1: (-1, 100, 0, 0),
expected_health: 0.0,
..Default::default()
},
TestHealth1Case {
// 3
perp1: (1, -100, 0, 0),
expected_health: -100.0 + 0.8 * 1.0 * base_lots_to_quote,
..Default::default()
},
TestHealth1Case {
// 4
perp1: (10, 100, 0, 0),
expected_health: 0.0,
..Default::default()
},
TestHealth1Case {
// 5
perp1: (30, -100, 0, 0),
expected_health: 0.0,
..Default::default()
},
TestHealth1Case { // 6, reserved oo funds
token1: -100,
token2: -10,
token3: -10,
oo_1_2: (1, 1),
oo_1_3: (1, 1),
expected_health:
// tokens
-100.0 * 1.2 - 10.0 * 5.0 * 1.5 - 10.0 * 10.0 * 1.5
// oo_1_2 (-> token1)
+ (1.0 + 5.0) * 1.2
// oo_1_3 (-> token1)
+ (1.0 + 10.0) * 1.2,
..Default::default()
},
TestHealth1Case { // 7, reserved oo funds cross the zero balance level
token1: -14,
token2: -10,
token3: -10,
oo_1_2: (1, 1),
oo_1_3: (1, 1),
expected_health:
// tokens
-14.0 * 1.2 - 10.0 * 5.0 * 1.5 - 10.0 * 10.0 * 1.5
// oo_1_2 (-> token1)
+ 3.0 * 1.2 + 3.0 * 0.8
// oo_1_3 (-> token1)
+ 8.0 * 1.2 + 3.0 * 0.8,
..Default::default()
},
TestHealth1Case { // 8, reserved oo funds in a non-quote currency
token1: -100,
token2: -100,
token3: -1,
oo_1_2: (0, 0),
oo_1_3: (10, 1),
expected_health:
// tokens
-100.0 * 1.2 - 100.0 * 5.0 * 1.5 - 10.0 * 1.5
// oo_1_3 (-> token3)
+ 10.0 * 1.5 + 10.0 * 0.5,
..Default::default()
},
TestHealth1Case { // 9, like 8 but oo_1_2 flips the oo_1_3 target
token1: -100,
token2: -100,
token3: -1,
oo_1_2: (100, 0),
oo_1_3: (10, 1),
expected_health:
// tokens
-100.0 * 1.2 - 100.0 * 5.0 * 1.5 - 10.0 * 1.5
// oo_1_2 (-> token1)
+ 80.0 * 1.2 + 20.0 * 0.8
// oo_1_3 (-> token1)
+ 20.0 * 0.8,
..Default::default()
},
];
for (i, testcase) in testcases.iter().enumerate() {
println!("checking testcase {}", i);
test_health1_runner(&testcase);
}
}
}