Docs: improve HealthCache comments (#590)

This commit is contained in:
Christian Kamm 2023-05-19 14:42:14 +02:00 committed by GitHub
parent 0b22e41acd
commit 9f9f3d257c
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
3 changed files with 138 additions and 64 deletions

View File

@ -1,3 +1,19 @@
/*!
* 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;
@ -209,6 +225,12 @@ impl TokenInfo {
}
}
/// 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, AnchorDeserialize, AnchorSerialize, Debug)]
pub struct Serum3Info {
// reserved amounts as stored on the open orders
@ -216,10 +238,11 @@ pub struct Serum3Info {
pub reserved_quote: I80F48,
// Index into TokenInfos _not_ a TokenIndex
pub base_index: usize,
pub quote_index: usize,
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,
}
@ -251,6 +274,30 @@ impl Serum3Info {
self.reserved_quote + self.reserved_base * base_asset / quote_liab
}
/// 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,
@ -266,8 +313,8 @@ impl Serum3Info {
return I80F48::ZERO;
}
let base_info = &token_infos[self.base_index];
let quote_info = &token_infos[self.quote_index];
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?
@ -299,14 +346,14 @@ impl Serum3Info {
let health_base = compute_health_effect(
base_info,
&token_balances[self.base_index],
&token_max_reserved[self.base_index],
&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_index],
&token_max_reserved[self.quote_index],
&token_balances[self.quote_info_index],
&token_max_reserved[self.quote_info_index],
market_reserved.all_reserved_as_quote,
);
health_base.min(health_quote)
@ -321,6 +368,10 @@ pub(crate) struct Serum3Reserved {
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, AnchorDeserialize, AnchorSerialize, Debug)]
pub struct PerpInfo {
pub perp_market_index: PerpMarketIndex,
@ -403,7 +454,7 @@ impl PerpInfo {
self.weigh_uhupnl_overall(contribution, health_type)
}
/// Convert uhupnl to hupnl by applying the overall weight.
/// 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 {
@ -417,8 +468,17 @@ impl PerpInfo {
}
}
/// Health in terms of settle token, without the overall asset weight or the settle token weight or price.
/// also called "uhupnl"
/// 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| {
@ -461,6 +521,19 @@ impl PerpInfo {
}
}
/// 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.
#[derive(Clone, AnchorDeserialize, AnchorSerialize, Debug)]
pub struct HealthCache {
pub(crate) token_infos: Vec<TokenInfo>,
@ -839,16 +912,16 @@ impl HealthCache {
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_index];
let base_info = &self.token_infos[info.base_index];
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_index].max_serum_reserved += all_reserved_as_base;
token_max_reserved[info.quote_index].max_serum_reserved += all_reserved_as_quote;
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,
@ -981,17 +1054,17 @@ impl HealthCache {
.serum3_infos
.iter()
.filter_map(|info| {
if info.quote_index == target_token_info_index {
if info.quote_info_index == target_token_info_index {
Some(info.all_reserved_as_quote(
health_type,
&self.token_infos[info.quote_index],
&self.token_infos[info.base_index],
&self.token_infos[info.quote_info_index],
&self.token_infos[info.base_info_index],
))
} else if info.base_index == target_token_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_index],
&self.token_infos[info.base_index],
&self.token_infos[info.quote_info_index],
&self.token_infos[info.base_info_index],
))
} else {
None
@ -1054,15 +1127,16 @@ pub fn new_health_cache(
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_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_index];
let base_info = &mut token_infos[base_info_index];
base_info.balance_spot += base_free;
let quote_info = &mut token_infos[quote_index];
let quote_info = &mut token_infos[quote_info_index];
quote_info.balance_spot += quote_free;
// track the reserved amounts
@ -1072,8 +1146,8 @@ pub fn new_health_cache(
serum3_infos.push(Serum3Info {
reserved_base,
reserved_quote,
base_index,
quote_index,
base_info_index,
quote_info_index,
market_index: serum_account.market_index,
has_zero_funds: oo.native_coin_total == 0
&& oo.native_pc_total == 0

View File

@ -938,8 +938,8 @@ mod tests {
println!("test 6 {test_name}");
let mut health_cache = health_cache.clone();
health_cache.serum3_infos = vec![Serum3Info {
base_index: 1,
quote_index: 0,
base_info_index: 1,
quote_info_index: 0,
market_index: 0,
reserved_base: I80F48::from(30 / 3),
reserved_quote: I80F48::from(30 / 2),

View File

@ -108,19 +108,19 @@ export class HealthCache {
const oo = mangoAccount.getSerum3OoAccount(serum3.marketIndex);
// find the TokenInfos for the market's base and quote tokens
const baseIndex = tokenInfos.findIndex(
const baseInfoIndex = tokenInfos.findIndex(
(tokenInfo) => tokenInfo.tokenIndex === serum3.baseTokenIndex,
);
const baseInfo = tokenInfos[baseIndex];
const baseInfo = tokenInfos[baseInfoIndex];
if (!baseInfo) {
throw new Error(
`BaseInfo not found for market with marketIndex ${serum3.marketIndex}!`,
);
}
const quoteIndex = tokenInfos.findIndex(
const quoteInfoIndex = tokenInfos.findIndex(
(tokenInfo) => tokenInfo.tokenIndex === serum3.quoteTokenIndex,
);
const quoteInfo = tokenInfos[quoteIndex];
const quoteInfo = tokenInfos[quoteInfoIndex];
if (!quoteInfo) {
throw new Error(
`QuoteInfo not found for market with marketIndex ${serum3.marketIndex}!`,
@ -128,9 +128,9 @@ export class HealthCache {
}
return Serum3Info.fromOoModifyingTokenInfos(
baseIndex,
baseInfoIndex,
baseInfo,
quoteIndex,
quoteInfoIndex,
quoteInfo,
serum3.marketIndex,
oo,
@ -170,8 +170,8 @@ export class HealthCache {
const serum3Reserved: Serum3Reserved[] = [];
for (const info of this.serum3Infos) {
const quote = this.tokenInfos[info.quoteIndex];
const base = this.tokenInfos[info.baseIndex];
const quote = this.tokenInfos[info.quoteInfoIndex];
const base = this.tokenInfos[info.baseInfoIndex];
const reservedBase = info.reservedBase;
const reservedQuote = info.reservedQuote;
@ -187,9 +187,9 @@ export class HealthCache {
reservedBase.mul(baseAsset).div(quoteLiab),
);
const baseMaxReserved = tokenMaxReserved[info.baseIndex];
const baseMaxReserved = tokenMaxReserved[info.baseInfoIndex];
baseMaxReserved.maxSerumReserved.iadd(allReservedAsBase);
const quoteMaxReserved = tokenMaxReserved[info.quoteIndex];
const quoteMaxReserved = tokenMaxReserved[info.quoteInfoIndex];
quoteMaxReserved.maxSerumReserved.iadd(allReservedAsQuote);
serum3Reserved.push(
@ -1356,8 +1356,8 @@ export class Serum3Info {
constructor(
public reservedBase: I80F48,
public reservedQuote: I80F48,
public baseIndex: number,
public quoteIndex: number,
public baseInfoIndex: number,
public quoteInfoIndex: number,
public marketIndex: MarketIndex,
) {}
@ -1365,8 +1365,8 @@ export class Serum3Info {
return new Serum3Info(
I80F48.from(dto.reservedBase),
I80F48.from(dto.reservedQuote),
dto.baseIndex,
dto.quoteIndex,
dto.baseInfoIndex,
dto.quoteInfoIndex,
dto.marketIndex as MarketIndex,
);
}
@ -1386,9 +1386,9 @@ export class Serum3Info {
}
static fromOoModifyingTokenInfos(
baseIndex: number,
baseInfoIndex: number,
baseInfo: TokenInfo,
quoteIndex: number,
quoteInfoIndex: number,
quoteInfo: TokenInfo,
marketIndex: MarketIndex,
oo: OpenOrders,
@ -1412,8 +1412,8 @@ export class Serum3Info {
return new Serum3Info(
reservedBase,
reservedQuote,
baseIndex,
quoteIndex,
baseInfoIndex,
quoteInfoIndex,
marketIndex,
);
}
@ -1433,10 +1433,10 @@ export class Serum3Info {
return ZERO_I80F48();
}
const baseInfo = tokenInfos[this.baseIndex];
const quoteInfo = tokenInfos[this.quoteIndex];
const baseMaxReserved = tokenMaxReserved[this.baseIndex];
const quoteMaxReserved = tokenMaxReserved[this.quoteIndex];
const baseInfo = tokenInfos[this.baseInfoIndex];
const quoteInfo = tokenInfos[this.quoteInfoIndex];
const baseMaxReserved = tokenMaxReserved[this.baseInfoIndex];
const quoteMaxReserved = tokenMaxReserved[this.quoteInfoIndex];
// How much the health would increase if the reserved balance were applied to the passed
// token info?
@ -1483,14 +1483,14 @@ export class Serum3Info {
const healthBase = computeHealthEffect(
baseInfo,
tokenBalances[this.baseIndex],
tokenMaxReserved[this.baseIndex],
tokenBalances[this.baseInfoIndex],
tokenMaxReserved[this.baseInfoIndex],
marketReserved.allReservedAsBase,
);
const healthQuote = computeHealthEffect(
quoteInfo,
tokenBalances[this.quoteIndex],
tokenMaxReserved[this.quoteIndex],
tokenBalances[this.quoteInfoIndex],
tokenMaxReserved[this.quoteInfoIndex],
marketReserved.allReservedAsQuote,
);
@ -1506,9 +1506,9 @@ export class Serum3Info {
tokenMaxReserved: TokenMaxReserved[],
marketReserved: Serum3Reserved,
): string {
return ` marketIndex: ${this.marketIndex}, baseIndex: ${
this.baseIndex
}, quoteIndex: ${this.quoteIndex}, reservedBase: ${
return ` marketIndex: ${this.marketIndex}, baseInfoIndex: ${
this.baseInfoIndex
}, quoteInfoIndex: ${this.quoteInfoIndex}, reservedBase: ${
this.reservedBase
}, reservedQuote: ${
this.reservedQuote
@ -1800,20 +1800,20 @@ export class TokenInfoDto {
export class Serum3InfoDto {
reservedBase: I80F48Dto;
reservedQuote: I80F48Dto;
baseIndex: number;
quoteIndex: number;
baseInfoIndex: number;
quoteInfoIndex: number;
marketIndex: number;
constructor(
reservedBase: I80F48Dto,
reservedQuote: I80F48Dto,
baseIndex: number,
quoteIndex: number,
baseInfoIndex: number,
quoteInfoIndex: number,
) {
this.reservedBase = reservedBase;
this.reservedQuote = reservedQuote;
this.baseIndex = baseIndex;
this.quoteIndex = quoteIndex;
this.baseInfoIndex = baseInfoIndex;
this.quoteInfoIndex = quoteInfoIndex;
}
}