liquidator: execute tcs with volume-weighted randomness (#670)

This commit is contained in:
Christian Kamm 2023-08-11 12:08:34 +02:00 committed by GitHub
parent f462c62816
commit 1f55d549a6
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
8 changed files with 296 additions and 92 deletions

View File

@ -381,7 +381,6 @@ impl<'a> LiquidateHelper<'a> {
price,
self.liqor_min_health_ratio,
)
.await
}
async fn token_liq(&self) -> anyhow::Result<Option<Signature>> {

View File

@ -532,13 +532,13 @@ struct SharedState {
}
#[derive(Clone)]
struct AccountErrorState {
count: u64,
last_at: std::time::Instant,
pub struct AccountErrorState {
pub count: u64,
pub last_at: std::time::Instant,
}
#[derive(Default)]
struct ErrorTracking {
pub struct ErrorTracking {
accounts: HashMap<Pubkey, AccountErrorState>,
skip_threshold: u64,
skip_duration: std::time::Duration,
@ -678,18 +678,55 @@ impl LiquidationState {
&mut self,
accounts_iter: impl Iterator<Item = &'b Pubkey>,
) -> anyhow::Result<()> {
use rand::seq::SliceRandom;
let accounts = accounts_iter.collect::<Vec<&Pubkey>>();
let mut accounts = accounts_iter.collect::<Vec<&Pubkey>>();
{
let mut rng = rand::thread_rng();
accounts.shuffle(&mut rng);
let now = std::time::Instant::now();
let now_ts: u64 = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)?
.as_secs()
.try_into()?;
// Find interesting (pubkey, tcsid, volume)
let mut interesting_tcs = Vec::with_capacity(accounts.len());
for pubkey in accounts.iter() {
if let Ok(mut v) = trigger_tcs::find_interesting_tcs_for_account(
pubkey,
&self.mango_client,
&self.account_fetcher,
&self.token_swap_info,
&self.tcs_errors,
now,
now_ts,
) {
interesting_tcs.append(&mut v);
}
}
if interesting_tcs.is_empty() {
return Ok(());
}
// Repeatedly pick one randomly (volume-weighted) and try to execute
use rand::distributions::{Distribution, WeightedIndex};
let weights = interesting_tcs.iter().map(|(_, _, volume)| {
(*volume)
.min(self.trigger_tcs_config.max_trigger_quote_amount)
.max(1)
});
let mut dist = WeightedIndex::new(weights).unwrap();
let mut took_one = false;
for pubkey in accounts {
for i in 0..interesting_tcs.len() {
let (pubkey, tcs_id, _) = {
let mut rng = rand::thread_rng();
let sample = dist.sample(&mut rng);
if i != interesting_tcs.len() - 1 {
// Would error if we updated the last weight to 0
dist.update_weights(&[(sample, &0)])?;
}
&interesting_tcs[sample]
};
if self
.maybe_take_conditional_swap_and_log_error(pubkey)
.maybe_take_conditional_swap_and_log_error(pubkey, *tcs_id)
.await
.unwrap_or(false)
{
@ -697,6 +734,7 @@ impl LiquidationState {
break;
}
}
if !took_one {
return Ok(());
}
@ -710,6 +748,7 @@ impl LiquidationState {
async fn maybe_take_conditional_swap_and_log_error(
&mut self,
pubkey: &Pubkey,
tcs_id: u64,
) -> anyhow::Result<bool> {
let now = std::time::Instant::now();
let error_tracking = &mut self.tcs_errors;
@ -728,6 +767,7 @@ impl LiquidationState {
&self.account_fetcher,
&self.token_swap_info,
pubkey,
tcs_id,
&self.trigger_tcs_config,
)
.await;

View File

@ -1,13 +1,25 @@
use std::time::Duration;
use std::time::{Duration, Instant};
use mango_v4::state::{MangoAccountValue, TokenConditionalSwap};
use mango_v4_client::{chain_data, health_cache, JupiterSwapMode, MangoClient};
use itertools::Itertools;
use mango_v4::{
i80f48::ClampToInt,
state::{Bank, MangoAccountValue, TokenConditionalSwap},
};
use mango_v4_client::{chain_data, health_cache, JupiterSwapMode, MangoClient, MangoGroupContext};
use rand::seq::SliceRandom;
use tracing::*;
use {anyhow::Context, fixed::types::I80F48, solana_sdk::pubkey::Pubkey};
use crate::{token_swap_info, util};
use crate::{token_swap_info, util, ErrorTracking};
// 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;
pub struct Config {
pub min_health_ratio: f64,
@ -16,12 +28,15 @@ pub struct Config {
pub mock_jupiter: bool,
}
async fn tcs_is_in_price_range(
mango_client: &MangoClient,
fn tcs_is_in_price_range(
context: &MangoGroupContext,
account_fetcher: &chain_data::AccountFetcher,
tcs: &TokenConditionalSwap,
) -> anyhow::Result<bool> {
let buy_token_price = mango_client.bank_oracle_price(tcs.buy_token_index).await?;
let sell_token_price = mango_client.bank_oracle_price(tcs.sell_token_index).await?;
let buy_bank = context.mint_info(tcs.buy_token_index).first_bank();
let sell_bank = context.mint_info(tcs.sell_token_index).first_bank();
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);
@ -57,15 +72,16 @@ fn tcs_has_plausible_premium(
Ok(cost <= premium)
}
async fn tcs_is_interesting(
mango_client: &MangoClient,
fn tcs_is_interesting(
context: &MangoGroupContext,
account_fetcher: &chain_data::AccountFetcher,
tcs: &TokenConditionalSwap,
token_swap_info: &token_swap_info::TokenSwapInfoUpdater,
now_ts: u64,
) -> anyhow::Result<bool> {
Ok(!tcs.is_expired(now_ts)
&& tcs_is_in_price_range(mango_client, tcs).await?
&& tcs_has_plausible_premium(tcs, token_swap_info)?)
Ok(tcs.is_expired(now_ts)
|| (tcs_is_in_price_range(context, account_fetcher, tcs)?
&& tcs_has_plausible_premium(tcs, token_swap_info)?))
}
#[allow(clippy::too_many_arguments)]
@ -89,7 +105,15 @@ async fn maybe_execute_token_conditional_swap_inner(
// get a fresh account and re-check the tcs and health
let liqee = account_fetcher.fetch_fresh_mango_account(pubkey).await?;
let (_, tcs) = liqee.token_conditional_swap_by_id(tcs_id)?;
if !tcs_is_interesting(mango_client, tcs, token_swap_info, now_ts).await? {
if tcs.is_expired(now_ts)
|| !tcs_is_interesting(
&mango_client.context,
account_fetcher,
tcs,
token_swap_info,
now_ts,
)?
{
return Ok(false);
}
@ -116,8 +140,16 @@ async fn execute_token_conditional_swap(
let liqor_min_health_ratio = I80F48::from_num(config.min_health_ratio);
// Compute the max viable swap (for liqor and liqee) and min it
let buy_token_price = mango_client.bank_oracle_price(tcs.buy_token_index).await?;
let sell_token_price = mango_client.bank_oracle_price(tcs.sell_token_index).await?;
let buy_bank = mango_client
.context
.mint_info(tcs.buy_token_index)
.first_bank();
let sell_bank = mango_client
.context
.mint_info(tcs.sell_token_index)
.first_bank();
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;
let premium_price = tcs.premium_price(base_price.to_num());
@ -126,11 +158,7 @@ async fn execute_token_conditional_swap(
let max_take_quote = I80F48::from(config.max_trigger_quote_amount);
// 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!
let liqee_target_health_ratio = I80F48::from_num(0.5);
let liqee_target_health_ratio = I80F48::from_num(TARGET_HEALTH_RATIO);
let max_sell_token_to_liqor = util::max_swap_source(
mango_client,
@ -140,8 +168,7 @@ async fn execute_token_conditional_swap(
tcs.buy_token_index,
I80F48::ONE / maker_price,
liqee_target_health_ratio,
)
.await?
)?
.min(max_take_quote / sell_token_price)
.floor()
.to_num::<u64>()
@ -155,8 +182,7 @@ async fn execute_token_conditional_swap(
tcs.sell_token_index,
taker_price,
liqor_min_health_ratio,
)
.await?
)?
.min(max_take_quote / buy_token_price)
.floor()
.to_num::<u64>()
@ -265,6 +291,7 @@ pub async fn maybe_execute_token_conditional_swap(
account_fetcher: &chain_data::AccountFetcher,
token_swap_info: &token_swap_info::TokenSwapInfoUpdater,
pubkey: &Pubkey,
tcs_id: u64,
config: &Config,
) -> anyhow::Result<bool> {
let now_ts: u64 = std::time::SystemTime::now()
@ -272,35 +299,111 @@ pub async fn maybe_execute_token_conditional_swap(
.as_secs()
.try_into()?;
let liqee = account_fetcher.fetch_mango_account(pubkey)?;
let tcs = liqee.token_conditional_swap_by_id(tcs_id)?.1;
// Find an interesting triggerable conditional swap
let mut tcs_shuffled = liqee.active_token_conditional_swaps().collect::<Vec<&_>>();
{
let mut rng = rand::thread_rng();
tcs_shuffled.shuffle(&mut rng);
if tcs.is_expired(now_ts) {
remove_expired_token_conditional_swap(mango_client, pubkey, &liqee, tcs.id).await
} else {
maybe_execute_token_conditional_swap_inner(
mango_client,
account_fetcher,
token_swap_info,
pubkey,
&liqee,
tcs.id,
config,
now_ts,
)
.await
}
for tcs in tcs_shuffled.iter() {
if tcs_is_interesting(mango_client, tcs, token_swap_info, now_ts).await? {
return maybe_execute_token_conditional_swap_inner(
mango_client,
account_fetcher,
token_swap_info,
pubkey,
&liqee,
tcs.id,
config,
now_ts,
)
.await;
}
}
for tcs in tcs_shuffled {
if tcs.is_expired(now_ts) {
return remove_expired_token_conditional_swap(mango_client, pubkey, &liqee, tcs.id)
.await;
}
}
Ok(false)
}
/// Returns the maximum execution size of a tcs order in quote units
fn tcs_max_volume(
account: &MangoAccountValue,
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
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_bank: Bank = account_fetcher.fetch(&buy_bank_pk)?;
let sell_bank: Bank = account_fetcher.fetch(&sell_bank_pk)?;
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 buy_position = account
.token_position(tcs.buy_token_index)
.map(|p| p.native(&buy_bank))
.unwrap_or(I80F48::ZERO);
let sell_position = account
.token_position(tcs.sell_token_index)
.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(
mango_client,
account_fetcher,
&account,
tcs.sell_token_index,
tcs.buy_token_index,
I80F48::from_num(1.0 / maker_price),
liqee_target_health_ratio,
)?
.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_quote =
(I80F48::from(max_buy) * buy_token_price).min(I80F48::from(max_sell) * sell_token_price);
Ok(max_quote.floor().clamp_to_u64())
}
pub fn find_interesting_tcs_for_account(
pubkey: &Pubkey,
mango_client: &MangoClient,
account_fetcher: &chain_data::AccountFetcher,
token_swap_info: &token_swap_info::TokenSwapInfoUpdater,
error_tracking: &ErrorTracking,
now: Instant,
now_ts: u64,
) -> anyhow::Result<Vec<(Pubkey, u64, u64)>> {
if error_tracking.had_too_many_errors(pubkey, now).is_some() {
anyhow::bail!("too many errors");
}
let liqee = account_fetcher.fetch_mango_account(pubkey)?;
let interesting_tcs = liqee.active_token_conditional_swaps().filter_map(|tcs| {
match tcs_is_interesting(
&mango_client.context,
account_fetcher,
tcs,
token_swap_info,
now_ts,
) {
Ok(false) | Err(_) => None,
Ok(true) => {
let volume =
tcs_max_volume(&liqee, mango_client, account_fetcher, tcs).unwrap_or(1);
Some((*pubkey, tcs.id, volume))
}
}
});
Ok(interesting_tcs.collect_vec())
}

View File

@ -105,7 +105,7 @@ pub async fn jupiter_route(
}
/// Convenience wrapper for getting max swap amounts for a token pair
pub async fn max_swap_source(
pub fn max_swap_source(
client: &MangoClient,
account_fetcher: &chain_data::AccountFetcher,
account: &MangoAccountValue,
@ -122,12 +122,13 @@ pub async fn max_swap_source(
account.ensure_token_position(target)?;
let health_cache =
mango_v4_client::health_cache::new(&client.context, account_fetcher, &account)
.await
mango_v4_client::health_cache::new_sync(&client.context, account_fetcher, &account)
.expect("always ok");
let source_bank = client.first_bank(source).await?;
let target_bank = client.first_bank(target).await?;
let source_bank: Bank =
account_fetcher.fetch(&client.context.mint_info(source).first_bank())?;
let target_bank: Bank =
account_fetcher.fetch(&client.context.mint_info(target).first_bank())?;
let source_price = health_cache.token_info(source).unwrap().prices.oracle;

View File

@ -6,8 +6,9 @@ use crate::chain_data::*;
use anchor_lang::Discriminator;
use mango_v4::accounts_zerocopy::LoadZeroCopy;
use mango_v4::state::{MangoAccount, MangoAccountValue};
use fixed::types::I80F48;
use mango_v4::accounts_zerocopy::{KeyedAccountSharedData, LoadZeroCopy};
use mango_v4::state::{Bank, MangoAccount, MangoAccountValue};
use anyhow::Context;
@ -17,6 +18,13 @@ use solana_sdk::clock::Slot;
use solana_sdk::pubkey::Pubkey;
use solana_sdk::signature::Signature;
/// A complex account fetcher that mostly depends on an external job keeping
/// the chain_data up to date.
///
/// In addition to the usual async fetching interface, it also has synchronous
/// functions to access some kinds of data with less overhead.
///
/// Also, there's functions for fetching up to date data via rpc.
pub struct AccountFetcher {
pub chain_data: Arc<RwLock<ChainData>>,
pub rpc: RpcClientAsync,
@ -54,6 +62,13 @@ impl AccountFetcher {
.with_context(|| format!("loading mango account {}", address))
}
pub fn fetch_bank_price(&self, bank: &Pubkey) -> anyhow::Result<I80F48> {
let bank: Bank = self.fetch(bank)?;
let oracle = self.fetch_raw(&bank.oracle)?;
let price = bank.oracle_price(&KeyedAccountSharedData::new(bank.oracle, oracle), None)?;
Ok(price)
}
// fetches via RPC, stores in ChainData, returns new version
pub async fn fetch_fresh<T: anchor_lang::ZeroCopy + anchor_lang::Owner>(
&self,

View File

@ -35,3 +35,34 @@ pub async fn new(
};
mango_v4::health::new_health_cache(&account.borrow(), &retriever).context("make health cache")
}
pub fn new_sync(
context: &MangoGroupContext,
account_fetcher: &crate::chain_data::AccountFetcher,
account: &MangoAccountValue,
) -> anyhow::Result<HealthCache> {
let active_token_len = account.active_token_positions().count();
let active_perp_len = account.active_perp_positions().count();
let metas =
context.derive_health_check_remaining_account_metas(account, vec![], vec![], vec![])?;
let accounts = metas
.iter()
.map(|meta| {
Ok(KeyedAccountSharedData::new(
meta.pubkey,
account_fetcher.fetch_raw(&meta.pubkey)?,
))
})
.collect::<anyhow::Result<Vec<_>>>()?;
let retriever = FixedOrderAccountRetriever {
ais: accounts,
n_banks: active_token_len,
n_perps: active_perp_len,
begin_perp: active_token_len * 2,
begin_serum3: active_token_len * 2 + active_perp_len * 2,
staleness_slot: None,
};
mango_v4::health::new_health_cache(&account.borrow(), &retriever).context("make health cache")
}

View File

@ -123,15 +123,7 @@ fn trade_amount(
sell_bank: &Bank,
) -> (u64, u64) {
let max_buy = max_buy_token_to_liqee
.min(tcs.remaining_buy())
.min(
if tcs.allow_creating_deposits() && !buy_bank.are_deposits_reduce_only() {
u64::MAX
} else {
// ceil() because we're ok reaching 0..1 deposited native tokens
(-liqee_buy_balance).ceil().clamp_to_u64()
},
)
.min(tcs.max_buy_for_position(liqee_buy_balance, buy_bank))
.min(if buy_bank.are_borrows_reduce_only() {
// floor() so we never go below 0
liqor_buy_balance.floor().clamp_to_u64()
@ -139,15 +131,7 @@ fn trade_amount(
u64::MAX
});
let max_sell = max_sell_token_to_liqor
.min(tcs.remaining_sell())
.min(
if tcs.allow_creating_borrows() && !sell_bank.are_borrows_reduce_only() {
u64::MAX
} else {
// floor() so we never go below 0
liqee_sell_balance.floor().clamp_to_u64()
},
)
.min(tcs.max_sell_for_position(liqee_sell_balance, sell_bank))
.min(if sell_bank.are_deposits_reduce_only() {
// ceil() because we're ok reaching 0..1 deposited native tokens
(-liqor_sell_balance).ceil().clamp_to_u64()

View File

@ -6,6 +6,7 @@ use num_enum::{IntoPrimitive, TryFromPrimitive};
use static_assertions::const_assert_eq;
use std::mem::size_of;
use crate::i80f48::ClampToInt;
use crate::state::*;
#[derive(
@ -199,4 +200,34 @@ impl TokenConditionalSwap {
pub fn price_in_range(&self, price: f64) -> bool {
price >= self.price_lower_limit && price <= self.price_upper_limit
}
/// The remaining buy amount, taking the current buy token position and
/// buy bank's reduce-only status into account.
///
/// Note that the account health might further restrict execution.
pub fn max_buy_for_position(&self, buy_position: I80F48, buy_bank: &Bank) -> u64 {
self.remaining_buy().min(
if self.allow_creating_deposits() && !buy_bank.are_deposits_reduce_only() {
u64::MAX
} else {
// ceil() because we're ok reaching 0..1 deposited native tokens
(-buy_position).ceil().clamp_to_u64()
},
)
}
/// The remaining sell amount, taking the current sell token position and
/// sell bank's reduce-only status into account.
///
/// Note that the account health might further restrict execution.
pub fn max_sell_for_position(&self, sell_position: I80F48, sell_bank: &Bank) -> u64 {
self.remaining_sell().min(
if self.allow_creating_borrows() && !sell_bank.are_borrows_reduce_only() {
u64::MAX
} else {
// floor() so we never go below 0
sell_position.floor().clamp_to_u64()
},
)
}
}