liquidator: Deal with net-borrow restricted tcs executions
This commit is contained in:
parent
727f9a2400
commit
f1e2b521f2
|
@ -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,
|
||||
|
@ -154,27 +156,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 +183,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 +328,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 +381,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 +394,67 @@ 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).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 +475,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)
|
||||
}
|
||||
|
|
Loading…
Reference in New Issue