liquidator: add allow/forbid token list (#883)
liquidator: add a way to restrict token accepted by the liquidator - add allow/forbid list of token for liquidation & conditional token swap triggering - add allow/forbid list for perp market liquidation - housekeeping: extract cli args to a dedicated file - move more hardcoded thing to config and stop using token name (replace with token index)
This commit is contained in:
parent
8a3a3bf70b
commit
338a9cb7b8
|
@ -0,0 +1,207 @@
|
||||||
|
use crate::trigger_tcs;
|
||||||
|
use anchor_lang::prelude::Pubkey;
|
||||||
|
use clap::Parser;
|
||||||
|
use mango_v4_client::{jupiter, priority_fees_cli};
|
||||||
|
use std::collections::HashSet;
|
||||||
|
|
||||||
|
#[derive(Parser, Debug)]
|
||||||
|
#[clap()]
|
||||||
|
pub(crate) struct CliDotenv {
|
||||||
|
// When --dotenv <file> is passed, read the specified dotenv file before parsing args
|
||||||
|
#[clap(long)]
|
||||||
|
pub(crate) dotenv: std::path::PathBuf,
|
||||||
|
|
||||||
|
pub(crate) remaining_args: Vec<std::ffi::OsString>,
|
||||||
|
}
|
||||||
|
|
||||||
|
// Prefer "--rebalance false" over "--no-rebalance" because it works
|
||||||
|
// better with REBALANCE=false env values.
|
||||||
|
#[derive(clap::ValueEnum, Clone, Copy, Debug, PartialEq, Eq)]
|
||||||
|
pub(crate) enum BoolArg {
|
||||||
|
True,
|
||||||
|
False,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(clap::ValueEnum, Clone, Copy, Debug, PartialEq, Eq)]
|
||||||
|
pub(crate) enum JupiterVersionArg {
|
||||||
|
Mock,
|
||||||
|
V6,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<JupiterVersionArg> for jupiter::Version {
|
||||||
|
fn from(a: JupiterVersionArg) -> Self {
|
||||||
|
match a {
|
||||||
|
JupiterVersionArg::Mock => jupiter::Version::Mock,
|
||||||
|
JupiterVersionArg::V6 => jupiter::Version::V6,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(clap::ValueEnum, Clone, Copy, Debug, PartialEq, Eq)]
|
||||||
|
pub(crate) enum TcsMode {
|
||||||
|
BorrowBuy,
|
||||||
|
SwapSellIntoBuy,
|
||||||
|
SwapCollateralIntoBuy,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<TcsMode> for trigger_tcs::Mode {
|
||||||
|
fn from(a: TcsMode) -> Self {
|
||||||
|
match a {
|
||||||
|
TcsMode::BorrowBuy => trigger_tcs::Mode::BorrowBuyToken,
|
||||||
|
TcsMode::SwapSellIntoBuy => trigger_tcs::Mode::SwapSellIntoBuy,
|
||||||
|
TcsMode::SwapCollateralIntoBuy => trigger_tcs::Mode::SwapCollateralIntoBuy,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn cli_to_hashset<T: Eq + std::hash::Hash + From<u16>>(
|
||||||
|
str_list: Option<Vec<u16>>,
|
||||||
|
) -> HashSet<T> {
|
||||||
|
return str_list
|
||||||
|
.map(|v| v.iter().map(|x| T::from(*x)).collect::<HashSet<T>>())
|
||||||
|
.unwrap_or_default();
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Parser)]
|
||||||
|
#[clap()]
|
||||||
|
pub struct Cli {
|
||||||
|
#[clap(short, long, env)]
|
||||||
|
pub(crate) rpc_url: String,
|
||||||
|
|
||||||
|
#[clap(long, env, value_delimiter = ';')]
|
||||||
|
pub(crate) override_send_transaction_url: Option<Vec<String>>,
|
||||||
|
|
||||||
|
#[clap(long, env)]
|
||||||
|
pub(crate) liqor_mango_account: Pubkey,
|
||||||
|
|
||||||
|
#[clap(long, env)]
|
||||||
|
pub(crate) liqor_owner: String,
|
||||||
|
|
||||||
|
#[clap(long, env, default_value = "1000")]
|
||||||
|
pub(crate) check_interval_ms: u64,
|
||||||
|
|
||||||
|
#[clap(long, env, default_value = "300")]
|
||||||
|
pub(crate) snapshot_interval_secs: u64,
|
||||||
|
|
||||||
|
// how often do we refresh token swap route/prices
|
||||||
|
#[clap(long, env, default_value = "30")]
|
||||||
|
pub(crate) token_swap_refresh_interval_secs: u64,
|
||||||
|
|
||||||
|
/// how many getMultipleAccounts requests to send in parallel
|
||||||
|
#[clap(long, env, default_value = "10")]
|
||||||
|
pub(crate) parallel_rpc_requests: usize,
|
||||||
|
|
||||||
|
/// typically 100 is the max number of accounts getMultipleAccounts will retrieve at once
|
||||||
|
#[clap(long, env, default_value = "100")]
|
||||||
|
pub(crate) get_multiple_accounts_count: usize,
|
||||||
|
|
||||||
|
/// liquidator health ratio should not fall below this value
|
||||||
|
#[clap(long, env, default_value = "50")]
|
||||||
|
pub(crate) min_health_ratio: f64,
|
||||||
|
|
||||||
|
/// if rebalancing is enabled
|
||||||
|
///
|
||||||
|
/// typically only disabled for tests where swaps are unavailable
|
||||||
|
#[clap(long, env, value_enum, default_value = "true")]
|
||||||
|
pub(crate) rebalance: BoolArg,
|
||||||
|
|
||||||
|
/// max slippage to request on swaps to rebalance spot tokens
|
||||||
|
#[clap(long, env, default_value = "100")]
|
||||||
|
pub(crate) rebalance_slippage_bps: u64,
|
||||||
|
|
||||||
|
/// tokens to not rebalance (in addition to USDC=0); use a comma separated list of token index
|
||||||
|
#[clap(long, env, value_parser, value_delimiter = ',')]
|
||||||
|
pub(crate) rebalance_skip_tokens: Option<Vec<u16>>,
|
||||||
|
|
||||||
|
/// When closing borrows, the rebalancer can't close token positions exactly.
|
||||||
|
/// Instead it purchases too much and then gets rid of the excess in a second step.
|
||||||
|
/// If this is 0.05, then it'll swap borrow_value * (1 + 0.05) quote token into borrow token.
|
||||||
|
#[clap(long, env, default_value = "0.05")]
|
||||||
|
pub(crate) rebalance_borrow_settle_excess: f64,
|
||||||
|
|
||||||
|
#[clap(long, env, default_value = "30")]
|
||||||
|
pub(crate) rebalance_refresh_timeout_secs: u64,
|
||||||
|
|
||||||
|
/// if taking tcs orders is enabled
|
||||||
|
///
|
||||||
|
/// typically only disabled for tests where swaps are unavailable
|
||||||
|
#[clap(long, env, value_enum, default_value = "true")]
|
||||||
|
pub(crate) take_tcs: BoolArg,
|
||||||
|
|
||||||
|
/// profit margin at which to take tcs orders
|
||||||
|
#[clap(long, env, default_value = "0.0005")]
|
||||||
|
pub(crate) tcs_profit_fraction: f64,
|
||||||
|
|
||||||
|
/// control how tcs triggering provides buy tokens
|
||||||
|
#[clap(long, env, value_enum, default_value = "swap-sell-into-buy")]
|
||||||
|
pub(crate) tcs_mode: TcsMode,
|
||||||
|
|
||||||
|
/// largest tcs amount to trigger in one transaction, in dollar
|
||||||
|
#[clap(long, env, default_value = "1000.0")]
|
||||||
|
pub(crate) tcs_max_trigger_amount: f64,
|
||||||
|
|
||||||
|
/// Minimum fraction of max_buy to buy for success when triggering,
|
||||||
|
/// useful in conjunction with jupiter swaps in same tx to avoid over-buying.
|
||||||
|
///
|
||||||
|
/// Can be set to 0 to allow executions of any size.
|
||||||
|
#[clap(long, env, default_value = "0.7")]
|
||||||
|
pub(crate) tcs_min_buy_fraction: f64,
|
||||||
|
|
||||||
|
#[clap(flatten)]
|
||||||
|
pub(crate) prioritization_fee_cli: priority_fees_cli::PriorityFeeArgs,
|
||||||
|
|
||||||
|
/// url to the lite-rpc websocket, optional
|
||||||
|
#[clap(long, env, default_value = "")]
|
||||||
|
pub(crate) lite_rpc_url: String,
|
||||||
|
|
||||||
|
/// compute limit requested for liquidation instructions
|
||||||
|
#[clap(long, env, default_value = "250000")]
|
||||||
|
pub(crate) compute_limit_for_liquidation: u32,
|
||||||
|
|
||||||
|
/// compute limit requested for tcs trigger instructions
|
||||||
|
#[clap(long, env, default_value = "300000")]
|
||||||
|
pub(crate) compute_limit_for_tcs: u32,
|
||||||
|
|
||||||
|
/// control which version of jupiter to use
|
||||||
|
#[clap(long, env, value_enum, default_value = "v6")]
|
||||||
|
pub(crate) jupiter_version: JupiterVersionArg,
|
||||||
|
|
||||||
|
/// override the url to jupiter v6
|
||||||
|
#[clap(long, env, default_value = "https://quote-api.jup.ag/v6")]
|
||||||
|
pub(crate) jupiter_v6_url: String,
|
||||||
|
|
||||||
|
/// provide a jupiter token, currently only for jup v6
|
||||||
|
#[clap(long, env, default_value = "")]
|
||||||
|
pub(crate) jupiter_token: String,
|
||||||
|
|
||||||
|
/// size of the swap to quote via jupiter to get slippage info, in dollar
|
||||||
|
/// should be larger than tcs_max_trigger_amount
|
||||||
|
#[clap(long, env, default_value = "1000.0")]
|
||||||
|
pub(crate) jupiter_swap_info_amount: f64,
|
||||||
|
|
||||||
|
/// report liquidator's existence and pubkey
|
||||||
|
#[clap(long, env, value_enum, default_value = "true")]
|
||||||
|
pub(crate) telemetry: BoolArg,
|
||||||
|
|
||||||
|
/// liquidation refresh timeout in secs
|
||||||
|
#[clap(long, env, default_value = "30")]
|
||||||
|
pub(crate) liquidation_refresh_timeout_secs: u8,
|
||||||
|
|
||||||
|
/// tokens to exclude for liquidation/tcs (never liquidate any pair where base or quote is in this list)
|
||||||
|
#[clap(long, env, value_parser, value_delimiter = ' ')]
|
||||||
|
pub(crate) forbidden_tokens: Option<Vec<u16>>,
|
||||||
|
|
||||||
|
/// tokens to allow for liquidation/tcs (only liquidate a pair if base or quote is in this list)
|
||||||
|
/// when empty, allows all pairs
|
||||||
|
#[clap(long, env, value_parser, value_delimiter = ' ')]
|
||||||
|
pub(crate) only_allow_tokens: Option<Vec<u16>>,
|
||||||
|
|
||||||
|
/// perp market to exclude for liquidation
|
||||||
|
#[clap(long, env, value_parser, value_delimiter = ' ')]
|
||||||
|
pub(crate) liquidation_forbidden_perp_markets: Option<Vec<u16>>,
|
||||||
|
|
||||||
|
/// perp market to allow for liquidation (only liquidate if is in this list)
|
||||||
|
/// when empty, allows all pairs
|
||||||
|
#[clap(long, env, value_parser, value_delimiter = ' ')]
|
||||||
|
pub(crate) liquidation_only_allow_perp_markets: Option<Vec<u16>>,
|
||||||
|
}
|
|
@ -1,3 +1,4 @@
|
||||||
|
use std::cmp::Reverse;
|
||||||
use std::collections::HashSet;
|
use std::collections::HashSet;
|
||||||
use std::time::Duration;
|
use std::time::Duration;
|
||||||
|
|
||||||
|
@ -20,6 +21,12 @@ pub struct Config {
|
||||||
pub refresh_timeout: Duration,
|
pub refresh_timeout: Duration,
|
||||||
pub compute_limit_for_liq_ix: u32,
|
pub compute_limit_for_liq_ix: u32,
|
||||||
|
|
||||||
|
pub only_allowed_tokens: HashSet<TokenIndex>,
|
||||||
|
pub forbidden_tokens: HashSet<TokenIndex>,
|
||||||
|
|
||||||
|
pub only_allowed_perp_markets: HashSet<PerpMarketIndex>,
|
||||||
|
pub forbidden_perp_markets: HashSet<PerpMarketIndex>,
|
||||||
|
|
||||||
/// If we cram multiple ix into a transaction, don't exceed this level
|
/// If we cram multiple ix into a transaction, don't exceed this level
|
||||||
/// of expected-cu.
|
/// of expected-cu.
|
||||||
pub max_cu_per_transaction: u32,
|
pub max_cu_per_transaction: u32,
|
||||||
|
@ -33,8 +40,6 @@ struct LiquidateHelper<'a> {
|
||||||
health_cache: &'a HealthCache,
|
health_cache: &'a HealthCache,
|
||||||
maint_health: I80F48,
|
maint_health: I80F48,
|
||||||
liqor_min_health_ratio: I80F48,
|
liqor_min_health_ratio: I80F48,
|
||||||
allowed_asset_tokens: HashSet<Pubkey>,
|
|
||||||
allowed_liab_tokens: HashSet<Pubkey>,
|
|
||||||
config: Config,
|
config: Config,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -136,6 +141,25 @@ impl<'a> LiquidateHelper<'a> {
|
||||||
let all_perp_base_positions: anyhow::Result<
|
let all_perp_base_positions: anyhow::Result<
|
||||||
Vec<Option<(PerpMarketIndex, i64, I80F48, I80F48)>>,
|
Vec<Option<(PerpMarketIndex, i64, I80F48, I80F48)>>,
|
||||||
> = stream::iter(self.liqee.active_perp_positions())
|
> = stream::iter(self.liqee.active_perp_positions())
|
||||||
|
.filter(|pp| async {
|
||||||
|
if self
|
||||||
|
.config
|
||||||
|
.forbidden_perp_markets
|
||||||
|
.contains(&pp.market_index)
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if !self.config.only_allowed_perp_markets.is_empty()
|
||||||
|
&& !self
|
||||||
|
.config
|
||||||
|
.only_allowed_perp_markets
|
||||||
|
.contains(&pp.market_index)
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
true
|
||||||
|
})
|
||||||
.then(|pp| async {
|
.then(|pp| async {
|
||||||
let base_lots = pp.base_position_lots();
|
let base_lots = pp.base_position_lots();
|
||||||
if (base_lots == 0 && pp.quote_position_native() <= 0) || pp.has_open_taker_fills()
|
if (base_lots == 0 && pp.quote_position_native() <= 0) || pp.has_open_taker_fills()
|
||||||
|
@ -353,6 +377,7 @@ impl<'a> LiquidateHelper<'a> {
|
||||||
.health_cache
|
.health_cache
|
||||||
.token_infos
|
.token_infos
|
||||||
.iter()
|
.iter()
|
||||||
|
.filter(|p| !self.config.forbidden_tokens.contains(&p.token_index))
|
||||||
.zip(
|
.zip(
|
||||||
self.health_cache
|
self.health_cache
|
||||||
.effective_token_balances(HealthType::LiquidationEnd)
|
.effective_token_balances(HealthType::LiquidationEnd)
|
||||||
|
@ -378,26 +403,9 @@ impl<'a> LiquidateHelper<'a> {
|
||||||
is_valid_asset.then_some((ti.token_index, is_preferred, quote_value))
|
is_valid_asset.then_some((ti.token_index, is_preferred, quote_value))
|
||||||
})
|
})
|
||||||
.collect_vec();
|
.collect_vec();
|
||||||
// sort such that preferred tokens are at the end, and the one with the larget quote value is
|
// sort such that preferred tokens are at the start, and the one with the larget quote value is
|
||||||
// at the very end
|
// at 0
|
||||||
potential_assets.sort_by_key(|(_, is_preferred, amount)| (*is_preferred, *amount));
|
potential_assets.sort_by_key(|(_, is_preferred, amount)| Reverse((*is_preferred, *amount)));
|
||||||
|
|
||||||
// filter only allowed assets
|
|
||||||
let potential_allowed_assets = potential_assets.iter().filter_map(|(ti, _, _)| {
|
|
||||||
let is_allowed = self
|
|
||||||
.allowed_asset_tokens
|
|
||||||
.contains(&self.client.context.token(*ti).mint);
|
|
||||||
is_allowed.then_some(*ti)
|
|
||||||
});
|
|
||||||
|
|
||||||
let asset_token_index = match potential_allowed_assets.last() {
|
|
||||||
Some(token_index) => token_index,
|
|
||||||
None => anyhow::bail!(
|
|
||||||
"mango account {}, has no allowed asset tokens that are liquidatable: {:?}",
|
|
||||||
self.pubkey,
|
|
||||||
potential_assets,
|
|
||||||
),
|
|
||||||
};
|
|
||||||
|
|
||||||
//
|
//
|
||||||
// find a good liab, same as for assets
|
// find a good liab, same as for assets
|
||||||
|
@ -410,29 +418,69 @@ impl<'a> LiquidateHelper<'a> {
|
||||||
let tokens = (-ti.balance_spot).min(-effective.spot_and_perp);
|
let tokens = (-ti.balance_spot).min(-effective.spot_and_perp);
|
||||||
let is_valid_liab = tokens > 0;
|
let is_valid_liab = tokens > 0;
|
||||||
let quote_value = tokens * ti.prices.oracle;
|
let quote_value = tokens * ti.prices.oracle;
|
||||||
is_valid_liab.then_some((ti.token_index, quote_value))
|
is_valid_liab.then_some((ti.token_index, false, quote_value))
|
||||||
})
|
})
|
||||||
.collect_vec();
|
.collect_vec();
|
||||||
// largest liquidatable liability at the end
|
// largest liquidatable liability at the start
|
||||||
potential_liabs.sort_by_key(|(_, amount)| *amount);
|
potential_liabs.sort_by_key(|(_, is_preferred, amount)| Reverse((*is_preferred, *amount)));
|
||||||
|
|
||||||
// filter only allowed liabs
|
//
|
||||||
let potential_allowed_liabs = potential_liabs.iter().filter_map(|(ti, _)| {
|
// Find a pair
|
||||||
let is_allowed = self
|
//
|
||||||
.allowed_liab_tokens
|
|
||||||
.contains(&self.client.context.token(*ti).mint);
|
|
||||||
is_allowed.then_some(*ti)
|
|
||||||
});
|
|
||||||
|
|
||||||
let liab_token_index = match potential_allowed_liabs.last() {
|
fn find_best_token(
|
||||||
Some(token_index) => token_index,
|
lh: &LiquidateHelper,
|
||||||
None => anyhow::bail!(
|
token_list: &Vec<(TokenIndex, bool, I80F48)>,
|
||||||
"mango account {}, has no liab tokens that are liquidatable: {:?}",
|
) -> (Option<TokenIndex>, Option<TokenIndex>) {
|
||||||
|
let mut best_whitelisted = None;
|
||||||
|
let mut best = None;
|
||||||
|
|
||||||
|
let allowed_token_list = token_list
|
||||||
|
.iter()
|
||||||
|
.filter_map(|(ti, _, _)| (!lh.config.forbidden_tokens.contains(ti)).then_some(ti));
|
||||||
|
|
||||||
|
for ti in allowed_token_list {
|
||||||
|
let whitelisted = lh.config.only_allowed_tokens.is_empty()
|
||||||
|
|| lh.config.only_allowed_tokens.contains(ti);
|
||||||
|
if best.is_none() {
|
||||||
|
best = Some(*ti);
|
||||||
|
}
|
||||||
|
|
||||||
|
if best_whitelisted.is_none() && whitelisted {
|
||||||
|
best_whitelisted = Some(*ti);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (best, best_whitelisted);
|
||||||
|
}
|
||||||
|
|
||||||
|
let (best_asset, best_whitelisted_asset) = find_best_token(self, &potential_assets);
|
||||||
|
let (best_liab, best_whitelisted_liab) = find_best_token(self, &potential_liabs);
|
||||||
|
|
||||||
|
let best_pair_opt = [
|
||||||
|
(best_whitelisted_asset, best_liab),
|
||||||
|
(best_asset, best_whitelisted_liab),
|
||||||
|
]
|
||||||
|
.iter()
|
||||||
|
.filter_map(|(a, l)| (a.is_some() && l.is_some()).then_some((a.unwrap(), l.unwrap())))
|
||||||
|
.next();
|
||||||
|
|
||||||
|
if best_pair_opt.is_none() {
|
||||||
|
anyhow::bail!(
|
||||||
|
"mango account {}, has no allowed asset/liab tokens pair that are liquidatable: assets={:?}; liabs={:?}",
|
||||||
self.pubkey,
|
self.pubkey,
|
||||||
|
potential_assets,
|
||||||
potential_liabs,
|
potential_liabs,
|
||||||
),
|
)
|
||||||
};
|
};
|
||||||
|
|
||||||
|
let (asset_token_index, liab_token_index) = best_pair_opt.unwrap();
|
||||||
|
|
||||||
|
//
|
||||||
|
// Compute max transfer size
|
||||||
|
//
|
||||||
|
|
||||||
let max_liab_transfer = self
|
let max_liab_transfer = self
|
||||||
.max_token_liab_transfer(liab_token_index, asset_token_index)
|
.max_token_liab_transfer(liab_token_index, asset_token_index)
|
||||||
.await
|
.await
|
||||||
|
@ -484,9 +532,7 @@ impl<'a> LiquidateHelper<'a> {
|
||||||
.iter()
|
.iter()
|
||||||
.find(|(liab_token_index, _liab_price, liab_usdc_equivalent)| {
|
.find(|(liab_token_index, _liab_price, liab_usdc_equivalent)| {
|
||||||
liab_usdc_equivalent.is_negative()
|
liab_usdc_equivalent.is_negative()
|
||||||
&& self
|
&& !self.config.forbidden_tokens.contains(liab_token_index)
|
||||||
.allowed_liab_tokens
|
|
||||||
.contains(&self.client.context.token(*liab_token_index).mint)
|
|
||||||
})
|
})
|
||||||
.ok_or_else(|| {
|
.ok_or_else(|| {
|
||||||
anyhow::anyhow!(
|
anyhow::anyhow!(
|
||||||
|
@ -643,8 +689,6 @@ pub async fn maybe_liquidate_account(
|
||||||
|
|
||||||
let maint_health = health_cache.health(HealthType::Maint);
|
let maint_health = health_cache.health(HealthType::Maint);
|
||||||
|
|
||||||
let all_token_mints = HashSet::from_iter(mango_client.context.tokens.values().map(|c| c.mint));
|
|
||||||
|
|
||||||
// try liquidating
|
// try liquidating
|
||||||
let maybe_txsig = LiquidateHelper {
|
let maybe_txsig = LiquidateHelper {
|
||||||
client: mango_client,
|
client: mango_client,
|
||||||
|
@ -654,8 +698,6 @@ pub async fn maybe_liquidate_account(
|
||||||
health_cache: &health_cache,
|
health_cache: &health_cache,
|
||||||
maint_health,
|
maint_health,
|
||||||
liqor_min_health_ratio,
|
liqor_min_health_ratio,
|
||||||
allowed_asset_tokens: all_token_mints.clone(),
|
|
||||||
allowed_liab_tokens: all_token_mints,
|
|
||||||
config: config.clone(),
|
config: config.clone(),
|
||||||
}
|
}
|
||||||
.send_liq_tx()
|
.send_liq_tx()
|
||||||
|
|
|
@ -7,10 +7,9 @@ use anchor_client::Cluster;
|
||||||
use anyhow::Context;
|
use anyhow::Context;
|
||||||
use clap::Parser;
|
use clap::Parser;
|
||||||
use mango_v4::state::{PerpMarketIndex, TokenIndex};
|
use mango_v4::state::{PerpMarketIndex, TokenIndex};
|
||||||
use mango_v4_client::priority_fees_cli;
|
|
||||||
use mango_v4_client::AsyncChannelSendUnlessFull;
|
use mango_v4_client::AsyncChannelSendUnlessFull;
|
||||||
use mango_v4_client::{
|
use mango_v4_client::{
|
||||||
account_update_stream, chain_data, error_tracking::ErrorTracking, jupiter, keypair_from_cli,
|
account_update_stream, chain_data, error_tracking::ErrorTracking, keypair_from_cli,
|
||||||
snapshot_source, websocket_source, Client, MangoClient, MangoClientError, MangoGroupContext,
|
snapshot_source, websocket_source, Client, MangoClient, MangoClientError, MangoGroupContext,
|
||||||
TransactionBuilderConfig,
|
TransactionBuilderConfig,
|
||||||
};
|
};
|
||||||
|
@ -21,6 +20,7 @@ use solana_sdk::pubkey::Pubkey;
|
||||||
use solana_sdk::signer::Signer;
|
use solana_sdk::signer::Signer;
|
||||||
use tracing::*;
|
use tracing::*;
|
||||||
|
|
||||||
|
pub mod cli_args;
|
||||||
pub mod liquidate;
|
pub mod liquidate;
|
||||||
pub mod metrics;
|
pub mod metrics;
|
||||||
pub mod rebalance;
|
pub mod rebalance;
|
||||||
|
@ -36,158 +36,6 @@ use crate::util::{is_mango_account, is_mint_info, is_perp_market};
|
||||||
#[global_allocator]
|
#[global_allocator]
|
||||||
static ALLOC: jemallocator::Jemalloc = jemallocator::Jemalloc;
|
static ALLOC: jemallocator::Jemalloc = jemallocator::Jemalloc;
|
||||||
|
|
||||||
#[derive(Parser, Debug)]
|
|
||||||
#[clap()]
|
|
||||||
struct CliDotenv {
|
|
||||||
// When --dotenv <file> is passed, read the specified dotenv file before parsing args
|
|
||||||
#[clap(long)]
|
|
||||||
dotenv: std::path::PathBuf,
|
|
||||||
|
|
||||||
remaining_args: Vec<std::ffi::OsString>,
|
|
||||||
}
|
|
||||||
|
|
||||||
// Prefer "--rebalance false" over "--no-rebalance" because it works
|
|
||||||
// better with REBALANCE=false env values.
|
|
||||||
#[derive(clap::ValueEnum, Clone, Copy, Debug, PartialEq, Eq)]
|
|
||||||
enum BoolArg {
|
|
||||||
True,
|
|
||||||
False,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(clap::ValueEnum, Clone, Copy, Debug, PartialEq, Eq)]
|
|
||||||
enum JupiterVersionArg {
|
|
||||||
Mock,
|
|
||||||
V6,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl From<JupiterVersionArg> for jupiter::Version {
|
|
||||||
fn from(a: JupiterVersionArg) -> Self {
|
|
||||||
match a {
|
|
||||||
JupiterVersionArg::Mock => jupiter::Version::Mock,
|
|
||||||
JupiterVersionArg::V6 => jupiter::Version::V6,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(clap::ValueEnum, Clone, Copy, Debug, PartialEq, Eq)]
|
|
||||||
enum TcsMode {
|
|
||||||
BorrowBuy,
|
|
||||||
SwapSellIntoBuy,
|
|
||||||
SwapCollateralIntoBuy,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl From<TcsMode> for trigger_tcs::Mode {
|
|
||||||
fn from(a: TcsMode) -> Self {
|
|
||||||
match a {
|
|
||||||
TcsMode::BorrowBuy => trigger_tcs::Mode::BorrowBuyToken,
|
|
||||||
TcsMode::SwapSellIntoBuy => trigger_tcs::Mode::SwapSellIntoBuy,
|
|
||||||
TcsMode::SwapCollateralIntoBuy => trigger_tcs::Mode::SwapCollateralIntoBuy,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Parser)]
|
|
||||||
#[clap()]
|
|
||||||
struct Cli {
|
|
||||||
#[clap(short, long, env)]
|
|
||||||
rpc_url: String,
|
|
||||||
|
|
||||||
#[clap(long, env, value_delimiter = ';')]
|
|
||||||
override_send_transaction_url: Option<Vec<String>>,
|
|
||||||
|
|
||||||
#[clap(long, env)]
|
|
||||||
liqor_mango_account: Pubkey,
|
|
||||||
|
|
||||||
#[clap(long, env)]
|
|
||||||
liqor_owner: String,
|
|
||||||
|
|
||||||
#[clap(long, env, default_value = "1000")]
|
|
||||||
check_interval_ms: u64,
|
|
||||||
|
|
||||||
#[clap(long, env, default_value = "300")]
|
|
||||||
snapshot_interval_secs: u64,
|
|
||||||
|
|
||||||
/// how many getMultipleAccounts requests to send in parallel
|
|
||||||
#[clap(long, env, default_value = "10")]
|
|
||||||
parallel_rpc_requests: usize,
|
|
||||||
|
|
||||||
/// typically 100 is the max number of accounts getMultipleAccounts will retrieve at once
|
|
||||||
#[clap(long, env, default_value = "100")]
|
|
||||||
get_multiple_accounts_count: usize,
|
|
||||||
|
|
||||||
/// liquidator health ratio should not fall below this value
|
|
||||||
#[clap(long, env, default_value = "50")]
|
|
||||||
min_health_ratio: f64,
|
|
||||||
|
|
||||||
/// if rebalancing is enabled
|
|
||||||
///
|
|
||||||
/// typically only disabled for tests where swaps are unavailable
|
|
||||||
#[clap(long, env, value_enum, default_value = "true")]
|
|
||||||
rebalance: BoolArg,
|
|
||||||
|
|
||||||
/// max slippage to request on swaps to rebalance spot tokens
|
|
||||||
#[clap(long, env, default_value = "100")]
|
|
||||||
rebalance_slippage_bps: u64,
|
|
||||||
|
|
||||||
/// tokens to not rebalance (in addition to USDC); use a comma separated list of names
|
|
||||||
#[clap(long, env, default_value = "")]
|
|
||||||
rebalance_skip_tokens: String,
|
|
||||||
|
|
||||||
/// if taking tcs orders is enabled
|
|
||||||
///
|
|
||||||
/// typically only disabled for tests where swaps are unavailable
|
|
||||||
#[clap(long, env, value_enum, default_value = "true")]
|
|
||||||
take_tcs: BoolArg,
|
|
||||||
|
|
||||||
/// profit margin at which to take tcs orders
|
|
||||||
#[clap(long, env, default_value = "0.0005")]
|
|
||||||
tcs_profit_fraction: f64,
|
|
||||||
|
|
||||||
/// control how tcs triggering provides buy tokens
|
|
||||||
#[clap(long, env, value_enum, default_value = "swap-sell-into-buy")]
|
|
||||||
tcs_mode: TcsMode,
|
|
||||||
|
|
||||||
/// largest tcs amount to trigger in one transaction, in dollar
|
|
||||||
#[clap(long, env, default_value = "1000.0")]
|
|
||||||
tcs_max_trigger_amount: f64,
|
|
||||||
|
|
||||||
#[clap(flatten)]
|
|
||||||
prioritization_fee_cli: priority_fees_cli::PriorityFeeArgs,
|
|
||||||
|
|
||||||
/// url to the lite-rpc websocket, optional
|
|
||||||
#[clap(long, env, default_value = "")]
|
|
||||||
lite_rpc_url: String,
|
|
||||||
|
|
||||||
/// compute limit requested for liquidation instructions
|
|
||||||
#[clap(long, env, default_value = "250000")]
|
|
||||||
compute_limit_for_liquidation: u32,
|
|
||||||
|
|
||||||
/// compute limit requested for tcs trigger instructions
|
|
||||||
#[clap(long, env, default_value = "300000")]
|
|
||||||
compute_limit_for_tcs: u32,
|
|
||||||
|
|
||||||
/// control which version of jupiter to use
|
|
||||||
#[clap(long, env, value_enum, default_value = "v6")]
|
|
||||||
jupiter_version: JupiterVersionArg,
|
|
||||||
|
|
||||||
/// override the url to jupiter v6
|
|
||||||
#[clap(long, env, default_value = "https://quote-api.jup.ag/v6")]
|
|
||||||
jupiter_v6_url: String,
|
|
||||||
|
|
||||||
/// provide a jupiter token, currently only for jup v6
|
|
||||||
#[clap(long, env, default_value = "")]
|
|
||||||
jupiter_token: String,
|
|
||||||
|
|
||||||
/// size of the swap to quote via jupiter to get slippage info, in dollar
|
|
||||||
/// should be larger than tcs_max_trigger_amount
|
|
||||||
#[clap(long, env, default_value = "1000.0")]
|
|
||||||
jupiter_swap_info_amount: f64,
|
|
||||||
|
|
||||||
/// report liquidator's existence and pubkey
|
|
||||||
#[clap(long, env, value_enum, default_value = "true")]
|
|
||||||
telemetry: BoolArg,
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn encode_address(addr: &Pubkey) -> String {
|
pub fn encode_address(addr: &Pubkey) -> String {
|
||||||
bs58::encode(&addr.to_bytes()).into_string()
|
bs58::encode(&addr.to_bytes()).into_string()
|
||||||
}
|
}
|
||||||
|
@ -356,8 +204,15 @@ async fn main() -> anyhow::Result<()> {
|
||||||
min_health_ratio: cli.min_health_ratio,
|
min_health_ratio: cli.min_health_ratio,
|
||||||
compute_limit_for_liq_ix: cli.compute_limit_for_liquidation,
|
compute_limit_for_liq_ix: cli.compute_limit_for_liquidation,
|
||||||
max_cu_per_transaction: 1_000_000,
|
max_cu_per_transaction: 1_000_000,
|
||||||
// TODO: config
|
refresh_timeout: Duration::from_secs(cli.liquidation_refresh_timeout_secs as u64),
|
||||||
refresh_timeout: Duration::from_secs(30),
|
only_allowed_tokens: cli_args::cli_to_hashset::<TokenIndex>(cli.only_allow_tokens),
|
||||||
|
forbidden_tokens: cli_args::cli_to_hashset::<TokenIndex>(cli.forbidden_tokens),
|
||||||
|
only_allowed_perp_markets: cli_args::cli_to_hashset::<PerpMarketIndex>(
|
||||||
|
cli.liquidation_only_allow_perp_markets,
|
||||||
|
),
|
||||||
|
forbidden_perp_markets: cli_args::cli_to_hashset::<PerpMarketIndex>(
|
||||||
|
cli.liquidation_forbidden_perp_markets,
|
||||||
|
),
|
||||||
};
|
};
|
||||||
|
|
||||||
let tcs_config = trigger_tcs::Config {
|
let tcs_config = trigger_tcs::Config {
|
||||||
|
@ -366,14 +221,15 @@ async fn main() -> anyhow::Result<()> {
|
||||||
compute_limit_for_trigger: cli.compute_limit_for_tcs,
|
compute_limit_for_trigger: cli.compute_limit_for_tcs,
|
||||||
profit_fraction: cli.tcs_profit_fraction,
|
profit_fraction: cli.tcs_profit_fraction,
|
||||||
collateral_token_index: 0, // USDC
|
collateral_token_index: 0, // USDC
|
||||||
// TODO: config
|
|
||||||
refresh_timeout: Duration::from_secs(30),
|
|
||||||
|
|
||||||
jupiter_version: cli.jupiter_version.into(),
|
jupiter_version: cli.jupiter_version.into(),
|
||||||
jupiter_slippage_bps: cli.rebalance_slippage_bps,
|
jupiter_slippage_bps: cli.rebalance_slippage_bps,
|
||||||
|
|
||||||
mode: cli.tcs_mode.into(),
|
mode: cli.tcs_mode.into(),
|
||||||
min_buy_fraction: 0.7,
|
min_buy_fraction: cli.tcs_min_buy_fraction,
|
||||||
|
|
||||||
|
only_allowed_tokens: liq_config.only_allowed_tokens.clone(),
|
||||||
|
forbidden_tokens: liq_config.forbidden_tokens.clone(),
|
||||||
};
|
};
|
||||||
|
|
||||||
let mut rebalance_interval = tokio::time::interval(Duration::from_secs(30));
|
let mut rebalance_interval = tokio::time::interval(Duration::from_secs(30));
|
||||||
|
@ -381,16 +237,10 @@ async fn main() -> anyhow::Result<()> {
|
||||||
let rebalance_config = rebalance::Config {
|
let rebalance_config = rebalance::Config {
|
||||||
enabled: cli.rebalance == BoolArg::True,
|
enabled: cli.rebalance == BoolArg::True,
|
||||||
slippage_bps: cli.rebalance_slippage_bps,
|
slippage_bps: cli.rebalance_slippage_bps,
|
||||||
// TODO: config
|
borrow_settle_excess: (1f64 + cli.rebalance_borrow_settle_excess).max(1f64),
|
||||||
borrow_settle_excess: 1.05,
|
refresh_timeout: Duration::from_secs(cli.rebalance_refresh_timeout_secs),
|
||||||
refresh_timeout: Duration::from_secs(30),
|
|
||||||
jupiter_version: cli.jupiter_version.into(),
|
jupiter_version: cli.jupiter_version.into(),
|
||||||
skip_tokens: cli
|
skip_tokens: cli.rebalance_skip_tokens.unwrap_or(Vec::new()),
|
||||||
.rebalance_skip_tokens
|
|
||||||
.split(',')
|
|
||||||
.filter(|v| !v.is_empty())
|
|
||||||
.map(|name| mango_client.context.token_by_name(name).token_index)
|
|
||||||
.collect(),
|
|
||||||
allow_withdraws: signer_is_owner,
|
allow_withdraws: signer_is_owner,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
@ -532,16 +382,13 @@ async fn main() -> anyhow::Result<()> {
|
||||||
|
|
||||||
let mut took_tcs = false;
|
let mut took_tcs = false;
|
||||||
if !liquidated && cli.take_tcs == BoolArg::True {
|
if !liquidated && cli.take_tcs == BoolArg::True {
|
||||||
took_tcs = match liquidation
|
took_tcs = liquidation
|
||||||
.maybe_take_token_conditional_swap(account_addresses.iter())
|
.maybe_take_token_conditional_swap(account_addresses.iter())
|
||||||
.await
|
.await
|
||||||
{
|
.unwrap_or_else(|err| {
|
||||||
Ok(v) => v,
|
|
||||||
Err(err) => {
|
|
||||||
error!("error during maybe_take_token_conditional_swap: {err}");
|
error!("error during maybe_take_token_conditional_swap: {err}");
|
||||||
false
|
false
|
||||||
}
|
})
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if liquidated || took_tcs {
|
if liquidated || took_tcs {
|
||||||
|
@ -552,14 +399,15 @@ async fn main() -> anyhow::Result<()> {
|
||||||
});
|
});
|
||||||
|
|
||||||
let token_swap_info_job = tokio::spawn({
|
let token_swap_info_job = tokio::spawn({
|
||||||
// TODO: configurable interval
|
let mut interval = mango_v4_client::delay_interval(Duration::from_secs(
|
||||||
let mut interval = mango_v4_client::delay_interval(Duration::from_secs(60));
|
cli.token_swap_refresh_interval_secs,
|
||||||
|
));
|
||||||
let mut startup_wait = mango_v4_client::delay_interval(Duration::from_secs(1));
|
let mut startup_wait = mango_v4_client::delay_interval(Duration::from_secs(1));
|
||||||
let shared_state = shared_state.clone();
|
let shared_state = shared_state.clone();
|
||||||
async move {
|
async move {
|
||||||
loop {
|
loop {
|
||||||
startup_wait.tick().await;
|
|
||||||
if !shared_state.read().unwrap().one_snapshot_done {
|
if !shared_state.read().unwrap().one_snapshot_done {
|
||||||
|
startup_wait.tick().await;
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -594,6 +442,7 @@ async fn main() -> anyhow::Result<()> {
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
use cli_args::{BoolArg, Cli, CliDotenv};
|
||||||
use futures::StreamExt;
|
use futures::StreamExt;
|
||||||
let mut jobs: futures::stream::FuturesUnordered<_> = vec![
|
let mut jobs: futures::stream::FuturesUnordered<_> = vec![
|
||||||
data_job,
|
data_job,
|
||||||
|
|
|
@ -1,8 +1,9 @@
|
||||||
|
use std::collections::HashSet;
|
||||||
use std::{
|
use std::{
|
||||||
collections::HashMap,
|
collections::HashMap,
|
||||||
pin::Pin,
|
pin::Pin,
|
||||||
sync::{Arc, RwLock},
|
sync::{Arc, RwLock},
|
||||||
time::{Duration, Instant},
|
time::Instant,
|
||||||
};
|
};
|
||||||
|
|
||||||
use futures_core::Future;
|
use futures_core::Future;
|
||||||
|
@ -56,7 +57,6 @@ pub enum Mode {
|
||||||
pub struct Config {
|
pub struct Config {
|
||||||
pub min_health_ratio: f64,
|
pub min_health_ratio: f64,
|
||||||
pub max_trigger_quote_amount: u64,
|
pub max_trigger_quote_amount: u64,
|
||||||
pub refresh_timeout: Duration,
|
|
||||||
pub compute_limit_for_trigger: u32,
|
pub compute_limit_for_trigger: u32,
|
||||||
pub collateral_token_index: TokenIndex,
|
pub collateral_token_index: TokenIndex,
|
||||||
|
|
||||||
|
@ -73,6 +73,9 @@ pub struct Config {
|
||||||
pub jupiter_version: jupiter::Version,
|
pub jupiter_version: jupiter::Version,
|
||||||
pub jupiter_slippage_bps: u64,
|
pub jupiter_slippage_bps: u64,
|
||||||
pub mode: Mode,
|
pub mode: Mode,
|
||||||
|
|
||||||
|
pub only_allowed_tokens: HashSet<TokenIndex>,
|
||||||
|
pub forbidden_tokens: HashSet<TokenIndex>,
|
||||||
}
|
}
|
||||||
|
|
||||||
pub enum JupiterQuoteCacheResult<T> {
|
pub enum JupiterQuoteCacheResult<T> {
|
||||||
|
@ -401,11 +404,43 @@ impl Context {
|
||||||
Ok(taker_price >= base_price * cost_over_oracle * (1.0 + self.config.profit_fraction))
|
Ok(taker_price >= base_price * cost_over_oracle * (1.0 + self.config.profit_fraction))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// excluded by config
|
||||||
|
fn tcs_pair_is_allowed(
|
||||||
|
&self,
|
||||||
|
buy_token_index: TokenIndex,
|
||||||
|
sell_token_index: TokenIndex,
|
||||||
|
) -> bool {
|
||||||
|
if self.config.forbidden_tokens.contains(&buy_token_index) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if self.config.forbidden_tokens.contains(&sell_token_index) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if self.config.only_allowed_tokens.is_empty() {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if self.config.only_allowed_tokens.contains(&buy_token_index) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if self.config.only_allowed_tokens.contains(&sell_token_index) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
// Either expired or triggerable with ok-looking price.
|
// Either expired or triggerable with ok-looking price.
|
||||||
fn tcs_is_interesting(&self, tcs: &TokenConditionalSwap) -> anyhow::Result<bool> {
|
fn tcs_is_interesting(&self, tcs: &TokenConditionalSwap) -> anyhow::Result<bool> {
|
||||||
if tcs.is_expired(self.now_ts) {
|
if tcs.is_expired(self.now_ts) {
|
||||||
return Ok(true);
|
return Ok(true);
|
||||||
}
|
}
|
||||||
|
if !self.tcs_pair_is_allowed(tcs.buy_token_index, tcs.buy_token_index) {
|
||||||
|
return Ok(false);
|
||||||
|
}
|
||||||
|
|
||||||
let (_, buy_token_price, _) = self.token_bank_price_mint(tcs.buy_token_index)?;
|
let (_, buy_token_price, _) = self.token_bank_price_mint(tcs.buy_token_index)?;
|
||||||
let (_, sell_token_price, _) = self.token_bank_price_mint(tcs.sell_token_index)?;
|
let (_, sell_token_price, _) = self.token_bank_price_mint(tcs.sell_token_index)?;
|
||||||
|
|
Loading…
Reference in New Issue