1169 lines
43 KiB
Rust
1169 lines
43 KiB
Rust
use anchor_lang::prelude::*;
|
|
|
|
use fixed::types::I80F48;
|
|
|
|
use crate::error::*;
|
|
use crate::state::{
|
|
Bank, MangoAccountRef, PerpMarket, PerpMarketIndex, PerpPosition, Serum3MarketIndex, TokenIndex,
|
|
};
|
|
use crate::util::checked_math as cm;
|
|
|
|
use super::*;
|
|
|
|
/// Information about prices for a bank or perp market.
|
|
#[derive(Clone, AnchorDeserialize, AnchorSerialize, 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 {
|
|
if health_type == HealthType::Maint {
|
|
self.oracle
|
|
} else {
|
|
self.oracle.max(self.stable)
|
|
}
|
|
}
|
|
|
|
/// The asset price to use for the given health type
|
|
#[inline(always)]
|
|
pub fn asset(&self, health_type: HealthType) -> I80F48 {
|
|
if health_type == HealthType::Maint {
|
|
self.oracle
|
|
} else {
|
|
self.oracle.min(self.stable)
|
|
}
|
|
}
|
|
}
|
|
|
|
/// There are two types of health, initial health used for opening new positions and maintenance
|
|
/// health used for liquidations. They are both calculated as a weighted sum of the assets
|
|
/// minus the liabilities but the maint. health uses slightly larger weights for assets and
|
|
/// slightly smaller weights for the liabilities. Zero is used as the bright line for both
|
|
/// i.e. if your init health falls below zero, you cannot open new positions and if your maint. health
|
|
/// falls below zero you will be liquidated.
|
|
#[derive(PartialEq, Copy, Clone, AnchorSerialize, AnchorDeserialize)]
|
|
pub enum HealthType {
|
|
Init,
|
|
Maint,
|
|
}
|
|
|
|
/// Computes health for a mango account given a set of account infos
|
|
///
|
|
/// These account infos must fit the fixed layout defined by FixedOrderAccountRetriever.
|
|
pub fn compute_health_from_fixed_accounts(
|
|
account: &MangoAccountRef,
|
|
health_type: HealthType,
|
|
ais: &[AccountInfo],
|
|
) -> Result<I80F48> {
|
|
let retriever = new_fixed_order_account_retriever(ais, account)?;
|
|
Ok(new_health_cache(account, &retriever)?.health(health_type))
|
|
}
|
|
|
|
/// Compute health with an arbitrary AccountRetriever
|
|
pub fn compute_health(
|
|
account: &MangoAccountRef,
|
|
health_type: HealthType,
|
|
retriever: &impl AccountRetriever,
|
|
) -> Result<I80F48> {
|
|
Ok(new_health_cache(account, retriever)?.health(health_type))
|
|
}
|
|
|
|
#[derive(Clone, AnchorDeserialize, AnchorSerialize, Debug)]
|
|
pub struct TokenInfo {
|
|
pub token_index: TokenIndex,
|
|
pub maint_asset_weight: I80F48,
|
|
pub init_asset_weight: I80F48,
|
|
pub maint_liab_weight: I80F48,
|
|
pub init_liab_weight: I80F48,
|
|
pub prices: Prices,
|
|
pub balance_native: I80F48,
|
|
}
|
|
|
|
impl TokenInfo {
|
|
#[inline(always)]
|
|
fn asset_weight(&self, health_type: HealthType) -> I80F48 {
|
|
match health_type {
|
|
HealthType::Init => self.init_asset_weight,
|
|
HealthType::Maint => self.maint_asset_weight,
|
|
}
|
|
}
|
|
|
|
#[inline(always)]
|
|
fn liab_weight(&self, health_type: HealthType) -> I80F48 {
|
|
match health_type {
|
|
HealthType::Init => self.init_liab_weight,
|
|
HealthType::Maint => self.maint_liab_weight,
|
|
}
|
|
}
|
|
|
|
#[inline(always)]
|
|
fn health_contribution(&self, health_type: HealthType) -> I80F48 {
|
|
let (weight, price) = if self.balance_native.is_negative() {
|
|
(self.liab_weight(health_type), self.prices.liab(health_type))
|
|
} else {
|
|
(
|
|
self.asset_weight(health_type),
|
|
self.prices.asset(health_type),
|
|
)
|
|
};
|
|
cm!(self.balance_native * price * weight)
|
|
}
|
|
}
|
|
|
|
#[derive(Clone, AnchorDeserialize, AnchorSerialize, Debug)]
|
|
pub struct Serum3Info {
|
|
// reserved amounts as stored on the open orders
|
|
pub reserved_base: I80F48,
|
|
pub reserved_quote: I80F48,
|
|
|
|
pub base_index: usize,
|
|
pub quote_index: usize,
|
|
pub market_index: Serum3MarketIndex,
|
|
/// The open orders account has no free or reserved funds
|
|
pub has_zero_funds: bool,
|
|
}
|
|
|
|
impl Serum3Info {
|
|
#[inline(always)]
|
|
fn health_contribution(
|
|
&self,
|
|
health_type: HealthType,
|
|
token_infos: &[TokenInfo],
|
|
token_max_reserved: &[I80F48],
|
|
market_reserved: &Serum3Reserved,
|
|
) -> I80F48 {
|
|
if market_reserved.all_reserved_as_base.is_zero()
|
|
|| market_reserved.all_reserved_as_quote.is_zero()
|
|
{
|
|
return I80F48::ZERO;
|
|
}
|
|
|
|
let base_info = &token_infos[self.base_index];
|
|
let quote_info = &token_infos[self.quote_index];
|
|
let base_max_reserved = token_max_reserved[self.base_index];
|
|
let quote_max_reserved = token_max_reserved[self.quote_index];
|
|
|
|
// How much would health increase if the reserved balance were applied to the passed
|
|
// token info?
|
|
let compute_health_effect =
|
|
|token_info: &TokenInfo, token_max_reserved: I80F48, market_reserved: I80F48| {
|
|
// This balance includes all possible reserved funds from markets that relate to the
|
|
// token, including this market itself: `market_reserved` is already included in `token_max_reserved`.
|
|
let max_balance = cm!(token_info.balance_native + token_max_reserved);
|
|
|
|
// For simplicity, we assume that `market_reserved` was added to `max_balance` last
|
|
// (it underestimates health because that gives the smallest effects): how much did
|
|
// health change because of it?
|
|
let (asset_part, liab_part) = if max_balance >= market_reserved {
|
|
(market_reserved, I80F48::ZERO)
|
|
} else if max_balance.is_negative() {
|
|
(I80F48::ZERO, market_reserved)
|
|
} else {
|
|
(max_balance, cm!(market_reserved - max_balance))
|
|
};
|
|
|
|
let asset_weight = token_info.asset_weight(health_type);
|
|
let liab_weight = token_info.liab_weight(health_type);
|
|
let asset_price = token_info.prices.asset(health_type);
|
|
let liab_price = token_info.prices.liab(health_type);
|
|
cm!(asset_part * asset_weight * asset_price + liab_part * liab_weight * liab_price)
|
|
};
|
|
|
|
let health_base = compute_health_effect(
|
|
base_info,
|
|
base_max_reserved,
|
|
market_reserved.all_reserved_as_base,
|
|
);
|
|
let health_quote = compute_health_effect(
|
|
quote_info,
|
|
quote_max_reserved,
|
|
market_reserved.all_reserved_as_quote,
|
|
);
|
|
health_base.min(health_quote)
|
|
}
|
|
}
|
|
|
|
#[derive(Clone)]
|
|
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,
|
|
}
|
|
|
|
#[derive(Clone, AnchorDeserialize, AnchorSerialize, Debug)]
|
|
pub struct PerpInfo {
|
|
pub perp_market_index: PerpMarketIndex,
|
|
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_pnl_asset_weight: I80F48,
|
|
pub init_pnl_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 prices: Prices,
|
|
pub has_open_orders: bool,
|
|
pub has_open_fills: bool,
|
|
}
|
|
|
|
impl PerpInfo {
|
|
fn new(perp_position: &PerpPosition, perp_market: &PerpMarket, prices: Prices) -> Result<Self> {
|
|
let base_lots = cm!(perp_position.base_position_lots() + perp_position.taker_base_lots);
|
|
|
|
let unsettled_funding = perp_position.unsettled_funding(perp_market);
|
|
let taker_quote = I80F48::from(cm!(
|
|
perp_position.taker_quote_lots * perp_market.quote_lot_size
|
|
));
|
|
let quote_current =
|
|
cm!(perp_position.quote_position_native() - unsettled_funding + taker_quote);
|
|
|
|
Ok(Self {
|
|
perp_market_index: perp_market.perp_market_index,
|
|
init_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_pnl_asset_weight: perp_market.init_pnl_asset_weight,
|
|
maint_pnl_asset_weight: perp_market.maint_pnl_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,
|
|
prices,
|
|
has_open_orders: perp_position.has_open_orders(),
|
|
has_open_fills: perp_position.has_open_taker_fills(),
|
|
})
|
|
}
|
|
|
|
/// Total health contribution from perp balances
|
|
///
|
|
/// For fully isolated perp markets, users may never borrow against unsettled
|
|
/// positive perp pnl, there pnl_asset_weight == 0 and there can't be positive
|
|
/// health contributions from these perp market. We sometimes call these markets
|
|
/// "untrusted markets".
|
|
///
|
|
/// 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 pnl asset weights would be >0.
|
|
#[inline(always)]
|
|
pub fn health_contribution(&self, health_type: HealthType) -> I80F48 {
|
|
let contribution = self.unweighted_health_contribution(health_type);
|
|
|
|
if contribution > 0 {
|
|
let asset_weight = match health_type {
|
|
HealthType::Init => self.init_pnl_asset_weight,
|
|
HealthType::Maint => self.maint_pnl_asset_weight,
|
|
};
|
|
|
|
cm!(asset_weight * contribution)
|
|
} else {
|
|
contribution
|
|
}
|
|
}
|
|
|
|
#[inline(always)]
|
|
pub fn unweighted_health_contribution(&self, health_type: HealthType) -> I80F48 {
|
|
let order_execution_case = |orders_base_lots: i64, order_price: I80F48| {
|
|
let net_base_native =
|
|
I80F48::from(cm!((self.base_lots + orders_base_lots) * self.base_lot_size));
|
|
let (weight, base_price) = match (health_type, net_base_native.is_negative()) {
|
|
(HealthType::Init, true) => {
|
|
(self.init_base_liab_weight, self.prices.liab(health_type))
|
|
}
|
|
(HealthType::Init, false) => {
|
|
(self.init_base_asset_weight, self.prices.asset(health_type))
|
|
}
|
|
(HealthType::Maint, true) => {
|
|
(self.maint_base_liab_weight, self.prices.liab(health_type))
|
|
}
|
|
(HealthType::Maint, false) => {
|
|
(self.maint_base_asset_weight, self.prices.asset(health_type))
|
|
}
|
|
};
|
|
// Total value of the order-execution adjusted base position
|
|
let base_health = cm!(net_base_native * weight * base_price);
|
|
|
|
let orders_base_native = I80F48::from(cm!(orders_base_lots * self.base_lot_size));
|
|
// The quote change from executing the bids/asks
|
|
let order_quote = cm!(-orders_base_native * order_price);
|
|
|
|
cm!(base_health + order_quote)
|
|
};
|
|
|
|
// What is worse: Executing all bids at oracle_price.liab, or executing all asks at oracle_price.asset?
|
|
let bids_case = order_execution_case(self.bids_base_lots, self.prices.liab(health_type));
|
|
let asks_case = order_execution_case(-self.asks_base_lots, self.prices.asset(health_type));
|
|
let worst_case = bids_case.min(asks_case);
|
|
|
|
cm!(self.quote + worst_case)
|
|
}
|
|
}
|
|
|
|
#[derive(Clone, AnchorDeserialize, AnchorSerialize, Debug)]
|
|
pub struct HealthCache {
|
|
pub(crate) token_infos: Vec<TokenInfo>,
|
|
pub(crate) serum3_infos: Vec<Serum3Info>,
|
|
pub(crate) perp_infos: Vec<PerpInfo>,
|
|
pub(crate) being_liquidated: bool,
|
|
}
|
|
|
|
impl HealthCache {
|
|
pub fn health(&self, health_type: HealthType) -> I80F48 {
|
|
let mut health = I80F48::ZERO;
|
|
let sum = |contrib| {
|
|
cm!(health += contrib);
|
|
};
|
|
self.health_sum(health_type, sum);
|
|
health
|
|
}
|
|
|
|
pub fn token_info(&self, token_index: TokenIndex) -> Result<&TokenInfo> {
|
|
Ok(&self.token_infos[self.token_info_index(token_index)?])
|
|
}
|
|
|
|
fn token_info_index(&self, token_index: TokenIndex) -> Result<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
|
|
)
|
|
})
|
|
}
|
|
|
|
/// 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_asset_weight =
|
|
bank.scaled_init_asset_weight(entry.prices.asset(HealthType::Init));
|
|
entry.init_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;
|
|
cm!(entry.balance_native -= removed_contribution);
|
|
Ok(())
|
|
}
|
|
|
|
/// Changes the cached user account token and serum balances.
|
|
///
|
|
/// WARNING: You must also call recompute_token_weights() after all bank
|
|
/// deposit/withdraw changes!
|
|
#[allow(clippy::too_many_arguments)]
|
|
pub fn adjust_serum3_reserved(
|
|
&mut self,
|
|
market_index: Serum3MarketIndex,
|
|
base_token_index: TokenIndex,
|
|
reserved_base_change: I80F48,
|
|
free_base_change: I80F48,
|
|
quote_token_index: TokenIndex,
|
|
reserved_quote_change: I80F48,
|
|
free_quote_change: I80F48,
|
|
) -> Result<()> {
|
|
let base_entry_index = self.token_info_index(base_token_index)?;
|
|
let quote_entry_index = self.token_info_index(quote_token_index)?;
|
|
|
|
// Apply it to the tokens
|
|
{
|
|
let base_entry = &mut self.token_infos[base_entry_index];
|
|
cm!(base_entry.balance_native += free_base_change);
|
|
}
|
|
{
|
|
let quote_entry = &mut self.token_infos[quote_entry_index];
|
|
cm!(quote_entry.balance_native += free_quote_change);
|
|
}
|
|
|
|
// Apply it to the serum3 info
|
|
let market_entry = self
|
|
.serum3_infos
|
|
.iter_mut()
|
|
.find(|m| m.market_index == market_index)
|
|
.ok_or_else(|| error_msg!("serum3 market {} not found", market_index))?;
|
|
cm!(market_entry.reserved_base += reserved_base_change);
|
|
cm!(market_entry.reserved_quote += reserved_quote_change);
|
|
Ok(())
|
|
}
|
|
|
|
pub fn recompute_perp_info(
|
|
&mut self,
|
|
perp_position: &PerpPosition,
|
|
perp_market: &PerpMarket,
|
|
) -> Result<()> {
|
|
let perp_entry = self
|
|
.perp_infos
|
|
.iter_mut()
|
|
.find(|m| m.perp_market_index == perp_market.perp_market_index)
|
|
.ok_or_else(|| error_msg!("perp market {} not found", perp_market.perp_market_index))?;
|
|
*perp_entry = PerpInfo::new(perp_position, perp_market, perp_entry.prices.clone())?;
|
|
Ok(())
|
|
}
|
|
|
|
pub fn has_spot_assets(&self) -> bool {
|
|
self.token_infos.iter().any(|ti| {
|
|
// can use token_liq_with_token
|
|
ti.balance_native >= 1
|
|
})
|
|
}
|
|
|
|
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_maint_pnl_without_base_position(&self) -> bool {
|
|
self.perp_infos
|
|
.iter()
|
|
.any(|p| p.maint_pnl_asset_weight > 0 && p.base_lots == 0 && p.quote > 0)
|
|
}
|
|
|
|
pub fn has_perp_negative_pnl(&self) -> bool {
|
|
self.perp_infos.iter().any(|p| 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_spot_assets() && self.has_spot_borrows()
|
|
|| self.has_perp_base_positions()
|
|
|| self.has_perp_open_fills()
|
|
|| self.has_perp_positive_maint_pnl_without_base_position()
|
|
}
|
|
|
|
pub fn require_after_phase2_liquidation(&self) -> Result<()> {
|
|
self.require_after_phase1_liquidation()?;
|
|
require!(
|
|
!self.has_spot_assets() || !self.has_spot_borrows(),
|
|
MangoError::HasLiquidatableTokenPosition
|
|
);
|
|
require!(
|
|
!self.has_perp_base_positions(),
|
|
MangoError::HasLiquidatablePerpBasePosition
|
|
);
|
|
require!(
|
|
!self.has_perp_open_fills(),
|
|
MangoError::HasOpenPerpTakerFills
|
|
);
|
|
require!(
|
|
!self.has_perp_positive_maint_pnl_without_base_position(),
|
|
MangoError::HasLiquidatableTrustedPerpPnl
|
|
);
|
|
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_spot_borrows() || self.has_perp_negative_pnl()
|
|
}
|
|
|
|
pub fn in_phase3_liquidation(&self) -> bool {
|
|
!self.has_phase1_liquidatable()
|
|
&& !self.has_phase2_liquidatable()
|
|
&& self.has_phase3_liquidatable()
|
|
}
|
|
|
|
pub fn has_spot_borrows(&self) -> bool {
|
|
self.token_infos.iter().any(|ti| ti.balance_native < 0)
|
|
}
|
|
|
|
pub(crate) fn compute_serum3_reservations(
|
|
&self,
|
|
health_type: HealthType,
|
|
) -> (Vec<I80F48>, Vec<Serum3Reserved>) {
|
|
// For each token, compute the sum of serum-reserved amounts over all markets.
|
|
let mut token_max_reserved = vec![I80F48::ZERO; self.token_infos.len()];
|
|
|
|
// For each serum market, compute what happened if reserved_base was converted to quote
|
|
// or reserved_quote was converted to base.
|
|
let mut serum3_reserved = Vec::with_capacity(self.serum3_infos.len());
|
|
|
|
for info in self.serum3_infos.iter() {
|
|
let quote = &self.token_infos[info.quote_index];
|
|
let base = &self.token_infos[info.base_index];
|
|
|
|
let reserved_base = info.reserved_base;
|
|
let reserved_quote = info.reserved_quote;
|
|
|
|
let quote_asset = quote.prices.asset(health_type);
|
|
let base_liab = base.prices.liab(health_type);
|
|
// OPTIMIZATION: These divisions can be extremely expensive (up to 5k CU each)
|
|
let all_reserved_as_base =
|
|
cm!(reserved_base + reserved_quote * quote_asset / base_liab);
|
|
|
|
let base_asset = base.prices.asset(health_type);
|
|
let quote_liab = quote.prices.liab(health_type);
|
|
let all_reserved_as_quote =
|
|
cm!(reserved_quote + reserved_base * base_asset / quote_liab);
|
|
|
|
let base_max_reserved = &mut token_max_reserved[info.base_index];
|
|
// note: cm!() does not work with mutable references
|
|
*base_max_reserved = base_max_reserved.checked_add(all_reserved_as_base).unwrap();
|
|
let quote_max_reserved = &mut token_max_reserved[info.quote_index];
|
|
*quote_max_reserved = quote_max_reserved
|
|
.checked_add(all_reserved_as_quote)
|
|
.unwrap();
|
|
|
|
serum3_reserved.push(Serum3Reserved {
|
|
all_reserved_as_base,
|
|
all_reserved_as_quote,
|
|
});
|
|
}
|
|
|
|
(token_max_reserved, serum3_reserved)
|
|
}
|
|
|
|
pub(crate) fn health_sum(&self, health_type: HealthType, mut action: impl FnMut(I80F48)) {
|
|
for token_info in self.token_infos.iter() {
|
|
let contrib = token_info.health_contribution(health_type);
|
|
action(contrib);
|
|
}
|
|
|
|
let (token_max_reserved, serum3_reserved) = self.compute_serum3_reservations(health_type);
|
|
for (serum3_info, reserved) in self.serum3_infos.iter().zip(serum3_reserved.iter()) {
|
|
let contrib = serum3_info.health_contribution(
|
|
health_type,
|
|
&self.token_infos,
|
|
&token_max_reserved,
|
|
reserved,
|
|
);
|
|
action(contrib);
|
|
}
|
|
|
|
for perp_info in self.perp_infos.iter() {
|
|
let contrib = perp_info.health_contribution(health_type);
|
|
action(contrib);
|
|
}
|
|
}
|
|
|
|
/// Compute the health when it comes to settling perp pnl
|
|
///
|
|
/// Examples:
|
|
/// - An account may have maint_health < 0, but settling perp pnl could still be allowed.
|
|
/// (+100 USDC health, -50 USDT health, -50 perp health -> allow settling 50 health worth)
|
|
/// - Positive health from trusted pnl markets counts
|
|
/// - If overall health is 0 with two trusted perp pnl < 0, settling may still be possible.
|
|
/// (+100 USDC health, -150 perp1 health, -150 perp2 health -> allow settling 100 health worth)
|
|
/// - Positive trusted perp pnl can enable settling.
|
|
/// (+100 trusted perp1 health, -100 perp2 health -> allow settling of 100 health worth)
|
|
pub fn perp_settle_health(&self) -> I80F48 {
|
|
let health_type = HealthType::Maint;
|
|
let mut health = I80F48::ZERO;
|
|
for token_info in self.token_infos.iter() {
|
|
let contrib = token_info.health_contribution(health_type);
|
|
cm!(health += contrib);
|
|
}
|
|
|
|
let (token_max_reserved, serum3_reserved) = self.compute_serum3_reservations(health_type);
|
|
for (serum3_info, reserved) in self.serum3_infos.iter().zip(serum3_reserved.iter()) {
|
|
let contrib = serum3_info.health_contribution(
|
|
health_type,
|
|
&self.token_infos,
|
|
&token_max_reserved,
|
|
reserved,
|
|
);
|
|
cm!(health += contrib);
|
|
}
|
|
|
|
for perp_info in self.perp_infos.iter() {
|
|
let positive_contrib = perp_info.health_contribution(health_type).max(I80F48::ZERO);
|
|
cm!(health += positive_contrib);
|
|
}
|
|
health
|
|
}
|
|
}
|
|
|
|
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,
|
|
) -> Result<HealthCache> {
|
|
// token contribution from token accounts
|
|
let mut token_infos = vec![];
|
|
|
|
for (i, position) in account.active_token_positions().enumerate() {
|
|
let (bank, oracle_price) =
|
|
retriever.bank_and_oracle(&account.fixed.group, i, position.token_index)?;
|
|
|
|
let native = position.native(bank);
|
|
let prices = Prices {
|
|
oracle: oracle_price,
|
|
stable: bank.stable_price(),
|
|
};
|
|
token_infos.push(TokenInfo {
|
|
token_index: bank.token_index,
|
|
maint_asset_weight: bank.maint_asset_weight,
|
|
init_asset_weight: bank.scaled_init_asset_weight(prices.asset(HealthType::Init)),
|
|
maint_liab_weight: bank.maint_liab_weight,
|
|
init_liab_weight: bank.scaled_init_liab_weight(prices.liab(HealthType::Init)),
|
|
prices,
|
|
balance_native: native,
|
|
});
|
|
}
|
|
|
|
// Fill the TokenInfo balance with free funds in serum3 oo accounts and build Serum3Infos.
|
|
let mut serum3_infos = vec![];
|
|
for (i, serum_account) in account.active_serum3_orders().enumerate() {
|
|
let oo = retriever.serum_oo(i, &serum_account.open_orders)?;
|
|
|
|
// find the TokenInfos for the market's base and quote tokens
|
|
let base_index = find_token_info_index(&token_infos, serum_account.base_token_index)?;
|
|
let quote_index = find_token_info_index(&token_infos, serum_account.quote_token_index)?;
|
|
|
|
// add the amounts that are freely settleable immediately to token balances
|
|
let base_free = I80F48::from(oo.native_coin_free);
|
|
let quote_free = I80F48::from(cm!(oo.native_pc_free + oo.referrer_rebates_accrued));
|
|
let base_info = &mut token_infos[base_index];
|
|
cm!(base_info.balance_native += base_free);
|
|
let quote_info = &mut token_infos[quote_index];
|
|
cm!(quote_info.balance_native += quote_free);
|
|
|
|
// track the reserved amounts
|
|
let reserved_base = I80F48::from(cm!(oo.native_coin_total - oo.native_coin_free));
|
|
let reserved_quote = I80F48::from(cm!(oo.native_pc_total - oo.native_pc_free));
|
|
|
|
serum3_infos.push(Serum3Info {
|
|
reserved_base,
|
|
reserved_quote,
|
|
base_index,
|
|
quote_index,
|
|
market_index: serum_account.market_index,
|
|
has_zero_funds: oo.native_coin_total == 0
|
|
&& oo.native_pc_total == 0
|
|
&& oo.referrer_rebates_accrued == 0,
|
|
});
|
|
}
|
|
|
|
// health contribution from perp accounts
|
|
let mut perp_infos = Vec::with_capacity(account.active_perp_positions().count());
|
|
for (i, perp_position) in account.active_perp_positions().enumerate() {
|
|
let (perp_market, oracle_price) = retriever.perp_market_and_oracle_price(
|
|
&account.fixed.group,
|
|
i,
|
|
perp_position.market_index,
|
|
)?;
|
|
perp_infos.push(PerpInfo::new(
|
|
perp_position,
|
|
perp_market,
|
|
Prices {
|
|
oracle: oracle_price,
|
|
stable: perp_market.stable_price(),
|
|
},
|
|
)?);
|
|
}
|
|
|
|
Ok(HealthCache {
|
|
token_infos,
|
|
serum3_infos,
|
|
perp_infos,
|
|
being_liquidated: account.fixed.being_liquidated(),
|
|
})
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::super::test::*;
|
|
use super::*;
|
|
use crate::state::*;
|
|
use serum_dex::state::OpenOrders;
|
|
use std::str::FromStr;
|
|
|
|
#[test]
|
|
fn test_precision() {
|
|
// I80F48 can only represent until 1/2^48
|
|
assert_ne!(
|
|
I80F48::from_num(1_u128) / I80F48::from_num(2_u128.pow(48)),
|
|
0
|
|
);
|
|
assert_eq!(
|
|
I80F48::from_num(1_u128) / I80F48::from_num(2_u128.pow(49)),
|
|
0
|
|
);
|
|
|
|
// I80F48 can only represent until 14 decimal points
|
|
assert_ne!(
|
|
I80F48::from_str(format!("0.{}1", "0".repeat(13)).as_str()).unwrap(),
|
|
0
|
|
);
|
|
assert_eq!(
|
|
I80F48::from_str(format!("0.{}1", "0".repeat(14)).as_str()).unwrap(),
|
|
0
|
|
);
|
|
}
|
|
|
|
fn health_eq(a: I80F48, b: f64) -> bool {
|
|
if (a - I80F48::from_num(b)).abs() < 0.001 {
|
|
true
|
|
} else {
|
|
println!("health is {}, but expected {}", a, b);
|
|
false
|
|
}
|
|
}
|
|
|
|
// Run a health test that includes all the side values (like referrer_rebates_accrued)
|
|
#[test]
|
|
fn test_health0() {
|
|
let buffer = MangoAccount::default_for_tests().try_to_vec().unwrap();
|
|
let mut account = MangoAccountValue::from_bytes(&buffer).unwrap();
|
|
|
|
let group = Pubkey::new_unique();
|
|
|
|
let (mut bank1, mut oracle1) = mock_bank_and_oracle(group, 1, 1.0, 0.2, 0.1);
|
|
let (mut bank2, mut oracle2) = mock_bank_and_oracle(group, 4, 5.0, 0.5, 0.3);
|
|
bank1
|
|
.data()
|
|
.deposit(
|
|
account.ensure_token_position(1).unwrap().0,
|
|
I80F48::from(100),
|
|
DUMMY_NOW_TS,
|
|
)
|
|
.unwrap();
|
|
bank2
|
|
.data()
|
|
.withdraw_without_fee(
|
|
account.ensure_token_position(4).unwrap().0,
|
|
I80F48::from(10),
|
|
DUMMY_NOW_TS,
|
|
DUMMY_PRICE,
|
|
)
|
|
.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 = 1;
|
|
oo1.data().native_pc_total = 21;
|
|
oo1.data().native_coin_total = 18;
|
|
oo1.data().native_pc_free = 1;
|
|
oo1.data().native_coin_free = 3;
|
|
oo1.data().referrer_rebates_accrued = 2;
|
|
|
|
let mut perp1 = mock_perp_market(group, oracle2.pubkey, 5.0, 9, (0.2, 0.1), (0.05, 0.02));
|
|
let perpaccount = account.ensure_perp_position(9, 1).unwrap().0;
|
|
perpaccount.record_trade(perp1.data(), 3, -I80F48::from(310u16));
|
|
perpaccount.bids_base_lots = 7;
|
|
perpaccount.asks_base_lots = 11;
|
|
perpaccount.taker_base_lots = 1;
|
|
perpaccount.taker_quote_lots = 2;
|
|
|
|
let oracle2_ai = oracle2.as_account_info();
|
|
|
|
let ais = vec![
|
|
bank1.as_account_info(),
|
|
bank2.as_account_info(),
|
|
oracle1.as_account_info(),
|
|
oracle2_ai.clone(),
|
|
perp1.as_account_info(),
|
|
oracle2_ai,
|
|
oo1.as_account_info(),
|
|
];
|
|
|
|
let retriever = ScanningAccountRetriever::new_with_staleness(&ais, &group, None).unwrap();
|
|
|
|
// for bank1/oracle1, including open orders (scenario: bids execute)
|
|
let health1 = (100.0 + 1.0 + 2.0 + (20.0 + 15.0 * 5.0)) * 0.8;
|
|
// for bank2/oracle2
|
|
let health2 = (-10.0 + 3.0) * 5.0 * 1.5;
|
|
// for perp (scenario: bids execute)
|
|
let health3 =
|
|
(3.0 + 7.0 + 1.0) * 10.0 * 5.0 * 0.8 + (-310.0 + 2.0 * 100.0 - 7.0 * 10.0 * 5.0);
|
|
assert!(health_eq(
|
|
compute_health(&account.borrow(), HealthType::Init, &retriever).unwrap(),
|
|
health1 + health2 + health3
|
|
));
|
|
}
|
|
|
|
#[derive(Default)]
|
|
struct BankSettings {
|
|
deposits: u64,
|
|
borrows: u64,
|
|
deposit_weight_scale_start_quote: u64,
|
|
borrow_weight_scale_start_quote: 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],
|
|
}
|
|
fn test_health1_runner(testcase: &TestHealth1Case) {
|
|
let buffer = MangoAccount::default_for_tests().try_to_vec().unwrap();
|
|
let mut account = MangoAccountValue::from_bytes(&buffer).unwrap();
|
|
|
|
let group = Pubkey::new_unique();
|
|
|
|
let (mut bank1, mut oracle1) = mock_bank_and_oracle(group, 1, 1.0, 0.2, 0.1);
|
|
let (mut bank2, mut oracle2) = mock_bank_and_oracle(group, 4, 5.0, 0.5, 0.3);
|
|
let (mut bank3, mut oracle3) = mock_bank_and_oracle(group, 5, 10.0, 0.5, 0.3);
|
|
bank1
|
|
.data()
|
|
.change_without_fee(
|
|
account.ensure_token_position(1).unwrap().0,
|
|
I80F48::from(testcase.token1),
|
|
DUMMY_NOW_TS,
|
|
DUMMY_PRICE,
|
|
)
|
|
.unwrap();
|
|
bank2
|
|
.data()
|
|
.change_without_fee(
|
|
account.ensure_token_position(4).unwrap().0,
|
|
I80F48::from(testcase.token2),
|
|
DUMMY_NOW_TS,
|
|
DUMMY_PRICE,
|
|
)
|
|
.unwrap();
|
|
bank3
|
|
.data()
|
|
.change_without_fee(
|
|
account.ensure_token_position(5).unwrap().0,
|
|
I80F48::from(testcase.token3),
|
|
DUMMY_NOW_TS,
|
|
DUMMY_PRICE,
|
|
)
|
|
.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;
|
|
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 = 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.create_serum3_orders(3).unwrap();
|
|
serum3account2.open_orders = oo2.pubkey;
|
|
serum3account2.base_token_index = 5;
|
|
serum3account2.quote_token_index = 1;
|
|
oo2.data().native_pc_total = testcase.oo_1_3.0;
|
|
oo2.data().native_coin_total = testcase.oo_1_3.1;
|
|
|
|
let mut perp1 = mock_perp_market(group, oracle2.pubkey, 5.0, 9, (0.2, 0.1), (0.05, 0.02));
|
|
let perpaccount = account.ensure_perp_position(9, 1).unwrap().0;
|
|
perpaccount.record_trade(
|
|
perp1.data(),
|
|
testcase.perp1.0,
|
|
I80F48::from(testcase.perp1.1),
|
|
);
|
|
perpaccount.bids_base_lots = testcase.perp1.2;
|
|
perpaccount.asks_base_lots = testcase.perp1.3;
|
|
|
|
let oracle2_ai = oracle2.as_account_info();
|
|
let ais = vec![
|
|
bank1.as_account_info(),
|
|
bank2.as_account_info(),
|
|
bank3.as_account_info(),
|
|
oracle1.as_account_info(),
|
|
oracle2_ai.clone(),
|
|
oracle3.as_account_info(),
|
|
perp1.as_account_info(),
|
|
oracle2_ai,
|
|
oo1.as_account_info(),
|
|
oo2.as_account_info(),
|
|
];
|
|
|
|
let retriever = ScanningAccountRetriever::new_with_staleness(&ais, &group, None).unwrap();
|
|
|
|
assert!(health_eq(
|
|
compute_health(&account.borrow(), HealthType::Init, &retriever).unwrap(),
|
|
testcase.expected_health
|
|
));
|
|
}
|
|
|
|
// Check some specific health constellations
|
|
#[test]
|
|
fn test_health1() {
|
|
let base_price = 5.0;
|
|
let base_lots_to_quote = 10.0 * base_price;
|
|
let testcases = vec![
|
|
TestHealth1Case { // 0
|
|
token1: 100,
|
|
token2: -10,
|
|
oo_1_2: (20, 15),
|
|
perp1: (3, -131, 7, 11),
|
|
expected_health:
|
|
// for token1, including open orders (scenario: bids execute)
|
|
(100.0 + (20.0 + 15.0 * base_price)) * 0.8
|
|
// for token2
|
|
- 10.0 * base_price * 1.5
|
|
// for perp (scenario: bids execute)
|
|
+ (3.0 + 7.0) * base_lots_to_quote * 0.8 + (-131.0 - 7.0 * base_lots_to_quote),
|
|
..Default::default()
|
|
},
|
|
TestHealth1Case { // 1
|
|
token1: -100,
|
|
token2: 10,
|
|
oo_1_2: (20, 15),
|
|
perp1: (-10, -131, 7, 11),
|
|
expected_health:
|
|
// for token1
|
|
-100.0 * 1.2
|
|
// for token2, including open orders (scenario: asks execute)
|
|
+ (10.0 * base_price + (20.0 + 15.0 * base_price)) * 0.5
|
|
// for perp (scenario: asks execute)
|
|
+ (-10.0 - 11.0) * base_lots_to_quote * 1.2 + (-131.0 + 11.0 * base_lots_to_quote),
|
|
..Default::default()
|
|
},
|
|
TestHealth1Case {
|
|
// 2: weighted positive perp pnl
|
|
perp1: (-1, 100, 0, 0),
|
|
expected_health: 0.95 * (100.0 - 1.2 * 1.0 * base_lots_to_quote),
|
|
..Default::default()
|
|
},
|
|
TestHealth1Case {
|
|
// 3: negative perp pnl is not weighted
|
|
perp1: (1, -100, 0, 0),
|
|
expected_health: -100.0 + 0.8 * 1.0 * base_lots_to_quote,
|
|
..Default::default()
|
|
},
|
|
TestHealth1Case {
|
|
// 4: perp health
|
|
perp1: (10, 100, 0, 0),
|
|
expected_health: 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.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()
|
|
},
|
|
];
|
|
|
|
for (i, testcase) in testcases.iter().enumerate() {
|
|
println!("checking testcase {}", i);
|
|
test_health1_runner(testcase);
|
|
}
|
|
}
|
|
}
|