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

1734 lines
68 KiB
Rust

#![cfg(feature = "client")]
use anchor_lang::prelude::*;
use fixed::types::I80F48;
use crate::error::*;
use crate::state::Side as PerpOrderSide;
use crate::state::{Bank, MangoAccountValue, PerpMarketIndex};
use super::*;
impl HealthCache {
pub fn is_liquidatable(&self) -> bool {
if self.being_liquidated {
self.health(HealthType::LiquidationEnd).is_negative()
} else {
self.health(HealthType::Maint).is_negative()
}
}
/// Return a copy of the current cache where a swap between two banks was executed.
///
/// Errors:
/// - If there are no existing token positions for the source or target index.
/// - If the withdraw fails due to the net borrow limit.
fn cache_after_swap(
&self,
account: &MangoAccountValue,
source_bank: &Bank,
source_oracle_price: I80F48,
target_bank: &Bank,
amount: I80F48,
price: I80F48,
) -> Result<Self> {
let now_ts = system_epoch_secs();
let mut source_position = account.token_position(source_bank.token_index)?.clone();
let mut target_position = account.token_position(target_bank.token_index)?.clone();
let target_amount = amount * price;
let mut source_bank = source_bank.clone();
source_bank.withdraw_with_fee(&mut source_position, amount, now_ts)?;
let mut target_bank = target_bank.clone();
target_bank.deposit(&mut target_position, target_amount, now_ts)?;
let mut resulting_cache = self.clone();
resulting_cache.adjust_token_balance(&source_bank, -amount)?;
resulting_cache.adjust_token_balance(&target_bank, target_amount)?;
Ok(resulting_cache)
}
fn apply_limits_to_swap(
account: &MangoAccountValue,
source_bank: &Bank,
source_oracle_price: I80F48,
target_bank: &Bank,
price: I80F48,
source_unlimited: I80F48,
) -> Result<I80F48> {
let source_pos = account
.token_position(source_bank.token_index)?
.native(source_bank);
let target_pos = account
.token_position(target_bank.token_index)?
.native(target_bank);
// net borrow limit on source
let available_net_borrows = source_bank
.remaining_net_borrows_quote(source_oracle_price)
.saturating_div(source_oracle_price);
let potential_source = source_unlimited
.min(available_net_borrows.saturating_add(source_pos.max(I80F48::ZERO)));
// deposit limit on target
let available_deposits = target_bank.remaining_deposits_until_limit();
let potential_target_unlimited = potential_source.saturating_mul(price);
let potential_target = potential_target_unlimited
.min(available_deposits.saturating_add(-target_pos.min(I80F48::ZERO)));
let source = potential_source.min(potential_target.saturating_div(price));
Ok(source)
}
/// Verifies neither the net borrow or deposit limits
pub fn max_swap_source_for_health_ratio_ignoring_limits(
&self,
account: &MangoAccountValue,
source_bank: &Bank,
source_oracle_price: I80F48,
target_bank: &Bank,
price: I80F48,
min_ratio: I80F48,
) -> Result<I80F48> {
self.max_swap_source_for_health_fn(
account,
source_bank,
source_oracle_price,
target_bank,
price,
min_ratio,
|cache| cache.health_ratio(HealthType::Init),
)
}
pub fn max_swap_source_for_health_ratio_with_limits(
&self,
account: &MangoAccountValue,
source_bank: &Bank,
source_oracle_price: I80F48,
target_bank: &Bank,
price: I80F48,
min_ratio: I80F48,
) -> Result<I80F48> {
let source_unlimited = self.max_swap_source_for_health_fn(
account,
source_bank,
source_oracle_price,
target_bank,
price,
min_ratio,
|cache| cache.health_ratio(HealthType::Init),
)?;
Self::apply_limits_to_swap(
account,
source_bank,
source_oracle_price,
target_bank,
price,
source_unlimited,
)
}
/// How many source native tokens may be swapped for target tokens while staying
/// above the min_ratio health ratio.
///
/// `price`: The amount of target native you receive for one source native. So if we
/// swap BTC -> SOL and they're at ui prices of $20000 and $40, that means price
/// should be 500000 native_SOL for a native_BTC. Because 1 BTC gives you 500 SOL
/// so 1e6 native_BTC gives you 500e9 native_SOL.
///
/// Positions for the source and deposit token index must already exist in the account.
///
/// NOTE: keep getMaxSourceForTokenSwap in ts/client in sync with changes here
pub fn max_swap_source_for_health_fn(
&self,
account: &MangoAccountValue,
source_bank: &Bank,
source_oracle_price: I80F48,
target_bank: &Bank,
price: I80F48,
min_fn_value: I80F48,
target_fn: fn(&HealthCache) -> I80F48,
) -> Result<I80F48> {
// The health and health_ratio are nonlinear based on swap amount.
// For large swap amounts the slope is guaranteed to be negative (unless the price
// is extremely good), but small amounts can have positive slope (e.g. using
// source deposits to pay back target borrows).
//
// That means:
// - even if the initial value is < min_fn_value it can be useful to swap to *increase* health
// - even if initial value is < 0, swapping can increase health (maybe above 0)
// - be careful about finding the min_fn_value: the function isn't convex
let health_type = HealthType::Init;
// Fail if the health cache (or consequently the account) don't have existing
// positions for the source and target token index.
let source_index = find_token_info_index(&self.token_infos, source_bank.token_index)?;
let target_index = find_token_info_index(&self.token_infos, target_bank.token_index)?;
let source = &self.token_infos[source_index];
let target = &self.token_infos[target_index];
let (tokens_max_reserved, _) = self.compute_serum3_reservations(health_type);
let source_reserved = tokens_max_reserved[source_index].max_serum_reserved;
let target_reserved = tokens_max_reserved[target_index].max_serum_reserved;
let token_balances = self.effective_token_balances(health_type);
let source_balance = token_balances[source_index].spot_and_perp;
let target_balance = token_balances[target_index].spot_and_perp;
// If the price is sufficiently good, then health will just increase from swapping:
// once we've swapped enough, swapping x reduces health by x * source_liab_weight and
// increases it by x * target_asset_weight * price_factor.
// This is just the highest final slope we can get. If the health weights are
// scaled because the collateral or borrow limits are exceeded, health will decrease
// more quickly than this number.
let final_health_slope = -source.init_scaled_liab_weight * source.prices.liab(health_type)
+ target.init_asset_weight * target.prices.asset(health_type) * price;
if final_health_slope >= 0 {
// TODO: not true if weights scaled with deposits/borrows
return Ok(I80F48::MAX);
}
let cache_after_swap = |amount: I80F48| -> Result<Option<HealthCache>> {
ignore_net_borrow_limit_errors(self.cache_after_swap(
account,
source_bank,
source_oracle_price,
target_bank,
amount,
price,
))
};
let fn_value_after_swap = |amount| {
Ok(cache_after_swap(amount)?
.as_ref()
.map(target_fn)
.unwrap_or(I80F48::MIN))
};
// The function we're looking at has a unique maximum.
//
// If we discount serum3 reservations, there are two key slope changes:
// Assume source.balance > 0 and target.balance < 0.
// When these values flip sign, the health slope decreases, but could still be positive.
//
// The first thing we do is to find this maximum.
let (amount_for_max_value, max_value) = {
// The largest amount that the maximum could be at
let rightmost = (source_balance.abs() + source_reserved)
.max((target_balance.abs() + target_reserved) / price);
find_maximum(
I80F48::ZERO,
rightmost,
I80F48::from_num(0.1),
fn_value_after_swap,
)?
};
assert!(amount_for_max_value >= 0);
if max_value <= min_fn_value {
// We cannot reach min_ratio, just return the max
return Ok(amount_for_max_value);
}
let amount = {
// Now max_value is bigger than min_fn_value, the target amount must be >=amount_for_max_value.
// Search to the right of amount_for_max_value: but how far?
// Use a simple estimation for the amount that would lead to zero health:
// health
// - source_liab_weight * source_liab_price * a
// + target_asset_weight * target_asset_price * price * a = 0.
// where a is the source token native amount.
// Note that this is just an estimate. Swapping can increase the amount that serum3
// reserved contributions offset, moving the actual zero point further to the right.
let health_at_max_value = cache_after_swap(amount_for_max_value)?
.map(|c| c.health(health_type))
.unwrap_or(I80F48::MIN);
if health_at_max_value == 0 {
return Ok(amount_for_max_value);
} else if health_at_max_value < 0 {
// target_fn suggests health is good but health suggests it's not
return Ok(I80F48::ZERO);
}
let zero_health_estimate =
amount_for_max_value - health_at_max_value / final_health_slope;
let right_bound = scan_right_until_less_than(
zero_health_estimate,
min_fn_value,
fn_value_after_swap,
)?;
if right_bound == zero_health_estimate {
binary_search(
amount_for_max_value,
max_value,
right_bound,
min_fn_value,
I80F48::from_num(0.1),
fn_value_after_swap,
)?
} else {
binary_search(
zero_health_estimate,
fn_value_after_swap(zero_health_estimate)?,
right_bound,
min_fn_value,
I80F48::from_num(0.1),
fn_value_after_swap,
)?
}
};
assert!(amount >= 0);
Ok(amount)
}
/// NOTE: keep getMaxSourceForTokenSwap in ts/client in sync with changes here
pub fn max_perp_for_health_ratio(
&self,
perp_market_index: PerpMarketIndex,
price: I80F48,
side: PerpOrderSide,
min_ratio: I80F48,
) -> Result<i64> {
let health_type = HealthType::Init;
let initial_ratio = self.health_ratio(health_type);
if initial_ratio < 0 {
return Ok(0);
}
let direction = match side {
PerpOrderSide::Bid => 1,
PerpOrderSide::Ask => -1,
};
let perp_info_index = self.perp_info_index(perp_market_index)?;
let perp_info = &self.perp_infos[perp_info_index];
let prices = &perp_info.base_prices;
let base_lot_size = I80F48::from(perp_info.base_lot_size);
let settle_info_index = self.token_info_index(perp_info.settle_token_index)?;
let settle_info = &self.token_infos[settle_info_index];
// If the price is sufficiently good then health will just increase from trading.
// It's ok to ignore the overall_asset_weight and token asset weight here because
// we'll jump out early if this slope is >=0, and those weights would just decrease it.
let mut final_health_slope = if direction == 1 {
perp_info.init_base_asset_weight * prices.asset(health_type) - price
} else {
-perp_info.init_base_liab_weight * prices.liab(health_type) + price
};
if final_health_slope >= 0 {
return Ok(i64::MAX);
}
final_health_slope *= settle_info.liab_weighted_price(health_type);
let cache_after_trade = |base_lots: i64| -> Result<HealthCache> {
let mut adjusted_cache = self.clone();
adjusted_cache.perp_infos[perp_info_index].base_lots += direction * base_lots;
adjusted_cache.perp_infos[perp_info_index].quote -=
I80F48::from(direction) * I80F48::from(base_lots) * base_lot_size * price;
Ok(adjusted_cache)
};
let health_ratio_after_trade =
|base_lots: i64| Ok(cache_after_trade(base_lots)?.health_ratio(health_type));
let health_ratio_after_trade_trunc =
|base_lots: I80F48| health_ratio_after_trade(base_lots.round_to_zero().to_num());
let initial_base_lots = perp_info.base_lots;
// There are two cases:
// 1. We are increasing abs(base_lots)
// 2. We are bringing the base position to 0, and then going to case 1.
let has_case2 =
initial_base_lots > 0 && direction == -1 || initial_base_lots < 0 && direction == 1;
let (case1_start, case1_start_ratio) = if has_case2 {
let case1_start = initial_base_lots.abs();
let case1_start_ratio = health_ratio_after_trade(case1_start)?;
(case1_start, case1_start_ratio)
} else {
(0, initial_ratio)
};
let case1_start_i80f48 = I80F48::from(case1_start);
// If we start out below min_ratio and can't go above, pick the best case
let base_lots = if initial_ratio <= min_ratio && case1_start_ratio < min_ratio {
if case1_start_ratio >= initial_ratio {
case1_start_i80f48
} else {
I80F48::ZERO
}
} else if case1_start_ratio >= min_ratio {
// Must reach min_ratio to the right of case1_start
// Need to figure out how many lots to trade to reach zero health (zero_health_amount).
// We do this by looking at the starting health and the health slope per
// traded base lot (final_health_slope).
let mut start_cache = cache_after_trade(case1_start)?;
// The perp market's contribution to the health above may be capped. But we need to trade
// enough to fully reduce any positive-pnl buffer. Thus get the uncapped health by fixing
// the overall weight.
start_cache.perp_infos[perp_info_index].init_overall_asset_weight = I80F48::ONE;
// We don't want to deal with slope changes due to settle token assets being
// reduced first, so modify the weights to use settle token liab scaling everywhere.
// That way the final_health_slope is applicable from the start.
{
let settle_info = &mut start_cache.token_infos[settle_info_index];
settle_info.init_asset_weight = settle_info.init_liab_weight;
settle_info.init_scaled_asset_weight = settle_info.init_scaled_liab_weight;
}
let start_health = start_cache.health(health_type);
if start_health <= 0 {
return Ok(0);
}
// We add 1 here because health is computed for truncated base_lots and we want to guarantee
// zero_health_ratio <= 0. Similarly, scale down the per-lot slope slightly for a benign
// overestimation that guards against rounding issues.
let zero_health_amount = case1_start_i80f48
- start_health / (final_health_slope * base_lot_size * I80F48::from_num(0.99))
+ I80F48::ONE;
let zero_health_ratio = health_ratio_after_trade_trunc(zero_health_amount)?;
assert!(zero_health_ratio <= 0);
binary_search(
case1_start_i80f48,
case1_start_ratio,
zero_health_amount,
min_ratio,
I80F48::ONE,
health_ratio_after_trade_trunc,
)?
} else {
// Between 0 and case1_start
binary_search(
I80F48::ZERO,
initial_ratio,
case1_start_i80f48,
min_ratio,
I80F48::ONE,
health_ratio_after_trade_trunc,
)?
};
Ok(base_lots.round_to_zero().to_num())
}
fn max_borrow_for_health_fn(
&self,
account: &MangoAccountValue,
bank: &Bank,
min_fn_value: I80F48,
target_fn: fn(&HealthCache) -> I80F48,
) -> Result<I80F48> {
// If we're already below ratio, stop
if target_fn(self) <= min_fn_value {
return Ok(I80F48::ZERO);
}
let health_type = HealthType::Init;
// Fail if the health cache (or consequently the account) don't have existing
// positions for the source and target token index.
let token_info_index = find_token_info_index(&self.token_infos, bank.token_index)?;
let token = &self.token_infos[token_info_index];
let token_balance =
self.effective_token_balances(health_type)[token_info_index].spot_and_perp;
let cache_after_borrow = |amount: I80F48| -> Result<HealthCache> {
let now_ts = system_epoch_secs();
let mut position = account.token_position(bank.token_index)?.clone();
let mut bank = bank.clone();
bank.withdraw_with_fee(&mut position, amount, now_ts)?;
bank.check_net_borrows(token.prices.oracle)?;
let mut resulting_cache = self.clone();
resulting_cache.adjust_token_balance(&bank, -amount)?;
Ok(resulting_cache)
};
let fn_value_after_borrow = |amount: I80F48| -> Result<I80F48> {
Ok(ignore_net_borrow_limit_errors(cache_after_borrow(amount))?
.as_ref()
.map(target_fn)
.unwrap_or(I80F48::MIN))
};
// At most withdraw all deposits plus enough borrows to bring health to zero
// (ensure this works with zero asset weight)
let limit = token_balance.max(I80F48::ZERO)
+ self.health(health_type).max(I80F48::ZERO) / token.init_scaled_liab_weight;
if limit <= 0 {
return Ok(I80F48::ZERO);
}
binary_search(
I80F48::ZERO,
target_fn(self),
limit,
min_fn_value,
I80F48::ONE,
fn_value_after_borrow,
)
}
pub fn max_borrow_for_health_ratio(
&self,
account: &MangoAccountValue,
bank: &Bank,
min_ratio: I80F48,
) -> Result<I80F48> {
self.max_borrow_for_health_fn(account, bank, min_ratio, |cache| {
cache.health_ratio(HealthType::Init)
})
}
}
fn scan_right_until_less_than(
start: I80F48,
target: I80F48,
fun: impl Fn(I80F48) -> Result<I80F48>,
) -> Result<I80F48> {
let max_iterations = 20;
let mut current = start;
for _ in 0..max_iterations {
let value = fun(current)?;
if value <= target {
return Ok(current);
}
current = current.max(I80F48::ONE) * I80F48::from(2);
}
Err(error_msg!(
"could not find amount that lead to health ratio <= 0"
))
}
fn binary_search(
mut left: I80F48,
left_value: I80F48,
mut right: I80F48,
target_value: I80F48,
min_step: I80F48,
fun: impl Fn(I80F48) -> Result<I80F48>,
) -> Result<I80F48> {
let max_iterations = 50;
let target_error = I80F48::from_num(0.1);
let right_value = fun(right)?;
require_msg!(
(left_value <= target_value && right_value >= target_value)
|| (left_value >= target_value && right_value <= target_value),
"internal error: left {} and right {} don't contain the target value {}",
left_value,
right_value,
target_value
);
for _ in 0..max_iterations {
if (right - left).abs() < min_step {
return Ok(left);
}
let new = I80F48::from_num(0.5) * (left + right);
let new_value = fun(new)?;
let error = new_value.saturating_sub(target_value);
if error > 0 && error < target_error {
return Ok(new);
}
if (new_value > target_value) ^ (right_value > target_value) {
left = new;
} else {
right = new;
}
}
Err(error_msg!("binary search iterations exhausted"))
}
/// This is not a generic function. It assumes there is a almost-unique maximum between left and right,
/// in the sense that `fun` might be constant on the maximum value for a while, but there won't be
/// distinct maximums with non-maximal values between them.
///
/// If the maximum isn't just a single point, it returns the rightmost value.
fn find_maximum(
mut left: I80F48,
mut right: I80F48,
min_step: I80F48,
fun: impl Fn(I80F48) -> Result<I80F48>,
) -> Result<(I80F48, I80F48)> {
assert!(right >= left);
let half = I80F48::from_num(0.5);
let mut mid = half * (left + right);
let mut left_value = fun(left)?;
let mut right_value = fun(right)?;
let mut mid_value = fun(mid)?;
while (right - left) > min_step {
//println!("it {left} {left_value}; {mid} {mid_value}; {right} {right_value}");
if left_value > mid_value {
// max must be between left and mid
assert!(mid_value >= right_value);
right = mid;
right_value = mid_value;
mid = half * (left + mid);
mid_value = fun(mid)?
} else if mid_value <= right_value {
// max must be between mid and right
assert!(left_value <= mid_value);
left = mid;
left_value = mid_value;
mid = half * (mid + right);
mid_value = fun(mid)?;
} else {
// mid is larger than both left and right, max could be on either side
let leftmid = half * (left + mid);
let leftmid_value = fun(leftmid)?;
//println!("lm {leftmid} {leftmid_value}");
assert!(leftmid_value >= left_value);
if leftmid_value > mid_value {
// max between left and mid
right = mid;
right_value = mid_value;
mid = leftmid;
mid_value = leftmid_value;
continue;
}
let rightmid = half * (mid + right);
let rightmid_value = fun(rightmid)?;
//println!("rm {rightmid} {rightmid_value}");
assert!(rightmid_value >= right_value);
if rightmid_value >= mid_value {
// max between mid and right
left = mid;
left_value = mid_value;
mid = rightmid;
mid_value = rightmid_value;
continue;
}
// max between leftmid and rightmid
left = leftmid;
left_value = leftmid_value;
right = rightmid;
right_value = rightmid_value;
}
}
if left_value > mid_value {
Ok((left, left_value))
} else if mid_value > right_value {
Ok((mid, mid_value))
} else {
Ok((right, right_value))
}
}
fn ignore_net_borrow_limit_errors(maybe_cache: Result<HealthCache>) -> Result<Option<HealthCache>> {
// Special case net borrow errors: We want to be able to find a good
// swap amount even if the max swap is limited by the net borrow limit.
if maybe_cache.is_anchor_error_with_code(MangoError::BankNetBorrowsLimitReached.error_code()) {
return Ok(None);
}
maybe_cache.map(|c| Some(c))
}
fn system_epoch_secs() -> u64 {
use std::time::SystemTime;
SystemTime::now()
.duration_since(SystemTime::UNIX_EPOCH)
.expect("system time after epoch start")
.as_secs()
}
#[cfg(test)]
mod tests {
use super::super::test::*;
use super::*;
use crate::state::*;
use serum_dex::state::OpenOrders;
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
}
}
fn leverage_eq(h: &HealthCache, b: f64) -> bool {
let a = h.leverage();
if (a - I80F48::from_num(b)).abs() < 0.001 {
true
} else {
println!("leverage is {}, but expected {}", a, b);
false
}
}
fn default_token_info(x: f64, price: f64) -> TokenInfo {
TokenInfo {
token_index: 0,
maint_asset_weight: I80F48::from_num(1.0 - x),
init_asset_weight: I80F48::from_num(1.0 - x),
init_scaled_asset_weight: I80F48::from_num(1.0 - x),
maint_liab_weight: I80F48::from_num(1.0 + x),
init_liab_weight: I80F48::from_num(1.0 + x),
init_scaled_liab_weight: I80F48::from_num(1.0 + x),
prices: Prices::new_single_price(I80F48::from_num(price)),
balance_spot: I80F48::ZERO,
allow_asset_liquidation: true,
}
}
fn default_perp_info(x: f64, price: f64) -> PerpInfo {
PerpInfo {
perp_market_index: 0,
settle_token_index: 0,
maint_base_asset_weight: I80F48::from_num(1.0 - x),
init_base_asset_weight: I80F48::from_num(1.0 - x),
maint_base_liab_weight: I80F48::from_num(1.0 + x),
init_base_liab_weight: I80F48::from_num(1.0 + x),
maint_overall_asset_weight: I80F48::from_num(0.6),
init_overall_asset_weight: I80F48::from_num(0.6),
base_lot_size: 1,
base_lots: 0,
bids_base_lots: 0,
asks_base_lots: 0,
quote: I80F48::ZERO,
base_prices: Prices::new_single_price(I80F48::from_num(price)),
has_open_orders: false,
has_open_fills: false,
}
}
#[test]
fn test_max_swap() {
let buffer = MangoAccount::default_for_tests().try_to_vec().unwrap();
let mut account = MangoAccountValue::from_bytes(&buffer).unwrap();
account.ensure_token_position(0).unwrap();
account.ensure_token_position(1).unwrap();
account.ensure_token_position(2).unwrap();
let group = Pubkey::new_unique();
let (mut bank0, _) = mock_bank_and_oracle(group, 0, 1.0, 0.1, 0.1);
let (mut bank1, _) = mock_bank_and_oracle(group, 1, 5.0, 0.2, 0.2);
let (mut bank2, _) = mock_bank_and_oracle(group, 2, 5.0, 0.3, 0.3);
let banks = [
bank0.data().clone(),
bank1.data().clone(),
bank2.data().clone(),
];
let health_cache = HealthCache {
token_infos: vec![
TokenInfo {
token_index: 0,
..default_token_info(0.1, 2.0)
},
TokenInfo {
token_index: 1,
..default_token_info(0.2, 3.0)
},
TokenInfo {
token_index: 2,
..default_token_info(0.3, 4.0)
},
],
serum3_infos: vec![],
perp_infos: vec![],
being_liquidated: false,
};
assert_eq!(health_cache.health(HealthType::Init), I80F48::ZERO);
assert_eq!(health_cache.health_ratio(HealthType::Init), I80F48::MAX);
assert_eq!(
health_cache
.max_swap_source_for_health_ratio_with_limits(
&account,
&banks[0],
I80F48::from(1),
&banks[1],
I80F48::from_num(2.0 / 3.0),
I80F48::from_num(50.0)
)
.unwrap(),
I80F48::ZERO
);
type MaxSwapFn = fn(&HealthCache) -> I80F48;
let adjust_by_usdc = |c: &mut HealthCache, ti: TokenIndex, usdc: f64| {
let ti = &mut c.token_infos[ti as usize];
ti.balance_spot += I80F48::from_num(usdc) / ti.prices.oracle;
};
let find_max_swap_actual = |c: &HealthCache,
source: TokenIndex,
target: TokenIndex,
min_value: f64,
price_factor: f64,
banks: [Bank; 3],
max_swap_fn: MaxSwapFn| {
let source_ti = &c.token_infos[source as usize];
let source_price = &source_ti.prices;
let mut source_bank = banks[source as usize].clone();
// Update the bank weights, because the tests like to modify the cache
// weights and expect them to stick
source_bank.init_asset_weight = source_ti.init_asset_weight;
source_bank.init_liab_weight = source_ti.init_liab_weight;
let target_ti = &c.token_infos[target as usize];
let target_price = &target_ti.prices;
let mut target_bank = banks[target as usize].clone();
target_bank.init_asset_weight = target_ti.init_asset_weight;
target_bank.init_liab_weight = target_ti.init_liab_weight;
let swap_price =
I80F48::from_num(price_factor) * source_price.oracle / target_price.oracle;
let source_unlimited = c
.max_swap_source_for_health_fn(
&account,
&source_bank,
source_price.oracle,
&target_bank,
swap_price,
I80F48::from_num(min_value),
max_swap_fn,
)
.unwrap();
let source_amount = HealthCache::apply_limits_to_swap(
&account,
&source_bank,
source_price.oracle,
&target_bank,
swap_price,
source_unlimited,
)
.unwrap();
if source_amount == I80F48::MAX {
return (f64::MAX, f64::MAX, f64::MAX, f64::MAX);
}
let value_for_amount = |amount| {
c.cache_after_swap(
&account,
&source_bank,
source_price.oracle,
&target_bank,
I80F48::from(amount),
swap_price,
)
.map(|c| max_swap_fn(&c).to_num::<f64>())
.unwrap_or(f64::MIN)
};
(
source_amount.to_num(),
value_for_amount(source_amount),
value_for_amount(source_amount - I80F48::ONE),
value_for_amount(source_amount + I80F48::ONE),
)
};
let check_max_swap_result = |c: &HealthCache,
source: TokenIndex,
target: TokenIndex,
min_value: f64,
price_factor: f64,
banks: [Bank; 3],
max_swap_fn: MaxSwapFn| {
let (source_amount, actual_value, minus_value, plus_value) = find_max_swap_actual(
c,
source,
target,
min_value,
price_factor,
banks,
max_swap_fn,
);
println!(
"checking {source} to {target} for price_factor: {price_factor}, target {min_value}: actual: {minus_value}/{actual_value}/{plus_value}, amount: {source_amount}",
);
if actual_value < min_value {
// check that swapping more would decrease the ratio!
assert!(plus_value < actual_value);
} else {
assert!(actual_value >= min_value);
// either we're within tolerance of the target, or swapping 1 more would
// bring us below the target
assert!(actual_value < min_value + 1.0 || plus_value < min_value);
}
};
let health_fn: Box<MaxSwapFn> = Box::new(|c: &HealthCache| c.health(HealthType::Init));
let health_ratio_fn: Box<MaxSwapFn> =
Box::new(|c: &HealthCache| c.health_ratio(HealthType::Init));
for (test_name, max_swap_fn) in [("health", health_fn), ("health_ratio", health_ratio_fn)] {
let check = |c: &HealthCache,
source: TokenIndex,
target: TokenIndex,
min_value: f64,
price_factor: f64,
banks: [Bank; 3]| {
check_max_swap_result(
c,
source,
target,
min_value,
price_factor,
banks,
*max_swap_fn,
)
};
let find_max_swap = |c: &HealthCache,
source: TokenIndex,
target: TokenIndex,
min_value: f64,
price_factor: f64,
banks: [Bank; 3]| {
find_max_swap_actual(
c,
source,
target,
min_value,
price_factor,
banks,
*max_swap_fn,
)
};
{
println!("test 0 {test_name}");
let mut health_cache = health_cache.clone();
adjust_by_usdc(&mut health_cache, 1, 100.0);
for price_factor in [0.1, 0.9, 1.1] {
for target in 1..100 {
let target = target as f64;
check(&health_cache, 0, 1, target, price_factor, banks);
check(&health_cache, 1, 0, target, price_factor, banks);
check(&health_cache, 0, 2, target, price_factor, banks);
}
}
// At this unlikely price it's healthy to swap infinitely
assert!(find_max_swap(&health_cache, 0, 1, 50.0, 1.5, banks).0 > 1e16);
}
{
println!("test 1 {test_name}");
let mut health_cache = health_cache.clone();
adjust_by_usdc(&mut health_cache, 0, -20.0);
adjust_by_usdc(&mut health_cache, 1, 100.0);
for price_factor in [0.1, 0.9, 1.1] {
for target in 1..100 {
let target = target as f64;
check(&health_cache, 0, 1, target, price_factor, banks);
check(&health_cache, 1, 0, target, price_factor, banks);
check(&health_cache, 0, 2, target, price_factor, banks);
check(&health_cache, 2, 0, target, price_factor, banks);
}
}
}
{
println!("test 2 {test_name}");
let mut health_cache = health_cache.clone();
adjust_by_usdc(&mut health_cache, 0, -50.0);
adjust_by_usdc(&mut health_cache, 1, 100.0);
// possible even though the init ratio is <100
check(&health_cache, 1, 0, 100.0, 1.0, banks);
}
{
println!("test 3 {test_name}");
let mut health_cache = health_cache.clone();
adjust_by_usdc(&mut health_cache, 0, -30.0);
adjust_by_usdc(&mut health_cache, 1, 100.0);
adjust_by_usdc(&mut health_cache, 2, -30.0);
// swapping with a high ratio advises paying back all liabs
// and then swapping even more because increasing assets in 0 has better asset weight
let init_ratio = health_cache.health_ratio(HealthType::Init);
let (amount, actual_ratio, _, _) =
find_max_swap(&health_cache, 1, 0, 100.0, 1.0, banks);
println!(
"init {}, after {}, amount {}",
init_ratio, actual_ratio, amount
);
assert!(actual_ratio / 2.0 > init_ratio);
assert!((amount as f64 - 100.0 / 3.0).abs() < 1.0);
}
{
println!("test 4 {test_name}");
let mut health_cache = health_cache.clone();
adjust_by_usdc(&mut health_cache, 0, 100.0);
adjust_by_usdc(&mut health_cache, 1, -2.0);
adjust_by_usdc(&mut health_cache, 2, -65.0);
let init_ratio = health_cache.health_ratio(HealthType::Init);
assert!(init_ratio > 3 && init_ratio < 4);
check(&health_cache, 0, 1, 1.0, 1.0, banks);
check(&health_cache, 0, 1, 3.0, 1.0, banks);
check(&health_cache, 0, 1, 4.0, 1.0, banks);
}
{
// check with net borrow limits
println!("test 5 {test_name}");
let mut health_cache = health_cache.clone();
adjust_by_usdc(&mut health_cache, 1, 100.0);
let mut banks = banks.clone();
banks[0].net_borrow_limit_per_window_quote = 50;
// The net borrow limit restricts the amount that can be swapped
// (tracking happens without decimals)
assert!(find_max_swap(&health_cache, 0, 1, 1.0, 1.0, banks).0 < 51.0);
}
{
// check with serum reserved
println!("test 6 {test_name}");
let mut health_cache = health_cache.clone();
health_cache.serum3_infos = vec![Serum3Info {
base_info_index: 1,
quote_info_index: 0,
market_index: 0,
reserved_base: I80F48::from(30 / 3),
reserved_quote: I80F48::from(30 / 2),
reserved_base_as_quote_lowest_ask: I80F48::ZERO,
reserved_quote_as_base_highest_bid: I80F48::ZERO,
has_zero_funds: false,
}];
adjust_by_usdc(&mut health_cache, 0, -20.0);
adjust_by_usdc(&mut health_cache, 1, -40.0);
adjust_by_usdc(&mut health_cache, 2, 120.0);
for price_factor in [0.9, 1.1] {
for target in 1..100 {
let target = target as f64;
check(&health_cache, 0, 1, target, price_factor, banks);
check(&health_cache, 1, 0, target, price_factor, banks);
check(&health_cache, 0, 2, target, price_factor, banks);
check(&health_cache, 1, 2, target, price_factor, banks);
check(&health_cache, 2, 0, target, price_factor, banks);
check(&health_cache, 2, 1, target, price_factor, banks);
}
}
}
{
// check starting with negative health but swapping can make it positive
println!("test 7 {test_name}");
let mut health_cache = health_cache.clone();
adjust_by_usdc(&mut health_cache, 0, -20.0);
adjust_by_usdc(&mut health_cache, 1, 20.0);
assert!(health_cache.health(HealthType::Init) < 0);
if test_name == "health" {
assert!(find_max_swap(&health_cache, 1, 0, 1.0, 1.0, banks).0 > 0.0);
}
for price_factor in [0.9, 1.1] {
for target in 1..100 {
let target = target as f64;
check(&health_cache, 1, 0, target, price_factor, banks);
}
}
}
{
// check starting with negative health but swapping can't make it positive
println!("test 8 {test_name}");
let mut health_cache = health_cache.clone();
adjust_by_usdc(&mut health_cache, 0, -20.0);
adjust_by_usdc(&mut health_cache, 1, 10.0);
assert!(health_cache.health(HealthType::Init) < 0);
if test_name == "health" {
assert!(find_max_swap(&health_cache, 1, 0, 1.0, 1.0, banks).0 > 0.0);
}
for price_factor in [0.9, 1.1] {
for target in 1..100 {
let target = target as f64;
check(&health_cache, 1, 0, target, price_factor, banks);
}
}
}
{
// swap some assets into a zero-asset-weight token
println!("test 9 {test_name}");
let mut health_cache = health_cache.clone();
adjust_by_usdc(&mut health_cache, 0, 10.0);
health_cache.token_infos[1].init_asset_weight = I80F48::from(0);
assert!(find_max_swap(&health_cache, 0, 1, 1.0, 1.0, banks).0 > 0.0);
for price_factor in [0.9, 1.1] {
for target in 1..100 {
let target = target as f64;
check(&health_cache, 0, 1, target, price_factor, banks);
}
}
}
{
// swap while influenced by a perp market
println!("test 10 {test_name}");
let mut health_cache = health_cache.clone();
health_cache.perp_infos.push(PerpInfo {
perp_market_index: 0,
settle_token_index: 1,
..default_perp_info(0.3, 2.0)
});
adjust_by_usdc(&mut health_cache, 0, 60.0);
for perp_quote in [-10, 10] {
health_cache.perp_infos[0].quote = I80F48::from_num(perp_quote);
for price_factor in [0.9, 1.1] {
for target in 1..100 {
let target = target as f64;
check(&health_cache, 0, 1, target, price_factor, banks);
check(&health_cache, 1, 0, target, price_factor, banks);
}
}
}
}
{
// swap some assets between zero-asset-weight tokens
println!("test 11 {test_name}");
let mut health_cache = health_cache.clone();
adjust_by_usdc(&mut health_cache, 0, 10.0); // 5 tokens
health_cache.token_infos[0].init_asset_weight = I80F48::from(0);
health_cache.token_infos[1].init_asset_weight = I80F48::from(0);
let amount = find_max_swap(&health_cache, 0, 1, 1.0, 1.0, banks).0;
assert_eq!(amount, 5.0);
for price_factor in [0.9, 1.1] {
for target in 1..100 {
let target = target as f64;
// Result is always the same: swap all deposits
let amount =
find_max_swap(&health_cache, 0, 1, target, price_factor, banks).0;
assert_eq!(amount, 5.0);
}
}
adjust_by_usdc(&mut health_cache, 1, 6.0); // 2 tokens
for price_factor in [0.9, 1.1] {
for target in 1..100 {
let target = target as f64;
// Result is always the same: swap all deposits
let amount =
find_max_swap(&health_cache, 0, 1, target, price_factor, banks).0;
assert_eq!(amount, 5.0);
}
}
}
}
}
#[test]
fn test_max_perp() {
let base_lot_size = 100;
let health_cache = HealthCache {
token_infos: vec![
TokenInfo {
token_index: 0,
balance_spot: I80F48::ZERO,
..default_token_info(0.0, 1.0)
},
TokenInfo {
token_index: 1,
balance_spot: I80F48::ZERO,
..default_token_info(0.2, 1.5)
},
],
serum3_infos: vec![],
perp_infos: vec![PerpInfo {
perp_market_index: 0,
settle_token_index: 1,
base_lot_size,
..default_perp_info(0.3, 2.0)
}],
being_liquidated: false,
};
assert_eq!(health_cache.health(HealthType::Init), I80F48::ZERO);
assert_eq!(health_cache.health_ratio(HealthType::Init), I80F48::MAX);
assert_eq!(
health_cache
.max_perp_for_health_ratio(
0,
I80F48::from(2),
PerpOrderSide::Bid,
I80F48::from_num(50.0)
)
.unwrap(),
I80F48::ZERO
);
let adjust_token = |c: &mut HealthCache, info_index: usize, value: f64| {
let ti = &mut c.token_infos[info_index];
ti.balance_spot += I80F48::from_num(value);
};
let find_max_trade =
|c: &HealthCache, side: PerpOrderSide, ratio: f64, price_factor: f64| {
let prices = &c.perp_infos[0].base_prices;
let trade_price = I80F48::from_num(price_factor) * prices.oracle;
let base_lots = c
.max_perp_for_health_ratio(0, trade_price, side, I80F48::from_num(ratio))
.unwrap();
if base_lots == i64::MAX {
return (i64::MAX, f64::MAX, f64::MAX);
}
let direction = match side {
PerpOrderSide::Bid => 1,
PerpOrderSide::Ask => -1,
};
// compute the health ratio we'd get when executing the trade
let actual_ratio = {
let base_lots = direction * base_lots;
let base_native = I80F48::from(base_lots * base_lot_size);
let mut c = c.clone();
c.perp_infos[0].base_lots += base_lots;
c.perp_infos[0].quote -= base_native * trade_price;
c.health_ratio(HealthType::Init).to_num::<f64>()
};
// the ratio for trading just one base lot extra
let plus_ratio = {
let base_lots = direction * (base_lots + 1);
let base_native = I80F48::from(base_lots * base_lot_size);
let mut c = c.clone();
c.perp_infos[0].base_lots += base_lots;
c.perp_infos[0].quote -= base_native * trade_price;
c.health_ratio(HealthType::Init).to_num::<f64>()
};
(base_lots, actual_ratio, plus_ratio)
};
let check_max_trade = |c: &HealthCache,
side: PerpOrderSide,
ratio: f64,
price_factor: f64| {
let (base_lots, actual_ratio, plus_ratio) =
find_max_trade(c, side, ratio, price_factor);
println!(
"checking for price_factor: {price_factor}, target ratio {ratio}: actual ratio: {actual_ratio}, plus ratio: {plus_ratio}, base_lots: {base_lots}",
);
let max_binary_search_error = 0.1;
assert!(ratio <= actual_ratio);
assert!(plus_ratio - max_binary_search_error <= ratio);
};
{
let mut health_cache = health_cache.clone();
adjust_token(&mut health_cache, 0, 3000.0);
for existing_settle in [-500.0, 0.0, 300.0] {
let mut c = health_cache.clone();
adjust_token(&mut c, 1, existing_settle);
for existing_lots in [-5, 0, 3] {
let mut c = c.clone();
c.perp_infos[0].base_lots += existing_lots;
c.perp_infos[0].quote -= I80F48::from(existing_lots * base_lot_size * 2);
for side in [PerpOrderSide::Bid, PerpOrderSide::Ask] {
println!(
"test 0: lots {existing_lots}, settle {existing_settle}, side {side:?}"
);
for price_factor in [0.8, 1.0, 1.1] {
for ratio in 1..=100 {
check_max_trade(&c, side, ratio as f64, price_factor);
}
}
}
}
}
// check some extremely bad prices
check_max_trade(&health_cache, PerpOrderSide::Bid, 50.0, 2.0);
check_max_trade(&health_cache, PerpOrderSide::Ask, 50.0, 0.1);
// and extremely good prices
assert_eq!(
find_max_trade(&health_cache, PerpOrderSide::Bid, 50.0, 0.1).0,
i64::MAX
);
assert_eq!(
find_max_trade(&health_cache, PerpOrderSide::Ask, 50.0, 1.5).0,
i64::MAX
);
}
}
#[test]
fn test_health_perp_funding() {
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);
bank1
.data()
.change_without_fee(
account.ensure_token_position(0).unwrap().0,
I80F48::from(100),
DUMMY_NOW_TS,
)
.unwrap();
let mut perp1 = mock_perp_market(group, oracle1.pubkey, 1.0, 9, (0.2, 0.1), (0.05, 0.02));
perp1.data().long_funding = I80F48::from_num(10.1);
let perpaccount = account.ensure_perp_position(9, 0).unwrap().0;
perpaccount.record_trade(perp1.data(), 10, I80F48::from(-110));
perpaccount.long_settled_funding = I80F48::from_num(10.0);
let oracle1_ai = oracle1.as_account_info();
let ais = vec![
bank1.as_account_info(),
oracle1_ai.clone(),
perp1.as_account_info(),
oracle1_ai,
];
let retriever = ScanningAccountRetriever::new_with_staleness(&ais, &group, None).unwrap();
assert!(health_eq(
compute_health(&account.borrow(), HealthType::Init, &retriever, 0).unwrap(),
// token
0.8 * (100.0
// perp base
+ 0.8 * 100.0
// perp quote
- 110.0
// perp funding (10 * (10.1 - 10.0))
- 1.0)
));
}
#[test]
fn test_scanning_retreiver_mismatched_oracle_for_perps_throws_error() {
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 mut perp1 = mock_perp_market(group, oracle1.pubkey, 1.0, 9, (0.2, 0.1), (0.05, 0.02));
let mut perp2 = mock_perp_market(group, oracle2.pubkey, 5.0, 8, (0.2, 0.1), (0.05, 0.02));
let oracle1_account_info = oracle1.as_account_info();
let oracle2_account_info = oracle2.as_account_info();
let ais = vec![
bank1.as_account_info(),
bank2.as_account_info(),
oracle1_account_info.clone(),
oracle2_account_info.clone(),
perp1.as_account_info(),
perp2.as_account_info(),
oracle2_account_info, // Oracles wrong way around
oracle1_account_info,
oo1.as_account_info(),
];
let retriever = ScanningAccountRetriever::new_with_staleness(&ais, &group, None).unwrap();
let result = retriever.perp_market_and_oracle_price(&group, 0, 9);
assert!(result.is_err());
}
#[test]
fn test_health_stable_price_token() {
let buffer = MangoAccount::default_for_tests().try_to_vec().unwrap();
let mut account = MangoAccountValue::from_bytes(&buffer).unwrap();
let buffer2 = MangoAccount::default_for_tests().try_to_vec().unwrap();
let mut account2 = MangoAccountValue::from_bytes(&buffer2).unwrap();
let buffer3 = MangoAccount::default_for_tests().try_to_vec().unwrap();
let mut account3 = MangoAccountValue::from_bytes(&buffer3).unwrap();
let group = Pubkey::new_unique();
let (mut bank1, mut oracle1) = mock_bank_and_oracle(group, 0, 1.0, 0.2, 0.1);
bank1.data().stable_price_model.stable_price = 0.5;
bank1
.data()
.change_without_fee(
account.ensure_token_position(0).unwrap().0,
I80F48::from(100),
DUMMY_NOW_TS,
)
.unwrap();
bank1
.data()
.change_without_fee(
account2.ensure_token_position(0).unwrap().0,
I80F48::from(-100),
DUMMY_NOW_TS,
)
.unwrap();
let mut perp1 = mock_perp_market(group, oracle1.pubkey, 1.0, 9, (0.2, 0.1), (0.05, 0.02));
perp1.data().stable_price_model.stable_price = 0.5;
let perpaccount = account3.ensure_perp_position(9, 0).unwrap().0;
perpaccount.record_trade(perp1.data(), 10, I80F48::from(-100));
let oracle1_ai = oracle1.as_account_info();
let ais = vec![
bank1.as_account_info(),
oracle1_ai.clone(),
perp1.as_account_info(),
oracle1_ai,
];
let retriever = ScanningAccountRetriever::new_with_staleness(&ais, &group, None).unwrap();
assert!(health_eq(
compute_health(&account.borrow(), HealthType::Init, &retriever, 0).unwrap(),
0.8 * 0.5 * 100.0
));
assert!(health_eq(
compute_health(&account.borrow(), HealthType::Maint, &retriever, 0).unwrap(),
0.9 * 1.0 * 100.0
));
assert!(health_eq(
compute_health(&account2.borrow(), HealthType::Init, &retriever, 0).unwrap(),
-1.2 * 1.0 * 100.0
));
assert!(health_eq(
compute_health(&account2.borrow(), HealthType::Maint, &retriever, 0).unwrap(),
-1.1 * 1.0 * 100.0
));
assert!(health_eq(
compute_health(&account3.borrow(), HealthType::Init, &retriever, 0).unwrap(),
1.2 * (0.8 * 0.5 * 10.0 * 10.0 - 100.0)
));
assert!(health_eq(
compute_health(&account3.borrow(), HealthType::Maint, &retriever, 0).unwrap(),
1.1 * (0.9 * 1.0 * 10.0 * 10.0 - 100.0)
));
}
#[test]
fn test_max_borrow() {
let buffer = MangoAccount::default_for_tests().try_to_vec().unwrap();
let mut account = MangoAccountValue::from_bytes(&buffer).unwrap();
account.ensure_token_position(0).unwrap();
account.ensure_token_position(1).unwrap();
let group = Pubkey::new_unique();
let (mut bank0, _) = mock_bank_and_oracle(group, 0, 1.0, 0.0, 0.0);
let bank0_data = bank0.data();
let health_cache = HealthCache {
token_infos: vec![
TokenInfo {
token_index: 0,
..default_token_info(0.0, 1.0)
},
TokenInfo {
token_index: 1,
..default_token_info(0.2, 2.0)
},
],
serum3_infos: vec![],
perp_infos: vec![],
being_liquidated: false,
};
assert_eq!(health_cache.health(HealthType::Init), I80F48::ZERO);
assert_eq!(health_cache.health_ratio(HealthType::Init), I80F48::MAX);
assert_eq!(
health_cache
.max_borrow_for_health_ratio(&account, bank0_data, I80F48::from(50))
.unwrap(),
I80F48::ZERO
);
let now_ts = system_epoch_secs();
let cache_after_borrow = |account: &MangoAccountValue,
c: &HealthCache,
bank: &Bank,
amount: I80F48|
-> Result<HealthCache> {
let mut position = account.token_position(bank.token_index)?.clone();
let mut bank = bank.clone();
bank.withdraw_with_fee(&mut position, amount, now_ts)?;
bank.check_net_borrows(c.token_info(bank.token_index)?.prices.oracle)?;
let mut resulting_cache = c.clone();
resulting_cache.adjust_token_balance(&bank, -amount)?;
Ok(resulting_cache)
};
let find_max_borrow =
|account: &MangoAccountValue, c: &HealthCache, ratio: f64, bank: &Bank| {
let max_borrow = c
.max_borrow_for_health_ratio(account, bank, I80F48::from_num(ratio))
.unwrap();
// compute the health ratio we'd get when executing the trade
let actual_ratio = {
let c = cache_after_borrow(account, c, bank, max_borrow).unwrap();
c.health_ratio(HealthType::Init).to_num::<f64>()
};
// the ratio for borrowing one native token extra
let plus_ratio = {
let c = cache_after_borrow(account, c, bank, max_borrow + I80F48::ONE).unwrap();
c.health_ratio(HealthType::Init).to_num::<f64>()
};
(max_borrow, actual_ratio, plus_ratio)
};
let check_max_borrow = |account: &MangoAccountValue,
c: &HealthCache,
ratio: f64,
bank: &Bank|
-> f64 {
let initial_ratio = c.health_ratio(HealthType::Init).to_num::<f64>();
let (max_borrow, actual_ratio, plus_ratio) = find_max_borrow(account, c, ratio, bank);
println!(
"checking target ratio {ratio}: initial ratio: {initial_ratio}, actual ratio: {actual_ratio}, plus ratio: {plus_ratio}, borrow: {max_borrow}",
);
let max_binary_search_error = 0.1;
if initial_ratio >= ratio {
assert!(ratio <= actual_ratio);
assert!(plus_ratio - max_binary_search_error <= ratio);
}
max_borrow.to_num::<f64>()
};
{
let mut health_cache = health_cache.clone();
health_cache.token_infos[0].balance_spot = I80F48::from_num(100.0);
assert_eq!(
check_max_borrow(&account, &health_cache, 50.0, bank0_data),
100.0
);
}
{
let mut health_cache = health_cache.clone();
health_cache.token_infos[1].balance_spot = I80F48::from_num(50.0); // price 2, so 2*50*0.8 = 80 health
check_max_borrow(&account, &health_cache, 100.0, bank0_data);
check_max_borrow(&account, &health_cache, 50.0, bank0_data);
check_max_borrow(&account, &health_cache, 0.0, bank0_data);
}
{
let mut health_cache = health_cache.clone();
health_cache.token_infos[0].balance_spot = I80F48::from_num(50.0);
health_cache.token_infos[1].balance_spot = I80F48::from_num(50.0);
check_max_borrow(&account, &health_cache, 100.0, bank0_data);
check_max_borrow(&account, &health_cache, 50.0, bank0_data);
check_max_borrow(&account, &health_cache, 0.0, bank0_data);
}
{
let mut health_cache = health_cache.clone();
health_cache.token_infos[0].balance_spot = I80F48::from_num(-50.0);
health_cache.token_infos[1].balance_spot = I80F48::from_num(50.0);
check_max_borrow(&account, &health_cache, 100.0, bank0_data);
check_max_borrow(&account, &health_cache, 50.0, bank0_data);
check_max_borrow(&account, &health_cache, 0.0, bank0_data);
}
// A test that includes init weight scaling
{
let mut account = account.clone();
let mut bank0 = bank0_data.clone();
let mut health_cache = health_cache.clone();
let tok0_deposits = I80F48::from_num(500.0);
health_cache.token_infos[0].balance_spot = tok0_deposits;
health_cache.token_infos[1].balance_spot = I80F48::from_num(-100.0); // 2 * 100 * 1.2 = 240 liab
// This test case needs the bank to know about the deposits
let position = account.token_position_mut(bank0.token_index).unwrap().0;
bank0.deposit(position, tok0_deposits, now_ts).unwrap();
// Set up scaling such that token0 health contrib is 500 * 1.0 * 1.0 * (600 / (500 + 300)) = 375
bank0.deposit_weight_scale_start_quote = 600.0;
bank0.potential_serum_tokens = 300;
health_cache.token_infos[0].init_scaled_asset_weight =
bank0.scaled_init_asset_weight(I80F48::ONE);
check_max_borrow(&account, &health_cache, 100.0, &bank0);
check_max_borrow(&account, &health_cache, 50.0, &bank0);
let max_borrow = check_max_borrow(&account, &health_cache, 0.0, &bank0);
// that borrow leaves 240 tokens in the account and <600 total in bank
assert!((260.0 - max_borrow).abs() < 0.3);
bank0.deposit_weight_scale_start_quote = 500.0;
let max_borrow = check_max_borrow(&account, &health_cache, 0.0, &bank0);
// 500 - 222.6 = 277.4 remaining token 0 deposits
// 277.4 * 500 / (277.4 + 300) = 240.2 (compensating the -240 liab)
assert!((222.6 - max_borrow).abs() < 0.3);
}
}
#[test]
fn test_assets_and_borrows() {
let health_cache = HealthCache {
token_infos: vec![
TokenInfo {
token_index: 0,
..default_token_info(0.0, 1.0)
},
TokenInfo {
token_index: 1,
..default_token_info(0.2, 2.0)
},
],
serum3_infos: vec![],
perp_infos: vec![PerpInfo {
perp_market_index: 0,
settle_token_index: 0,
..default_perp_info(0.3, 2.0)
}],
being_liquidated: false,
};
{
let mut hc = health_cache.clone();
hc.token_infos[1].balance_spot = I80F48::from(10);
hc.perp_infos[0].quote = I80F48::from(-12);
let (assets, liabs) = hc.health_assets_and_liabs_stable_assets(HealthType::Init);
assert!((assets.to_num::<f64>() - 2.0 * 10.0 * 0.8) < 0.01);
assert!((liabs.to_num::<f64>() - 2.0 * (10.0 * 0.8 + 2.0 * 1.2)) < 0.01);
let (assets, liabs) = hc.health_assets_and_liabs_stable_liabs(HealthType::Init);
assert!((liabs.to_num::<f64>() - 2.0 * 12.0 * 1.2) < 0.01);
assert!((assets.to_num::<f64>() - 2.0 * 10.0 * 1.2) < 0.01);
}
{
let mut hc = health_cache.clone();
hc.token_infos[1].balance_spot = I80F48::from(12);
hc.perp_infos[0].quote = I80F48::from(-10);
let (assets, liabs) = hc.health_assets_and_liabs_stable_assets(HealthType::Init);
assert!((assets.to_num::<f64>() - 2.0 * 12.0 * 0.8) < 0.01);
assert!((liabs.to_num::<f64>() - 2.0 * 10.0 * 0.8) < 0.01);
let (assets, liabs) = hc.health_assets_and_liabs_stable_liabs(HealthType::Init);
assert!((liabs.to_num::<f64>() - 2.0 * 10.0 * 1.2) < 0.01);
assert!((assets.to_num::<f64>() - 2.0 * (10.0 * 1.2 + 2.0 * 0.8)) < 0.01);
}
}
#[test]
fn test_leverage() {
// only deposits
let health_cache = HealthCache {
token_infos: vec![
TokenInfo {
token_index: 0,
balance_spot: I80F48::ONE,
..default_token_info(0.0, 1.0)
},
TokenInfo {
token_index: 1,
..default_token_info(0.2, 2.0)
},
],
serum3_infos: vec![],
perp_infos: vec![],
being_liquidated: false,
};
assert!(leverage_eq(&health_cache, 0.0));
// deposits and borrows: assets = 10, equity = 1
let health_cache = HealthCache {
token_infos: vec![
TokenInfo {
token_index: 0,
balance_spot: I80F48::from_num(-9),
..default_token_info(0.0, 1.0)
},
TokenInfo {
token_index: 1,
balance_spot: I80F48::from_num(5),
..default_token_info(0.2, 2.0)
},
],
serum3_infos: vec![],
perp_infos: vec![],
being_liquidated: false,
};
assert!(leverage_eq(&health_cache, 9.0));
// perp trade: assets = 1 + 9.9, equity = 1
let health_cache = HealthCache {
token_infos: vec![
TokenInfo {
token_index: 0,
balance_spot: I80F48::ONE,
..default_token_info(0.0, 1.0)
},
TokenInfo {
token_index: 1,
..default_token_info(0.2, 2.0)
},
],
serum3_infos: vec![],
perp_infos: vec![PerpInfo {
perp_market_index: 0,
base_lot_size: 3,
base_lots: -3,
quote: I80F48::from_num(9.9),
..default_perp_info(0.1, 1.1)
}],
being_liquidated: false,
};
assert!(leverage_eq(&health_cache, 9.9));
// open orders: assets = 3, equity = 1
let health_cache = HealthCache {
token_infos: vec![
TokenInfo {
token_index: 0,
balance_spot: I80F48::ONE,
..default_token_info(0.0, 1.0)
},
TokenInfo {
token_index: 1,
balance_spot: I80F48::from_num(-1),
..default_token_info(0.2, 2.0)
},
],
serum3_infos: vec![Serum3Info {
reserved_base: I80F48::ONE,
reserved_quote: I80F48::ZERO,
reserved_base_as_quote_lowest_ask: I80F48::ONE,
reserved_quote_as_base_highest_bid: I80F48::ZERO,
base_info_index: 1,
quote_info_index: 0,
market_index: 0,
has_zero_funds: true,
}],
perp_infos: vec![],
being_liquidated: false,
};
assert!(leverage_eq(&health_cache, 2.0));
}
}