diff --git a/bin/liquidator/src/trigger_tcs.rs b/bin/liquidator/src/trigger_tcs.rs index 94167bc5ff..563663360e 100644 --- a/bin/liquidator/src/trigger_tcs.rs +++ b/bin/liquidator/src/trigger_tcs.rs @@ -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::() - .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::() - .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 { - // Compute the max viable swap (for liqor and liqee) and min it +) -> anyhow::Result> { + 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> { 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::() .min(tcs.max_sell_for_position(sell_position, &sell_bank)); - let max_buy = 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); + let max_buy_ignoring_net_borrows = tcs.max_buy_for_position(buy_position, &buy_bank); + + // 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. + + 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(max_quote.floor().clamp_to_u64()) + 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)), diff --git a/bin/liquidator/src/util.rs b/bin/liquidator/src/util.rs index ba846a9335..22c9a17ece 100644 --- a/bin/liquidator/src/util.rs +++ b/bin/liquidator/src/util.rs @@ -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 { + 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) +}