liquidator: rebalance with openbook (limit order) (#938)

liquidator: rebalance with limit order
This commit is contained in:
Serge Farny 2024-04-10 16:47:45 +02:00 committed by GitHub
parent 75a07e986a
commit d0125e9fdf
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
4 changed files with 364 additions and 57 deletions

View File

@ -136,6 +136,13 @@ pub struct Cli {
#[clap(long, env, default_value = "30")]
pub(crate) rebalance_refresh_timeout_secs: u64,
#[clap(long, env, value_enum, default_value = "false")]
pub(crate) rebalance_using_limit_order: BoolArg,
/// distance (in bps) from oracle price at which to place order for rebalancing
#[clap(long, env, default_value = "100")]
pub(crate) rebalance_limit_order_distance_from_oracle_price_bps: u64,
/// if taking tcs orders is enabled
///
/// typically only disabled for tests where swaps are unavailable

View File

@ -248,6 +248,11 @@ async fn main() -> anyhow::Result<()> {
let (rebalance_trigger_sender, rebalance_trigger_receiver) = async_channel::bounded::<()>(1);
let (tx_tcs_trigger_sender, tx_tcs_trigger_receiver) = async_channel::unbounded::<()>();
let (tx_liq_trigger_sender, tx_liq_trigger_receiver) = async_channel::unbounded::<()>();
if cli.rebalance_using_limit_order == BoolArg::True && !signer_is_owner {
warn!("Can't withdraw dust to liqor account if delegate and using limit orders for rebalancing");
}
let rebalance_config = rebalance::Config {
enabled: cli.rebalance == BoolArg::True,
slippage_bps: cli.rebalance_slippage_bps,
@ -263,8 +268,11 @@ async fn main() -> anyhow::Result<()> {
.rebalance_alternate_sanctum_route_tokens
.clone()
.unwrap_or_default(),
allow_withdraws: signer_is_owner,
allow_withdraws: cli.rebalance_using_limit_order == BoolArg::False || signer_is_owner,
use_sanctum: cli.sanctum_enabled == BoolArg::True,
use_limit_order: cli.rebalance_using_limit_order == BoolArg::True,
limit_order_distance_from_oracle_price_bps: cli
.rebalance_limit_order_distance_from_oracle_price_bps,
};
rebalance_config.validate(&mango_client.context);

View File

@ -2,21 +2,27 @@ use itertools::Itertools;
use mango_v4::accounts_zerocopy::KeyedAccountSharedData;
use mango_v4::state::{
Bank, BookSide, MangoAccountValue, OracleAccountInfos, PerpMarket, PerpPosition,
PlaceOrderType, Side, TokenIndex, QUOTE_TOKEN_INDEX,
PlaceOrderType, Serum3MarketIndex, Side, TokenIndex, QUOTE_TOKEN_INDEX,
};
use mango_v4_client::{
chain_data, perp_pnl, swap, MangoClient, MangoGroupContext, PerpMarketContext, TokenContext,
TransactionBuilder, TransactionSize,
chain_data, perp_pnl, swap, MangoClient, MangoGroupContext, PerpMarketContext,
PreparedInstructions, Serum3MarketContext, TokenContext, TransactionBuilder, TransactionSize,
};
use solana_client::nonblocking::rpc_client::RpcClient;
use {fixed::types::I80F48, solana_sdk::pubkey::Pubkey};
use fixed::types::extra::U48;
use fixed::FixedI128;
use mango_v4::accounts_ix::{Serum3OrderType, Serum3SelfTradeBehavior, Serum3Side};
use mango_v4::serum3_cpi;
use mango_v4::serum3_cpi::{OpenOrdersAmounts, OpenOrdersSlim};
use solana_sdk::account::ReadableAccount;
use solana_sdk::signature::Signature;
use std::collections::{HashMap, HashSet};
use std::future::Future;
use std::sync::Arc;
use std::time::Duration;
use std::time::{Duration, SystemTime, UNIX_EPOCH};
use tracing::*;
#[derive(Clone)]
@ -24,6 +30,8 @@ pub struct Config {
pub enabled: bool,
/// Maximum slippage allowed in Jupiter
pub slippage_bps: u64,
/// Maximum slippage from oracle price for limit orders
pub limit_order_distance_from_oracle_price_bps: u64,
/// 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 1.05, then it'll swap borrow_value * 1.05 quote token into borrow token.
@ -35,6 +43,7 @@ pub struct Config {
pub alternate_sanctum_route_tokens: Vec<TokenIndex>,
pub allow_withdraws: bool,
pub use_sanctum: bool,
pub use_limit_order: bool,
}
impl Config {
@ -440,6 +449,7 @@ impl Rebalancer {
}
async fn rebalance_tokens(&self) -> anyhow::Result<()> {
self.close_and_settle_all_openbook_orders().await?;
let account = self.mango_account()?;
// TODO: configurable?
@ -466,7 +476,20 @@ impl Rebalancer {
// to sell them. Instead they will be withdrawn at the end.
// Purchases will aim to purchase slightly more than is needed, such that we can
// again withdraw the dust at the end.
let dust_threshold = I80F48::from(2) / token_price;
let dust_threshold_res = if self.config.use_limit_order {
self.dust_threshold_for_limit_order(token)
.await
.map(|x| I80F48::from(x))
} else {
Ok(I80F48::from(2) / token_price)
};
let Ok(dust_threshold) = dust_threshold_res
else {
let e = dust_threshold_res.unwrap_err();
error!("Cannot rebalance token {}, probably missing USDC market ? - error: {}", token.name, e);
continue;
};
// Some rebalancing can actually change non-USDC positions (rebalancing to SOL)
// So re-fetch the current token position amount
@ -480,57 +503,29 @@ impl Rebalancer {
};
let mut amount = fresh_amount()?;
trace!(token_index, %amount, %dust_threshold, "checking");
if amount < 0 {
// Buy
let buy_amount =
amount.abs().ceil() + (dust_threshold - I80F48::ONE).max(I80F48::ZERO);
let input_amount =
buy_amount * token_price * I80F48::from_num(self.config.borrow_settle_excess);
let (txsig, route) = self
.token_swap_buy(&account, token_mint, input_amount.to_num())
.await?;
let in_token = self
.mango_client
.context
.token_by_mint(&route.input_mint)
.unwrap();
info!(
%txsig,
"bought {} {} for {} {}",
token.native_to_ui(I80F48::from(route.out_amount)),
token.name,
in_token.native_to_ui(I80F48::from(route.in_amount)),
in_token.name,
);
if !self.refresh_mango_account_after_tx(txsig).await? {
return Ok(());
}
amount = fresh_amount()?;
}
trace!(token_index, token.name, %amount, %dust_threshold, "checking");
if amount > dust_threshold {
// Sell
let (txsig, route) = self
.token_swap_sell(&account, token_mint, amount.to_num::<u64>())
if self.config.use_limit_order {
self.unwind_using_limit_orders(
&account,
token,
token_price,
dust_threshold,
amount,
)
.await?;
} else {
amount = self
.unwind_using_swap(
&account,
token,
token_mint,
token_price,
dust_threshold,
fresh_amount,
amount,
)
.await?;
let out_token = self
.mango_client
.context
.token_by_mint(&route.output_mint)
.unwrap();
info!(
%txsig,
"sold {} {} for {} {}",
token.native_to_ui(I80F48::from(route.in_amount)),
token.name,
out_token.native_to_ui(I80F48::from(route.out_amount)),
out_token.name,
);
if !self.refresh_mango_account_after_tx(txsig).await? {
return Ok(());
}
amount = fresh_amount()?;
}
// Any remainder that could not be sold just gets withdrawn to ensure the
@ -565,6 +560,288 @@ impl Rebalancer {
Ok(())
}
async fn dust_threshold_for_limit_order(&self, token: &TokenContext) -> anyhow::Result<u64> {
let (_, market) = self
.mango_client
.context
.serum3_markets
.iter()
.find(|(_, context)| {
context.base_token_index == token.token_index
&& context.quote_token_index == QUOTE_TOKEN_INDEX
})
.ok_or(anyhow::format_err!(
"could not find market for token {}",
token.name
))?;
Ok(market.coin_lot_size - 1)
}
async fn unwind_using_limit_orders(
&self,
account: &Box<MangoAccountValue>,
token: &TokenContext,
token_price: I80F48,
dust_threshold: FixedI128<U48>,
native_amount: I80F48,
) -> anyhow::Result<()> {
if native_amount >= 0 && native_amount < dust_threshold {
return Ok(());
}
let (market_index, market) = self
.mango_client
.context
.serum3_markets
.iter()
.find(|(_, context)| {
context.base_token_index == token.token_index
&& context.quote_token_index == QUOTE_TOKEN_INDEX
})
.ok_or(anyhow::format_err!(
"could not find market for token {}",
token.name
))?;
let side = if native_amount < 0 {
Serum3Side::Bid
} else {
Serum3Side::Ask
};
let distance_from_oracle_price_bp =
I80F48::from_num(self.config.limit_order_distance_from_oracle_price_bps)
* match side {
Serum3Side::Bid => 1,
Serum3Side::Ask => -1,
};
let price_adjustment_factor =
(I80F48::from_num(10_000) + distance_from_oracle_price_bp) / I80F48::from_num(10_000);
let limit_price =
(token_price * price_adjustment_factor * I80F48::from_num(market.coin_lot_size))
.to_num::<u64>()
/ market.pc_lot_size;
let mut max_base_lots =
(native_amount.abs() / I80F48::from_num(market.coin_lot_size)).to_num::<u64>();
debug!(
side = match side {
Serum3Side::Bid => "Buy",
Serum3Side::Ask => "Sell",
},
token = token.name,
oracle_price = token_price.to_num::<f64>(),
price_adjustment_factor = price_adjustment_factor.to_num::<f64>(),
coin_lot_size = market.coin_lot_size,
pc_lot_size = market.pc_lot_size,
limit_price,
native_amount = native_amount.to_num::<f64>(),
max_base_lots = max_base_lots,
"building order for rebalancing"
);
// Try to buy enough to close the borrow
if max_base_lots == 0 && native_amount < 0 {
info!(
"Buying a whole lot for token {} to cover borrow of {}",
token.name, native_amount
);
max_base_lots = 1;
}
if max_base_lots == 0 {
warn!("Could not rebalance token '{}' (native_amount={}) using limit order, below base lot size", token.name, native_amount);
return Ok(());
}
let mut account = account.clone();
let create_or_replace_account_ixs = self
.mango_client
.serum3_create_or_replace_account_instruction(&mut account, *market_index, side)
.await?;
let cancel_ixs =
self.mango_client
.serum3_cancel_all_orders_instruction(&account, *market_index, 10)?;
let place_order_ixs = self
.mango_client
.serum3_place_order_instruction(
&account,
*market_index,
side,
limit_price,
max_base_lots,
((limit_price * max_base_lots * market.pc_lot_size) as f64 * 1.01) as u64,
Serum3SelfTradeBehavior::CancelProvide,
Serum3OrderType::Limit,
SystemTime::now().duration_since(UNIX_EPOCH)?.as_millis() as u64,
10,
)
.await?;
let seq_check_ixs = self
.mango_client
.sequence_check_instruction(&self.mango_account_address, &account)
.await?;
let mut ixs = PreparedInstructions::new();
ixs.append(create_or_replace_account_ixs);
ixs.append(cancel_ixs);
ixs.append(place_order_ixs);
ixs.append(seq_check_ixs);
let txsig = self
.mango_client
.send_and_confirm_owner_tx(ixs.to_instructions())
.await?;
info!(
%txsig,
"placed order for {} {} at price = {}",
token.native_to_ui(I80F48::from(native_amount)),
token.name,
limit_price,
);
if !self.refresh_mango_account_after_tx(txsig).await? {
return Ok(());
}
Ok(())
}
async fn close_and_settle_all_openbook_orders(&self) -> anyhow::Result<()> {
let account = self.mango_account()?;
for x in Self::shuffle(account.active_serum3_orders()) {
let token = self.mango_client.context.token(x.base_token_index);
let quote = self.mango_client.context.token(x.quote_token_index);
let market_index = x.market_index;
let market = self
.mango_client
.context
.serum3_markets
.get(&market_index)
.expect("no openbook market found");
self.close_and_settle_openbook_orders(&account, token, &market_index, market, quote)
.await?;
}
Ok(())
}
/// This will only settle funds when there is no more active orders (avoid doing too many settle tx)
async fn close_and_settle_openbook_orders(
&self,
account: &Box<MangoAccountValue>,
token: &TokenContext,
market_index: &Serum3MarketIndex,
market: &Serum3MarketContext,
quote: &TokenContext,
) -> anyhow::Result<()> {
let Ok(open_orders) = account.serum3_orders(*market_index).map(|x| x.open_orders)
else {
return Ok(());
};
let oo_acc = self.account_fetcher.fetch_raw(&open_orders)?;
let oo = serum3_cpi::load_open_orders_bytes(oo_acc.data())?;
let oo_slim = OpenOrdersSlim::from_oo(oo);
if oo_slim.native_base_reserved() != 0 || oo_slim.native_quote_reserved() != 0 {
return Ok(());
}
let settle_ixs =
self.mango_client
.serum3_settle_funds_instruction(market, token, quote, open_orders);
let close_ixs = self
.mango_client
.serum3_close_open_orders_instruction(*market_index);
let mut ixs = PreparedInstructions::new();
ixs.append(close_ixs);
ixs.append(settle_ixs);
let txsig = self
.mango_client
.send_and_confirm_owner_tx(ixs.to_instructions())
.await?;
info!(
%txsig,
"settle spot funds for {}",
token.name,
);
if !self.refresh_mango_account_after_tx(txsig).await? {
return Ok(());
}
Ok(())
}
async fn unwind_using_swap(
&self,
account: &Box<MangoAccountValue>,
token: &TokenContext,
token_mint: Pubkey,
token_price: I80F48,
dust_threshold: FixedI128<U48>,
fresh_amount: impl Fn() -> anyhow::Result<I80F48>,
amount: I80F48,
) -> anyhow::Result<I80F48> {
if amount < 0 {
// Buy
let buy_amount = amount.abs().ceil() + (dust_threshold - I80F48::ONE).max(I80F48::ZERO);
let input_amount =
buy_amount * token_price * I80F48::from_num(self.config.borrow_settle_excess);
let (txsig, route) = self
.token_swap_buy(&account, token_mint, input_amount.to_num())
.await?;
let in_token = self
.mango_client
.context
.token_by_mint(&route.input_mint)
.unwrap();
info!(
%txsig,
"bought {} {} for {} {}",
token.native_to_ui(I80F48::from(route.out_amount)),
token.name,
in_token.native_to_ui(I80F48::from(route.in_amount)),
in_token.name,
);
if !self.refresh_mango_account_after_tx(txsig).await? {
return Ok(amount);
}
}
if amount > dust_threshold {
// Sell
let (txsig, route) = self
.token_swap_sell(&account, token_mint, amount.to_num::<u64>())
.await?;
let out_token = self
.mango_client
.context
.token_by_mint(&route.output_mint)
.unwrap();
info!(
%txsig,
"sold {} {} for {} {}",
token.native_to_ui(I80F48::from(route.in_amount)),
token.name,
out_token.native_to_ui(I80F48::from(route.out_amount)),
out_token.name,
);
if !self.refresh_mango_account_after_tx(txsig).await? {
return Ok(amount);
}
}
Ok(fresh_amount()?)
}
#[instrument(
skip_all,
fields(

View File

@ -30,11 +30,11 @@ use mango_v4::state::{
use crate::confirm_transaction::{wait_for_transaction_confirmation, RpcConfirmTransactionConfig};
use crate::context::MangoGroupContext;
use crate::gpa::{fetch_anchor_account, fetch_mango_accounts};
use crate::health_cache;
use crate::priority_fees::{FixedPriorityFeeProvider, PriorityFeeProvider};
use crate::util;
use crate::util::PreparedInstructions;
use crate::{account_fetcher::*, swap};
use crate::{health_cache, Serum3MarketContext, TokenContext};
use solana_address_lookup_table_program::state::AddressLookupTable;
use solana_client::nonblocking::rpc_client::RpcClient as RpcClientAsync;
use solana_client::rpc_client::SerializableTransaction;
@ -1171,6 +1171,17 @@ impl MangoClient {
let account = self.mango_account().await?;
let open_orders = account.serum3_orders(market_index).unwrap().open_orders;
let ix = self.serum3_settle_funds_instruction(s3, base, quote, open_orders);
self.send_and_confirm_owner_tx(ix.to_instructions()).await
}
pub fn serum3_settle_funds_instruction(
&self,
s3: &Serum3MarketContext,
base: &TokenContext,
quote: &TokenContext,
open_orders: Pubkey,
) -> PreparedInstructions {
let ix = Instruction {
program_id: mango_v4::id(),
accounts: anchor_lang::ToAccountMetas::to_account_metas(
@ -1203,7 +1214,11 @@ impl MangoClient {
fees_to_dao: true,
}),
};
self.send_and_confirm_owner_tx(vec![ix]).await
PreparedInstructions::from_single(
ix,
self.context.compute_estimates.cu_per_mango_instruction,
)
}
pub fn serum3_cancel_all_orders_instruction(