liquidator: rebalance with openbook (limit order) (#938)
liquidator: rebalance with limit order
This commit is contained in:
parent
75a07e986a
commit
d0125e9fdf
|
@ -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
|
||||
|
|
|
@ -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);
|
||||
|
||||
|
|
|
@ -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(
|
||||
|
|
|
@ -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(
|
||||
|
|
Loading…
Reference in New Issue