Merge pull request #677 from blockworks-foundation/ckamm/tcs-net-borrow
Tcs and liquidator and net borrow limits
This commit is contained in:
commit
0c26977ec9
|
@ -7,9 +7,8 @@ use anchor_client::Cluster;
|
|||
use clap::Parser;
|
||||
use mango_v4::state::{PerpMarketIndex, TokenIndex};
|
||||
use mango_v4_client::{
|
||||
account_update_stream, chain_data, keypair_from_cli, snapshot_source, websocket_source,
|
||||
AsyncChannelSendUnlessFull, Client, MangoClient, MangoClientError, MangoGroupContext,
|
||||
TransactionBuilderConfig,
|
||||
account_update_stream, chain_data, keypair_from_cli, snapshot_source, websocket_source, Client,
|
||||
MangoClient, MangoClientError, MangoGroupContext, TransactionBuilderConfig,
|
||||
};
|
||||
|
||||
use itertools::Itertools;
|
||||
|
@ -25,7 +24,7 @@ pub mod token_swap_info;
|
|||
pub mod trigger_tcs;
|
||||
pub mod util;
|
||||
|
||||
use crate::util::{is_mango_account, is_mango_bank, is_mint_info, is_perp_market};
|
||||
use crate::util::{is_mango_account, is_mint_info, is_perp_market};
|
||||
|
||||
// jemalloc seems to be better at keeping the memory footprint reasonable over
|
||||
// longer periods of time
|
||||
|
@ -317,9 +316,6 @@ async fn main() -> anyhow::Result<()> {
|
|||
last_persistent_error_report: Instant::now(),
|
||||
});
|
||||
|
||||
let (liquidation_trigger_sender, liquidation_trigger_receiver) =
|
||||
async_channel::bounded::<()>(1);
|
||||
|
||||
info!("main loop");
|
||||
|
||||
// Job to update chain_data and notify the liquidation job when a new check is needed.
|
||||
|
@ -359,29 +355,6 @@ async fn main() -> anyhow::Result<()> {
|
|||
// Track all MangoAccounts: we need to iterate over them later
|
||||
state.mango_accounts.insert(account_write.pubkey);
|
||||
metric_mango_accounts.set(state.mango_accounts.len() as u64);
|
||||
|
||||
if !state.health_check_all {
|
||||
state.health_check_accounts.push(account_write.pubkey);
|
||||
}
|
||||
liquidation_trigger_sender.send_unless_full(()).unwrap();
|
||||
} else {
|
||||
let mut must_check_all = false;
|
||||
if is_mango_bank(&account_write.account, &mango_group).is_some() {
|
||||
debug!("change to bank {}", &account_write.pubkey);
|
||||
must_check_all = true;
|
||||
}
|
||||
if is_perp_market(&account_write.account, &mango_group).is_some() {
|
||||
debug!("change to perp market {}", &account_write.pubkey);
|
||||
must_check_all = true;
|
||||
}
|
||||
if oracles.contains(&account_write.pubkey) {
|
||||
debug!("change to oracle {}", &account_write.pubkey);
|
||||
must_check_all = true;
|
||||
}
|
||||
if must_check_all {
|
||||
state.health_check_all = true;
|
||||
liquidation_trigger_sender.send_unless_full(()).unwrap();
|
||||
}
|
||||
}
|
||||
}
|
||||
Message::Snapshot(snapshot) => {
|
||||
|
@ -404,9 +377,6 @@ async fn main() -> anyhow::Result<()> {
|
|||
metric_mango_accounts.set(state.mango_accounts.len() as u64);
|
||||
|
||||
state.one_snapshot_done = true;
|
||||
state.health_check_all = true;
|
||||
|
||||
liquidation_trigger_sender.send_unless_full(()).unwrap();
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
|
@ -438,25 +408,20 @@ async fn main() -> anyhow::Result<()> {
|
|||
});
|
||||
|
||||
let liquidation_job = tokio::spawn({
|
||||
// TODO: configurable interval
|
||||
let mut interval = tokio::time::interval(Duration::from_secs(5));
|
||||
let shared_state = shared_state.clone();
|
||||
async move {
|
||||
loop {
|
||||
liquidation_trigger_receiver.recv().await.unwrap();
|
||||
interval.tick().await;
|
||||
|
||||
let account_addresses;
|
||||
{
|
||||
let mut state = shared_state.write().unwrap();
|
||||
let account_addresses = {
|
||||
let state = shared_state.write().unwrap();
|
||||
if !state.one_snapshot_done {
|
||||
continue;
|
||||
}
|
||||
account_addresses = if state.health_check_all {
|
||||
state.mango_accounts.iter().cloned().collect()
|
||||
} else {
|
||||
state.health_check_accounts.clone()
|
||||
};
|
||||
state.health_check_all = false;
|
||||
state.health_check_accounts = vec![];
|
||||
}
|
||||
state.mango_accounts.iter().cloned().collect_vec()
|
||||
};
|
||||
|
||||
liquidation.log_persistent_errors();
|
||||
|
||||
|
@ -482,10 +447,12 @@ async fn main() -> anyhow::Result<()> {
|
|||
let shared_state = shared_state.clone();
|
||||
async move {
|
||||
loop {
|
||||
interval.tick().await;
|
||||
min_delay.tick().await;
|
||||
if !shared_state.read().unwrap().one_snapshot_done {
|
||||
continue;
|
||||
}
|
||||
|
||||
interval.tick().await;
|
||||
let token_indexes = token_swap_info_updater
|
||||
.mango_client()
|
||||
.context
|
||||
|
@ -499,8 +466,7 @@ async fn main() -> anyhow::Result<()> {
|
|||
Ok(()) => {}
|
||||
Err(err) => {
|
||||
warn!(
|
||||
"failed to update token swap info for token {token_index}: {:?}",
|
||||
err
|
||||
"failed to update token swap info for token {token_index}: {err:?}",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
@ -540,12 +506,6 @@ struct SharedState {
|
|||
|
||||
/// Is the first snapshot done? Only start checking account health when it is.
|
||||
one_snapshot_done: bool,
|
||||
|
||||
/// Accounts whose health might have changed
|
||||
health_check_accounts: Vec<Pubkey>,
|
||||
|
||||
/// Check all accounts?
|
||||
health_check_all: bool,
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
|
|
|
@ -12,14 +12,16 @@ use {anyhow::Context, fixed::types::I80F48, solana_sdk::pubkey::Pubkey};
|
|||
|
||||
use crate::{token_swap_info, util};
|
||||
|
||||
// The liqee health ratio to aim for when executing tcs orders that are bigger
|
||||
// than the liqee can support.
|
||||
//
|
||||
// The background here is that the program considers bringing the liqee health ratio
|
||||
// below 1% as "the tcs was completely fulfilled" and then closes the tcs.
|
||||
// Choosing a value too close to 0 is problematic, since then small oracle fluctuations
|
||||
// could bring the final health below 0 and make the triggering invalid!
|
||||
const TARGET_HEALTH_RATIO: f64 = 0.5;
|
||||
/// When computing the max possible swap for a liqee, assume the price is this fraction worse for them.
|
||||
///
|
||||
/// That way when executing the swap, the prices may move this much against the liqee without
|
||||
/// making the whole execution fail.
|
||||
const SLIPPAGE_BUFFER: f64 = 0.01; // 1%
|
||||
|
||||
/// If a tcs gets limited due to exhausted net borrows, don't trigger execution if
|
||||
/// the possible value is below this amount. This avoids spamming executions when net
|
||||
/// borrows are exhausted.
|
||||
const NET_BORROW_EXECUTION_THRESHOLD: u64 = 1_000_000; // 1 USD
|
||||
|
||||
pub struct Config {
|
||||
pub min_health_ratio: f64,
|
||||
|
@ -39,11 +41,7 @@ fn tcs_is_in_price_range(
|
|||
let buy_token_price = account_fetcher.fetch_bank_price(&buy_bank)?;
|
||||
let sell_token_price = account_fetcher.fetch_bank_price(&sell_bank)?;
|
||||
let base_price = (buy_token_price / sell_token_price).to_num();
|
||||
if !tcs.price_in_range(base_price) {
|
||||
return Ok(false);
|
||||
}
|
||||
|
||||
return Ok(true);
|
||||
Ok(tcs.price_in_range(base_price))
|
||||
}
|
||||
|
||||
fn tcs_has_plausible_premium(
|
||||
|
@ -154,27 +152,21 @@ async fn execute_token_conditional_swap(
|
|||
|
||||
let base_price = buy_token_price / sell_token_price;
|
||||
let premium_price = tcs.premium_price(base_price.to_num());
|
||||
let maker_price = I80F48::from_num(tcs.maker_price(premium_price));
|
||||
let taker_price = I80F48::from_num(tcs.taker_price(premium_price));
|
||||
|
||||
let max_take_quote = I80F48::from(config.max_trigger_quote_amount);
|
||||
|
||||
let liqee_target_health_ratio = I80F48::from_num(TARGET_HEALTH_RATIO);
|
||||
|
||||
let max_sell_token_to_liqor = util::max_swap_source(
|
||||
mango_client,
|
||||
account_fetcher,
|
||||
&liqee,
|
||||
tcs.sell_token_index,
|
||||
tcs.buy_token_index,
|
||||
I80F48::ONE / maker_price,
|
||||
liqee_target_health_ratio,
|
||||
)?
|
||||
.min(max_take_quote / sell_token_price)
|
||||
.floor()
|
||||
.to_num::<u64>()
|
||||
.min(tcs.remaining_sell());
|
||||
let (liqee_max_buy, liqee_max_sell) =
|
||||
match tcs_max_liqee_execution(liqee, mango_client, account_fetcher, tcs)? {
|
||||
Some(v) => v,
|
||||
None => return Ok(false),
|
||||
};
|
||||
let max_sell_token_to_liqor = liqee_max_sell;
|
||||
|
||||
// In addition to the liqee's requirements, the liqor also has requirements:
|
||||
// - only swap while the health ratio stays high enough
|
||||
// - possible net borrow limit restrictions from the liqor borrowing the buy token
|
||||
// - liqor has a max_take_quote
|
||||
let max_buy_token_to_liqee = util::max_swap_source(
|
||||
mango_client,
|
||||
account_fetcher,
|
||||
|
@ -187,7 +179,7 @@ async fn execute_token_conditional_swap(
|
|||
.min(max_take_quote / buy_token_price)
|
||||
.floor()
|
||||
.to_num::<u64>()
|
||||
.min(tcs.remaining_buy());
|
||||
.min(liqee_max_buy);
|
||||
|
||||
if max_sell_token_to_liqor == 0 || max_buy_token_to_liqee == 0 {
|
||||
return Ok(false);
|
||||
|
@ -332,8 +324,46 @@ fn tcs_max_volume(
|
|||
mango_client: &MangoClient,
|
||||
account_fetcher: &chain_data::AccountFetcher,
|
||||
tcs: &TokenConditionalSwap,
|
||||
) -> anyhow::Result<u64> {
|
||||
// Compute the max viable swap (for liqor and liqee) and min it
|
||||
) -> anyhow::Result<Option<u64>> {
|
||||
let buy_bank_pk = mango_client
|
||||
.context
|
||||
.mint_info(tcs.buy_token_index)
|
||||
.first_bank();
|
||||
let sell_bank_pk = mango_client
|
||||
.context
|
||||
.mint_info(tcs.sell_token_index)
|
||||
.first_bank();
|
||||
let buy_token_price = account_fetcher.fetch_bank_price(&buy_bank_pk)?;
|
||||
let sell_token_price = account_fetcher.fetch_bank_price(&sell_bank_pk)?;
|
||||
|
||||
let (max_buy, max_sell) =
|
||||
match tcs_max_liqee_execution(account, mango_client, account_fetcher, tcs)? {
|
||||
Some(v) => v,
|
||||
None => return Ok(None),
|
||||
};
|
||||
|
||||
let max_quote =
|
||||
(I80F48::from(max_buy) * buy_token_price).min(I80F48::from(max_sell) * sell_token_price);
|
||||
|
||||
Ok(Some(max_quote.floor().clamp_to_u64()))
|
||||
}
|
||||
|
||||
/// Compute the max viable swap for liqee
|
||||
/// This includes
|
||||
/// - tcs restrictions (remaining buy/sell, create borrows/deposits)
|
||||
/// - reduce only banks
|
||||
/// - net borrow limits on BOTH sides, even though the buy side is technically
|
||||
/// a liqor limitation: the liqor could acquire the token before trying the
|
||||
/// execution... but in practice the liqor will work on margin
|
||||
///
|
||||
/// Returns Some((native buy amount, native sell amount)) if execution is sensible
|
||||
/// Returns None if the execution should be skipped (due to net borrow limits...)
|
||||
fn tcs_max_liqee_execution(
|
||||
account: &MangoAccountValue,
|
||||
mango_client: &MangoClient,
|
||||
account_fetcher: &chain_data::AccountFetcher,
|
||||
tcs: &TokenConditionalSwap,
|
||||
) -> anyhow::Result<Option<(u64, u64)>> {
|
||||
let buy_bank_pk = mango_client
|
||||
.context
|
||||
.mint_info(tcs.buy_token_index)
|
||||
|
@ -347,6 +377,10 @@ fn tcs_max_volume(
|
|||
let buy_token_price = account_fetcher.fetch_bank_price(&buy_bank_pk)?;
|
||||
let sell_token_price = account_fetcher.fetch_bank_price(&sell_bank_pk)?;
|
||||
|
||||
let base_price = buy_token_price / sell_token_price;
|
||||
let premium_price = tcs.premium_price(base_price.to_num());
|
||||
let maker_price = tcs.maker_price(premium_price);
|
||||
|
||||
let buy_position = account
|
||||
.token_position(tcs.buy_token_index)
|
||||
.map(|p| p.native(&buy_bank))
|
||||
|
@ -356,31 +390,69 @@ fn tcs_max_volume(
|
|||
.map(|p| p.native(&sell_bank))
|
||||
.unwrap_or(I80F48::ZERO);
|
||||
|
||||
let base_price = buy_token_price / sell_token_price;
|
||||
let premium_price = tcs.premium_price(base_price.to_num());
|
||||
let maker_price = tcs.maker_price(premium_price);
|
||||
|
||||
let liqee_target_health_ratio = I80F48::from_num(TARGET_HEALTH_RATIO);
|
||||
|
||||
let max_sell = util::max_swap_source(
|
||||
// this is in "buy token received per sell token given" units
|
||||
let swap_price = I80F48::from_num((1.0 - SLIPPAGE_BUFFER) / maker_price);
|
||||
let max_sell_ignoring_net_borrows = util::max_swap_source_ignore_net_borrows(
|
||||
mango_client,
|
||||
account_fetcher,
|
||||
&account,
|
||||
tcs.sell_token_index,
|
||||
tcs.buy_token_index,
|
||||
I80F48::from_num(1.0 / maker_price),
|
||||
liqee_target_health_ratio,
|
||||
swap_price,
|
||||
I80F48::ZERO,
|
||||
)?
|
||||
.floor()
|
||||
.to_num::<u64>()
|
||||
.min(tcs.max_sell_for_position(sell_position, &sell_bank));
|
||||
|
||||
let max_buy = tcs.max_buy_for_position(buy_position, &buy_bank);
|
||||
let max_buy_ignoring_net_borrows = tcs.max_buy_for_position(buy_position, &buy_bank);
|
||||
|
||||
let max_quote =
|
||||
(I80F48::from(max_buy) * buy_token_price).min(I80F48::from(max_sell) * sell_token_price);
|
||||
// What follows is a complex manual handling of net borrow limits, for the following reason:
|
||||
// Usually, we _do_ want to execute tcs even for small amounts because that will close the
|
||||
// tcs order: either due to full execution or due to the health threshold being reached.
|
||||
//
|
||||
// However, when the net borrow limits are hit, we do _not_ want to close the tcs order
|
||||
// even though no further execution is possible at that time. Furthermore, we don't even
|
||||
// want to send a too-tiny tcs execution transaction, because there's a good chance we
|
||||
// would then be sending lot of those as oracle prices fluctuate.
|
||||
//
|
||||
// Thus, we need to detect if the possible execution amount is tiny _because_ of the
|
||||
// net borrow limits. Then skip. If it's tiny for other reasons we can proceed.
|
||||
|
||||
Ok(max_quote.floor().clamp_to_u64())
|
||||
fn available_borrows(bank: &Bank, price: I80F48) -> u64 {
|
||||
if bank.net_borrow_limit_per_window_quote < 0 {
|
||||
u64::MAX
|
||||
} else {
|
||||
let limit = (I80F48::from(bank.net_borrow_limit_per_window_quote) / price)
|
||||
.floor()
|
||||
.clamp_to_i64();
|
||||
(limit - bank.net_borrows_in_window).max(0) as u64
|
||||
}
|
||||
}
|
||||
let available_buy_borrows = available_borrows(&buy_bank, buy_token_price);
|
||||
let available_sell_borrows = available_borrows(&sell_bank, sell_token_price);
|
||||
|
||||
// This technically depends on the liqor's buy token position, but we
|
||||
// just assume it'll be fully margined here
|
||||
let max_buy = max_buy_ignoring_net_borrows.min(available_buy_borrows);
|
||||
|
||||
let sell_borrows = (I80F48::from(max_sell_ignoring_net_borrows)
|
||||
- sell_position.max(I80F48::ZERO))
|
||||
.clamp_to_u64();
|
||||
let max_sell =
|
||||
max_sell_ignoring_net_borrows - sell_borrows + sell_borrows.min(available_sell_borrows);
|
||||
|
||||
let tiny_due_to_net_borrows = {
|
||||
let buy_threshold = I80F48::from(NET_BORROW_EXECUTION_THRESHOLD) / buy_token_price;
|
||||
let sell_threshold = I80F48::from(NET_BORROW_EXECUTION_THRESHOLD) / sell_token_price;
|
||||
max_buy < buy_threshold && max_buy_ignoring_net_borrows > buy_threshold
|
||||
|| max_sell < sell_threshold && max_sell_ignoring_net_borrows > sell_threshold
|
||||
};
|
||||
if tiny_due_to_net_borrows {
|
||||
return Ok(None);
|
||||
}
|
||||
|
||||
Ok(Some((max_buy, max_sell)))
|
||||
}
|
||||
|
||||
pub fn find_interesting_tcs_for_account(
|
||||
|
@ -401,8 +473,12 @@ pub fn find_interesting_tcs_for_account(
|
|||
now_ts,
|
||||
) {
|
||||
Ok(true) => {
|
||||
let volume_result = tcs_max_volume(&liqee, mango_client, account_fetcher, tcs);
|
||||
Some(volume_result.map(|v| (*pubkey, tcs.id, v)))
|
||||
// Filter out Ok(None) resuts of tcs that shouldn't be executed right now
|
||||
match tcs_max_volume(&liqee, mango_client, account_fetcher, tcs) {
|
||||
Ok(Some(v)) => Some(Ok((*pubkey, tcs.id, v))),
|
||||
Ok(None) => None,
|
||||
Err(e) => Some(Err(e)),
|
||||
}
|
||||
}
|
||||
Ok(false) => None,
|
||||
Err(e) => Some(Err(e)),
|
||||
|
|
|
@ -144,3 +144,46 @@ pub fn max_swap_source(
|
|||
.context("getting max_swap_source")?;
|
||||
Ok(amount)
|
||||
}
|
||||
|
||||
/// Convenience wrapper for getting max swap amounts for a token pair
|
||||
pub fn max_swap_source_ignore_net_borrows(
|
||||
client: &MangoClient,
|
||||
account_fetcher: &chain_data::AccountFetcher,
|
||||
account: &MangoAccountValue,
|
||||
source: TokenIndex,
|
||||
target: TokenIndex,
|
||||
price: I80F48,
|
||||
min_health_ratio: I80F48,
|
||||
) -> anyhow::Result<I80F48> {
|
||||
let mut account = account.clone();
|
||||
|
||||
// Ensure the tokens are activated, so they appear in the health cache and
|
||||
// max_swap_source() will work.
|
||||
account.ensure_token_position(source)?;
|
||||
account.ensure_token_position(target)?;
|
||||
|
||||
let health_cache =
|
||||
mango_v4_client::health_cache::new_sync(&client.context, account_fetcher, &account)
|
||||
.expect("always ok");
|
||||
|
||||
let mut source_bank: Bank =
|
||||
account_fetcher.fetch(&client.context.mint_info(source).first_bank())?;
|
||||
source_bank.net_borrow_limit_per_window_quote = -1;
|
||||
let mut target_bank: Bank =
|
||||
account_fetcher.fetch(&client.context.mint_info(target).first_bank())?;
|
||||
target_bank.net_borrow_limit_per_window_quote = -1;
|
||||
|
||||
let source_price = health_cache.token_info(source).unwrap().prices.oracle;
|
||||
|
||||
let amount = health_cache
|
||||
.max_swap_source_for_health_ratio(
|
||||
&account,
|
||||
&source_bank,
|
||||
source_price,
|
||||
&target_bank,
|
||||
price,
|
||||
min_health_ratio,
|
||||
)
|
||||
.context("getting max_swap_source")?;
|
||||
Ok(amount)
|
||||
}
|
||||
|
|
|
@ -12,6 +12,12 @@ use crate::logs::{
|
|||
};
|
||||
use crate::state::*;
|
||||
|
||||
/// If init health is reduced below this number, the tcs is considered done.
|
||||
///
|
||||
/// This avoids a situation where the same tcs can be triggered again and again
|
||||
/// for small amounts every time the init health increases by small amounts.
|
||||
const TCS_TRIGGER_INIT_HEALTH_THRESHOLD: u64 = 1_000_000;
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub fn token_conditional_swap_trigger(
|
||||
ctx: Context<TokenConditionalSwapTrigger>,
|
||||
|
@ -76,8 +82,6 @@ pub fn token_conditional_swap_trigger(
|
|||
return Ok(());
|
||||
}
|
||||
|
||||
let liqee_pre_init_health = liqee.check_health_pre(&liqee_health_cache)?;
|
||||
|
||||
let (liqee_buy_change, liqee_sell_change) = action(
|
||||
&mut liqor.borrow_mut(),
|
||||
liqor_key,
|
||||
|
@ -94,8 +98,7 @@ pub fn token_conditional_swap_trigger(
|
|||
now_ts,
|
||||
)?;
|
||||
|
||||
// Check liqee and liqor health
|
||||
liqee.check_health_post(&liqee_health_cache, liqee_pre_init_health)?;
|
||||
// Check liqor health, liqee health is checked inside (has to be, since tcs closure depends on it)
|
||||
let liqor_health = compute_health(&liqor.borrow(), HealthType::Init, &account_retriever)
|
||||
.context("compute liqor health")?;
|
||||
require!(liqor_health >= 0, MangoError::HealthMustBePositive);
|
||||
|
@ -187,6 +190,8 @@ fn action(
|
|||
max_sell_token_to_liqor: u64,
|
||||
now_ts: u64,
|
||||
) -> Result<(I80F48, I80F48)> {
|
||||
let liqee_pre_init_health = liqee.check_health_pre(&liqee_health_cache)?;
|
||||
|
||||
let tcs = liqee
|
||||
.token_conditional_swap_by_index(token_conditional_swap_index)?
|
||||
.clone();
|
||||
|
@ -275,10 +280,14 @@ fn action(
|
|||
|
||||
sell_bank.collected_fees_native += I80F48::from(maker_fee + taker_fee);
|
||||
|
||||
// No need to check net borrow limits on buy_bank or sell_bank, because this mostly transfers
|
||||
// tokens between two accounts. For the sell token, the withdraw is higher than the deposit
|
||||
// due to fees, so net borrows can technically increase a bit: but the difference gets "deposited"
|
||||
// into collected_fees_native.
|
||||
// Check net borrows on both banks.
|
||||
//
|
||||
// While tcs triggering doesn't cause actual tokens to leave the platform, it can increase the amount
|
||||
// of borrows. For instance, if someone with USDC has a tcs to buy SOL and sell BTC, execution would
|
||||
// create BTC borrows (unless the executor had BTC borrows that get repaid by the execution, but
|
||||
// most executors will work on margin)
|
||||
buy_bank.check_net_borrows(buy_token_price)?;
|
||||
sell_bank.check_net_borrows(sell_token_price)?;
|
||||
|
||||
let post_liqee_sell_token = liqee_sell_token.native(&sell_bank);
|
||||
let post_liqor_sell_token = liqor_sell_token.native(&sell_bank);
|
||||
|
@ -368,6 +377,9 @@ fn action(
|
|||
liqee_health_cache.adjust_token_balance(&buy_bank, liqee_buy_change)?;
|
||||
liqee_health_cache.adjust_token_balance(&sell_bank, liqee_sell_change)?;
|
||||
|
||||
let liqee_post_init_health =
|
||||
liqee.check_health_post(&liqee_health_cache, liqee_pre_init_health)?;
|
||||
|
||||
// update tcs information on the account
|
||||
let closed = {
|
||||
// record amount
|
||||
|
@ -399,14 +411,9 @@ fn action(
|
|||
sell_bank,
|
||||
);
|
||||
|
||||
// The health check depends on the account's health _ratio_ because it needs to work
|
||||
// with liquidators trying to trigger tcs maximally: they can't bring the health exactly
|
||||
// to 0 or even very close to it, because oracles will change before the transaction
|
||||
// is executed. So instead, they will target a certain health ratio.
|
||||
// This says, that as long as they bring the account's health ratio below 1%, we will
|
||||
// consider the tcs as fully executed.
|
||||
let liqee_health_is_low =
|
||||
liqee_health_cache.health_ratio(HealthType::Init) < I80F48::from(1);
|
||||
// If the health is low enough, close the trigger. Otherwise it'd trigger repeatedly
|
||||
// as oracle prices fluctuate.
|
||||
let liqee_health_is_low = liqee_post_init_health < TCS_TRIGGER_INIT_HEALTH_THRESHOLD;
|
||||
|
||||
if future_buy == 0 || future_sell == 0 || liqee_health_is_low {
|
||||
*tcs = TokenConditionalSwap::default();
|
||||
|
@ -758,12 +765,14 @@ mod tests {
|
|||
fn test_token_conditional_swap_trigger() {
|
||||
let mut setup = TestSetup::new();
|
||||
|
||||
let asset_pos = 100_000_000;
|
||||
|
||||
setup
|
||||
.asset_bank
|
||||
.data()
|
||||
.deposit(
|
||||
&mut setup.liqee.token_position_mut(0).unwrap().0,
|
||||
I80F48::from(1000),
|
||||
I80F48::from(asset_pos),
|
||||
0,
|
||||
)
|
||||
.unwrap();
|
||||
|
@ -801,7 +810,7 @@ mod tests {
|
|||
assert_eq!(tcs.sold, 88);
|
||||
|
||||
assert_eq!(setup.liqee_liab_pos().round(), 40);
|
||||
assert_eq!(setup.liqee_asset_pos().round(), 1000 - 88);
|
||||
assert_eq!(setup.liqee_asset_pos().round(), asset_pos - 88);
|
||||
assert_eq!(setup.liqor_liab_pos().round(), -40);
|
||||
assert_eq!(setup.liqor_asset_pos().round(), 88);
|
||||
|
||||
|
@ -812,7 +821,7 @@ mod tests {
|
|||
assert_eq!(setup.liqee.active_token_conditional_swaps().count(), 0);
|
||||
|
||||
assert_eq!(setup.liqee_liab_pos().round(), 45);
|
||||
assert_eq!(setup.liqee_asset_pos().round(), 1000 - 99);
|
||||
assert_eq!(setup.liqee_asset_pos().round(), asset_pos - 99);
|
||||
assert_eq!(setup.liqor_liab_pos().round(), -45);
|
||||
assert_eq!(setup.liqor_asset_pos().round(), 99);
|
||||
}
|
||||
|
@ -826,14 +835,14 @@ mod tests {
|
|||
.data()
|
||||
.deposit(
|
||||
&mut setup.liqee.token_position_mut(0).unwrap().0,
|
||||
I80F48::from(100),
|
||||
I80F48::from(100_000_000),
|
||||
0,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let tcs = TokenConditionalSwap {
|
||||
max_buy: 10000,
|
||||
max_sell: 10000,
|
||||
max_buy: 10_000_000_000,
|
||||
max_sell: 10_000_000_000,
|
||||
price_lower_limit: 1.0,
|
||||
price_upper_limit: 3.0,
|
||||
price_premium_rate: 0.0,
|
||||
|
@ -845,21 +854,40 @@ mod tests {
|
|||
..Default::default()
|
||||
};
|
||||
*setup.liqee.free_token_conditional_swap_mut().unwrap() = tcs.clone();
|
||||
let (buy_change, sell_change) = setup.trigger(2.0, 1000, 1.0, 1000).unwrap();
|
||||
assert_eq!(buy_change.round(), 500);
|
||||
assert_eq!(sell_change.round(), -1000);
|
||||
|
||||
// Overall health went negative, causing the tcs to close (even though max_buy/max_sell aren't reached)
|
||||
let (buy_change, sell_change) = setup.trigger(2.0, 50_000_000, 1.0, 50_000_000).unwrap();
|
||||
assert_eq!(buy_change.round(), 25_000_000);
|
||||
assert_eq!(sell_change.round(), -50_000_000);
|
||||
|
||||
// Not closed yet, health still good
|
||||
assert_eq!(setup.liqee.active_token_conditional_swaps().count(), 1);
|
||||
|
||||
let (buy_change, sell_change) = setup.trigger(2.0, 150_000_000, 1.0, 150_000_000).unwrap();
|
||||
assert_eq!(buy_change.round(), 75_000_000);
|
||||
assert_eq!(sell_change.round(), -150_000_000);
|
||||
|
||||
// Health is 0
|
||||
assert_eq!(setup.liqee.active_token_conditional_swaps().count(), 0);
|
||||
|
||||
assert_eq!(setup.liqee_liab_pos().round(), 500);
|
||||
assert_eq!(setup.liqee_asset_pos().round(), -900);
|
||||
assert_eq!(setup.liqee_liab_pos().round(), 100_000_000);
|
||||
assert_eq!(setup.liqee_asset_pos().round(), -100_000_000);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_token_conditional_swap_trigger_fees() {
|
||||
let mut setup = TestSetup::new();
|
||||
|
||||
let asset_pos = 100_000_000;
|
||||
setup
|
||||
.asset_bank
|
||||
.data()
|
||||
.deposit(
|
||||
&mut setup.liqee.token_position_mut(0).unwrap().0,
|
||||
I80F48::from(asset_pos),
|
||||
0,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let tcs = TokenConditionalSwap {
|
||||
max_buy: 1000,
|
||||
max_sell: 1000,
|
||||
|
@ -883,7 +911,7 @@ mod tests {
|
|||
assert_eq!(sell_change.round(), -1000);
|
||||
|
||||
assert_eq!(setup.liqee_liab_pos().round(), 952);
|
||||
assert_eq!(setup.liqee_asset_pos().round(), -1000);
|
||||
assert_eq!(setup.liqee_asset_pos().round(), asset_pos - 1000);
|
||||
assert_eq!(setup.liqor_liab_pos().round(), -952);
|
||||
assert_eq!(setup.liqor_asset_pos().round(), 923); // floor(952*1.02*0.95)
|
||||
|
||||
|
|
|
@ -27,7 +27,7 @@ async fn test_token_conditional_swap() -> Result<(), TransportError> {
|
|||
let quote_token = &tokens[0];
|
||||
let base_token = &tokens[1];
|
||||
|
||||
let deposit_amount = 1000;
|
||||
let deposit_amount = 1_000_000_000f64;
|
||||
let account = create_funded_account(
|
||||
&solana,
|
||||
group,
|
||||
|
@ -35,7 +35,7 @@ async fn test_token_conditional_swap() -> Result<(), TransportError> {
|
|||
0,
|
||||
&context.users[1],
|
||||
mints,
|
||||
deposit_amount,
|
||||
deposit_amount as u64,
|
||||
0,
|
||||
)
|
||||
.await;
|
||||
|
@ -46,7 +46,7 @@ async fn test_token_conditional_swap() -> Result<(), TransportError> {
|
|||
1,
|
||||
&context.users[1],
|
||||
mints,
|
||||
deposit_amount,
|
||||
deposit_amount as u64,
|
||||
0,
|
||||
)
|
||||
.await;
|
||||
|
@ -113,7 +113,7 @@ async fn test_token_conditional_swap() -> Result<(), TransportError> {
|
|||
assert_eq!(account_data.header.token_conditional_swap_count, 2);
|
||||
|
||||
//
|
||||
// TEST: Can create tsls until all slots are filled
|
||||
// TEST: Can create tcs until all slots are filled
|
||||
//
|
||||
let tcs_ix = TokenConditionalSwapCreateInstruction {
|
||||
account,
|
||||
|
@ -246,15 +246,15 @@ async fn test_token_conditional_swap() -> Result<(), TransportError> {
|
|||
let liqee_base = account_position_f64(solana, account, base_token.bank).await;
|
||||
assert!(assert_equal_f_f(
|
||||
liqee_quote,
|
||||
1000.0 + 42.0, // roughly 50 / (1.1 * 1.1)
|
||||
deposit_amount + 42.0, // roughly 50 / (1.1 * 1.1)
|
||||
0.01
|
||||
));
|
||||
assert!(assert_equal_f_f(liqee_base, 1000.0 - 50.0, 0.01));
|
||||
assert!(assert_equal_f_f(liqee_base, deposit_amount - 50.0, 0.01));
|
||||
|
||||
let liqor_quote = account_position_f64(solana, liqor, quote_token.bank).await;
|
||||
let liqor_base = account_position_f64(solana, liqor, base_token.bank).await;
|
||||
assert!(assert_equal_f_f(liqor_quote, 1000.0 - 42.0, 0.01));
|
||||
assert!(assert_equal_f_f(liqor_base, 1000.0 + 44.0, 0.01)); // roughly 42*1.1*0.95
|
||||
assert!(assert_equal_f_f(liqor_quote, deposit_amount - 42.0, 0.01));
|
||||
assert!(assert_equal_f_f(liqor_base, deposit_amount + 44.0, 0.01)); // roughly 42*1.1*0.95
|
||||
|
||||
//
|
||||
// TEST: trigger fully
|
||||
|
@ -275,13 +275,13 @@ async fn test_token_conditional_swap() -> Result<(), TransportError> {
|
|||
|
||||
let liqee_quote = account_position_f64(solana, account, quote_token.bank).await;
|
||||
let liqee_base = account_position_f64(solana, account, base_token.bank).await;
|
||||
assert!(assert_equal_f_f(liqee_quote, 1000.0 + 84.0, 0.01));
|
||||
assert!(assert_equal_f_f(liqee_base, 1000.0 - 100.0, 0.01));
|
||||
assert!(assert_equal_f_f(liqee_quote, deposit_amount + 84.0, 0.01));
|
||||
assert!(assert_equal_f_f(liqee_base, deposit_amount - 100.0, 0.01));
|
||||
|
||||
let liqor_quote = account_position_f64(solana, liqor, quote_token.bank).await;
|
||||
let liqor_base = account_position_f64(solana, liqor, base_token.bank).await;
|
||||
assert!(assert_equal_f_f(liqor_quote, 1000.0 - 84.0, 0.01));
|
||||
assert!(assert_equal_f_f(liqor_base, 1000.0 + 88.0, 0.01));
|
||||
assert!(assert_equal_f_f(liqor_quote, deposit_amount - 84.0, 0.01));
|
||||
assert!(assert_equal_f_f(liqor_base, deposit_amount + 88.0, 0.01));
|
||||
|
||||
let account_data = get_mango_account(solana, account).await;
|
||||
assert!(!account_data
|
||||
|
|
|
@ -1,5 +1,6 @@
|
|||
import { AnchorProvider, BN, Wallet } from '@coral-xyz/anchor';
|
||||
import { Cluster, Connection, Keypair, PublicKey } from '@solana/web3.js';
|
||||
import * as splToken from '@solana/spl-token';
|
||||
import fs from 'fs';
|
||||
import { Bank } from '../../src/accounts/bank';
|
||||
import {
|
||||
|
@ -11,6 +12,7 @@ import { PerpMarket } from '../../src/accounts/perp';
|
|||
import { Builder } from '../../src/builder';
|
||||
import { MangoClient } from '../../src/client';
|
||||
import {
|
||||
DefaultTokenRegisterParams,
|
||||
NullPerpEditParams,
|
||||
NullTokenEditParams,
|
||||
} from '../../src/clientIxParamBuilder';
|
||||
|
@ -33,7 +35,7 @@ const PRICES = {
|
|||
|
||||
const TOKEN_SCENARIOS: [string, [string, number][], [string, number][]][] = [
|
||||
[
|
||||
'LIQTEST, FUNDING',
|
||||
'TCS, FUNDING',
|
||||
[
|
||||
['USDC', 5000000],
|
||||
['ETH', 100000],
|
||||
|
@ -41,10 +43,11 @@ const TOKEN_SCENARIOS: [string, [string, number][], [string, number][]][] = [
|
|||
],
|
||||
[],
|
||||
],
|
||||
['LIQTEST, LIQOR', [['USDC', 1000000]], []],
|
||||
['LIQTEST, LIQEE1', [['USDC', 1000]], []],
|
||||
['LIQTEST, LIQEE2', [['USDC', 1000000]], []],
|
||||
['LIQTEST, LIQEE3', [['USDC', 1000000]], []],
|
||||
['TCS, LIQOR', [['USDC', 1000000]], []],
|
||||
['TCS, HEALTH', [['USDC', 1000]], []],
|
||||
['TCS, FULL+CLOSE', [['USDC', 1000000]], []],
|
||||
['TCS, EXPIRE', [['USDC', 1000000]], []],
|
||||
['TCS, NET-BORROW', [['USDC', 10000000]], []],
|
||||
];
|
||||
|
||||
async function main() {
|
||||
|
@ -172,9 +175,8 @@ async function main() {
|
|||
// Case LIQEE1: The liqee does not have enough health for the tcs
|
||||
{
|
||||
const account = ensure(
|
||||
accounts2.find((account) => account.name == 'LIQTEST, LIQEE1'),
|
||||
accounts2.find((account) => account.name == 'TCS, HEALTH'),
|
||||
);
|
||||
await client.accountExpandV2(group, account, 4, 4, 4, 4, 4);
|
||||
await client.tokenConditionalSwapCreateRaw(
|
||||
group,
|
||||
account,
|
||||
|
@ -196,9 +198,8 @@ async function main() {
|
|||
// Case LIQEE2: Full execution - tcs closes afterward
|
||||
{
|
||||
const account = ensure(
|
||||
accounts2.find((account) => account.name == 'LIQTEST, LIQEE2'),
|
||||
accounts2.find((account) => account.name == 'TCS, FULL+CLOSE'),
|
||||
);
|
||||
await client.accountExpandV2(group, account, 4, 4, 4, 4, 4);
|
||||
await client.tokenConditionalSwapCreateRaw(
|
||||
group,
|
||||
account,
|
||||
|
@ -220,9 +221,8 @@ async function main() {
|
|||
// Case LIQEE3: Create a tcs that will expire very soon
|
||||
{
|
||||
const account = ensure(
|
||||
accounts2.find((account) => account.name == 'LIQTEST, LIQEE3'),
|
||||
accounts2.find((account) => account.name == 'TCS, EXPIRE'),
|
||||
);
|
||||
await client.accountExpandV2(group, account, 4, 4, 4, 4, 4);
|
||||
await client.tokenConditionalSwapCreateRaw(
|
||||
group,
|
||||
account,
|
||||
|
@ -241,6 +241,76 @@ async function main() {
|
|||
);
|
||||
}
|
||||
|
||||
// Case LIQEE4: Create a tcs that hits net borrow limits
|
||||
{
|
||||
const account = ensure(
|
||||
accounts2.find((account) => account.name == 'TCS, NET-BORROW'),
|
||||
);
|
||||
|
||||
// To do this, first make a new mint and register it as a new token with tight net borrow limits
|
||||
const newMint = await splToken.createMint(
|
||||
connection,
|
||||
admin,
|
||||
admin.publicKey,
|
||||
null,
|
||||
6,
|
||||
);
|
||||
const tokenAccount = await splToken.createAssociatedTokenAccountIdempotent(
|
||||
connection,
|
||||
admin,
|
||||
newMint,
|
||||
admin.publicKey,
|
||||
);
|
||||
await splToken.mintTo(
|
||||
connection,
|
||||
admin,
|
||||
newMint,
|
||||
tokenAccount,
|
||||
admin,
|
||||
1e15,
|
||||
);
|
||||
|
||||
await client.stubOracleCreate(group, newMint, 1.0);
|
||||
const newOracle = (await client.getStubOracle(group, newMint))[0];
|
||||
const newTokenIndex = Math.max(...group.banksMapByTokenIndex.keys()) + 1;
|
||||
await client.tokenRegister(
|
||||
group,
|
||||
newMint,
|
||||
newOracle.publicKey,
|
||||
newTokenIndex,
|
||||
'TMP',
|
||||
{
|
||||
...DefaultTokenRegisterParams,
|
||||
loanOriginationFeeRate: 0,
|
||||
loanFeeRate: 0,
|
||||
initAssetWeight: 1,
|
||||
maintAssetWeight: 1,
|
||||
initLiabWeight: 1,
|
||||
maintLiabWeight: 1,
|
||||
liquidationFee: 0,
|
||||
netBorrowLimitPerWindowQuote: 1500000, // less than the $2 of the tcs
|
||||
},
|
||||
);
|
||||
await group.reloadAll(client);
|
||||
|
||||
await client.tokenConditionalSwapCreateRaw(
|
||||
group,
|
||||
account,
|
||||
newMint,
|
||||
MINTS.get('USDC')!,
|
||||
new BN(2000000), // $2
|
||||
new BN(2000000),
|
||||
null,
|
||||
0.0,
|
||||
1000000.0,
|
||||
0.01,
|
||||
true,
|
||||
true,
|
||||
TokenConditionalSwapDisplayPriceStyle.buyTokenPerSellToken,
|
||||
TokenConditionalSwapIntention.unknown,
|
||||
);
|
||||
}
|
||||
|
||||
process.exit();
|
||||
}
|
||||
|
||||
|
|
Loading…
Reference in New Issue