Serum3 open orders: Fix health overestimation (#716)

When bids or asks crossed the oracle price, the serum3 health would be
overestimated before.

The health code has no access to the open order quantites or prices and
used to assume all orders are at oracle price.

Now we track an account's max bid and min ask in each market and use that
as a worst-case price. The tracking isn't perfect for technical reasons
(compute cost, no notifications on fill) but produces an upper bound on
bids (lower bound on asks) that is sufficient to make health not
overestimate.

The tracked price is reset every time the serum3 open orders on a book
side are completely cleared.
This commit is contained in:
Christian Kamm 2023-09-13 09:35:10 +02:00 committed by GitHub
parent 7b8a92dcea
commit 2adc0339dc
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
10 changed files with 493 additions and 157 deletions

View File

@ -19,8 +19,10 @@ use anchor_lang::prelude::*;
use fixed::types::I80F48;
use crate::error::*;
use crate::serum3_cpi::{OpenOrdersAmounts, OpenOrdersSlim};
use crate::state::{
Bank, MangoAccountRef, PerpMarket, PerpMarketIndex, PerpPosition, Serum3MarketIndex, TokenIndex,
Bank, MangoAccountRef, PerpMarket, PerpMarketIndex, PerpPosition, Serum3MarketIndex,
Serum3Orders, TokenIndex,
};
use super::*;
@ -237,6 +239,11 @@ pub struct Serum3Info {
pub reserved_base: I80F48,
pub reserved_quote: I80F48,
// Reserved amounts, converted to the opposite token, while using the most extreme order price
// May be zero if the extreme bid/ask price is not available (for orders placed in the past)
pub reserved_base_as_quote_lowest_ask: I80F48,
pub reserved_quote_as_base_highest_bid: I80F48,
// Index into TokenInfos _not_ a TokenIndex
pub base_info_index: usize,
pub quote_info_index: usize,
@ -248,6 +255,35 @@ pub struct Serum3Info {
}
impl Serum3Info {
fn new(
serum_account: &Serum3Orders,
open_orders: &impl OpenOrdersAmounts,
base_info_index: usize,
quote_info_index: usize,
) -> Self {
// track the reserved amounts
let reserved_base = I80F48::from(open_orders.native_base_reserved());
let reserved_quote = I80F48::from(open_orders.native_quote_reserved());
let reserved_base_as_quote_lowest_ask =
reserved_base * I80F48::from_num(serum_account.lowest_placed_ask);
let reserved_quote_as_base_highest_bid =
reserved_quote * I80F48::from_num(serum_account.highest_placed_bid_inv);
Self {
reserved_base,
reserved_quote,
reserved_base_as_quote_lowest_ask,
reserved_quote_as_base_highest_bid,
base_info_index,
quote_info_index,
market_index: serum_account.market_index,
has_zero_funds: open_orders.native_base_total() == 0
&& open_orders.native_quote_total() == 0
&& open_orders.native_rebates() == 0,
}
}
#[inline(always)]
fn all_reserved_as_base(
&self,
@ -258,7 +294,13 @@ impl Serum3Info {
let quote_asset = quote_info.prices.asset(health_type);
let base_liab = base_info.prices.liab(health_type);
// OPTIMIZATION: These divisions can be extremely expensive (up to 5k CU each)
self.reserved_base + self.reserved_quote * quote_asset / base_liab
let reserved_quote_as_base_oracle = self.reserved_quote * quote_asset / base_liab;
if self.reserved_quote_as_base_highest_bid != 0 {
self.reserved_base
+ reserved_quote_as_base_oracle.min(self.reserved_quote_as_base_highest_bid)
} else {
self.reserved_base + reserved_quote_as_base_oracle
}
}
#[inline(always)]
@ -271,7 +313,13 @@ impl Serum3Info {
let base_asset = base_info.prices.asset(health_type);
let quote_liab = quote_info.prices.liab(health_type);
// OPTIMIZATION: These divisions can be extremely expensive (up to 5k CU each)
self.reserved_quote + self.reserved_base * base_asset / quote_liab
let reserved_base_as_quote_oracle = self.reserved_base * base_asset / quote_liab;
if self.reserved_base_as_quote_lowest_ask != 0 {
self.reserved_quote
+ reserved_base_as_quote_oracle.min(self.reserved_base_as_quote_lowest_ask)
} else {
self.reserved_quote + reserved_base_as_quote_oracle
}
}
/// Compute the health contribution from active open orders.
@ -800,42 +848,40 @@ impl HealthCache {
Ok(())
}
/// Changes the cached user account token and serum balances.
/// Recompute the cached information about a serum market.
///
/// WARNING: You must also call recompute_token_weights() after all bank
/// deposit/withdraw changes!
#[allow(clippy::too_many_arguments)]
pub fn adjust_serum3_reserved(
pub fn recompute_serum3_info(
&mut self,
market_index: Serum3MarketIndex,
base_token_index: TokenIndex,
reserved_base_change: I80F48,
serum_account: &Serum3Orders,
open_orders: &OpenOrdersSlim,
free_base_change: I80F48,
quote_token_index: TokenIndex,
reserved_quote_change: I80F48,
free_quote_change: I80F48,
) -> Result<()> {
let base_entry_index = self.token_info_index(base_token_index)?;
let quote_entry_index = self.token_info_index(quote_token_index)?;
let serum_info_index = self
.serum3_infos
.iter_mut()
.position(|m| m.market_index == serum_account.market_index)
.ok_or_else(|| error_msg!("serum3 market {} not found", serum_account.market_index))?;
// Apply it to the tokens
let serum_info = &self.serum3_infos[serum_info_index];
{
let base_entry = &mut self.token_infos[base_entry_index];
let base_entry = &mut self.token_infos[serum_info.base_info_index];
base_entry.balance_spot += free_base_change;
}
{
let quote_entry = &mut self.token_infos[quote_entry_index];
let quote_entry = &mut self.token_infos[serum_info.quote_info_index];
quote_entry.balance_spot += free_quote_change;
}
// Apply it to the serum3 info
let market_entry = self
.serum3_infos
.iter_mut()
.find(|m| m.market_index == market_index)
.ok_or_else(|| error_msg!("serum3 market {} not found", market_index))?;
market_entry.reserved_base += reserved_base_change;
market_entry.reserved_quote += reserved_quote_change;
let serum_info = &mut self.serum3_infos[serum_info_index];
*serum_info = Serum3Info::new(
serum_account,
open_orders,
serum_info.base_info_index,
serum_info.quote_info_index,
);
Ok(())
}
@ -1248,20 +1294,12 @@ fn new_health_cache_impl(
let quote_info = &mut token_infos[quote_info_index];
quote_info.balance_spot += quote_free;
// track the reserved amounts
let reserved_base = I80F48::from(oo.native_coin_total - oo.native_coin_free);
let reserved_quote = I80F48::from(oo.native_pc_total - oo.native_pc_free);
serum3_infos.push(Serum3Info {
reserved_base,
reserved_quote,
serum3_infos.push(Serum3Info::new(
serum_account,
oo,
base_info_index,
quote_info_index,
market_index: serum_account.market_index,
has_zero_funds: oo.native_coin_total == 0
&& oo.native_pc_total == 0
&& oo.referrer_rebates_accrued == 0,
});
));
}
// health contribution from perp accounts
@ -1423,6 +1461,7 @@ mod tests {
perp1: (i64, i64, i64, i64),
expected_health: f64,
bank_settings: [BankSettings; 3],
extra: Option<fn(&mut MangoAccountValue)>,
}
fn test_health1_runner(testcase: &TestHealth1Case) {
let buffer = MangoAccount::default_for_tests().try_to_vec().unwrap();
@ -1501,6 +1540,10 @@ mod tests {
perpaccount.bids_base_lots = testcase.perp1.2;
perpaccount.asks_base_lots = testcase.perp1.3;
if let Some(extra_fn) = testcase.extra {
extra_fn(&mut account);
}
let oracle2_ai = oracle2.as_account_info();
let ais = vec![
bank1.as_account_info(),
@ -1715,6 +1758,50 @@ mod tests {
expected_health: 1.2 * (100.0 - 100.0 - 1.2 * 1.0 * base_lots_to_quote),
..Default::default()
},
TestHealth1Case {
// 14, reserved oo funds with max bid/min ask
token1: -100,
token2: -10,
token3: 0,
oo_1_2: (1, 1),
oo_1_3: (11, 1),
expected_health:
// tokens
-100.0 * 1.2 - 10.0 * 5.0 * 1.5
// oo_1_2 (-> token1)
+ (1.0 + 3.0) * 1.2
// oo_1_3 (-> token3)
+ (11.0 / 12.0 + 1.0) * 10.0 * 0.5,
extra: Some(|account: &mut MangoAccountValue| {
let s2 = account.serum3_orders_mut(2).unwrap();
s2.lowest_placed_ask = 3.0;
let s3 = account.serum3_orders_mut(3).unwrap();
s3.highest_placed_bid_inv = 1.0 / 12.0;
}),
..Default::default()
},
TestHealth1Case {
// 15, reserved oo funds with max bid/min ask not crossing oracle
token1: -100,
token2: -10,
token3: 0,
oo_1_2: (1, 1),
oo_1_3: (11, 1),
expected_health:
// tokens
-100.0 * 1.2 - 10.0 * 5.0 * 1.5
// oo_1_2 (-> token1)
+ (1.0 + 5.0) * 1.2
// oo_1_3 (-> token3)
+ (11.0 / 10.0 + 1.0) * 10.0 * 0.5,
extra: Some(|account: &mut MangoAccountValue| {
let s2 = account.serum3_orders_mut(2).unwrap();
s2.lowest_placed_ask = 6.0;
let s3 = account.serum3_orders_mut(3).unwrap();
s3.highest_placed_bid_inv = 1.0 / 9.0;
}),
..Default::default()
},
];
for (i, testcase) in testcases.iter().enumerate() {

View File

@ -952,6 +952,8 @@ mod tests {
market_index: 0,
reserved_base: I80F48::from(30 / 3),
reserved_quote: I80F48::from(30 / 2),
reserved_base_as_quote_lowest_ask: I80F48::ZERO,
reserved_quote_as_base_highest_bid: I80F48::ZERO,
has_zero_funds: false,
}];
adjust_by_usdc(&mut health_cache, 0, -20.0);
@ -1606,6 +1608,8 @@ mod tests {
serum3_infos: vec![Serum3Info {
reserved_base: I80F48::ONE,
reserved_quote: I80F48::ZERO,
reserved_base_as_quote_lowest_ask: I80F48::ONE,
reserved_quote_as_base_highest_bid: I80F48::ZERO,
base_info_index: 1,
quote_info_index: 0,
market_index: 0,

View File

@ -1,10 +1,9 @@
use anchor_lang::prelude::*;
use super::{OpenOrdersAmounts, OpenOrdersSlim};
use crate::accounts_ix::*;
use crate::error::*;
use crate::logs::Serum3OpenOrdersBalanceLogV2;
use crate::serum3_cpi::load_open_orders_ref;
use crate::serum3_cpi::{load_open_orders_ref, OpenOrdersAmounts, OpenOrdersSlim};
use crate::state::*;
pub fn serum3_cancel_all_orders(ctx: Context<Serum3CancelAllOrders>, limit: u8) -> Result<()> {

View File

@ -5,10 +5,9 @@ use serum_dex::instruction::CancelOrderInstructionV2;
use crate::error::*;
use crate::state::*;
use super::{OpenOrdersAmounts, OpenOrdersSlim};
use crate::accounts_ix::*;
use crate::logs::Serum3OpenOrdersBalanceLogV2;
use crate::serum3_cpi::load_open_orders_ref;
use crate::serum3_cpi::{load_open_orders_ref, OpenOrdersAmounts, OpenOrdersSlim};
pub fn serum3_cancel_order(
ctx: Context<Serum3CancelOrder>,

View File

@ -4,9 +4,9 @@ use crate::accounts_ix::*;
use crate::error::*;
use crate::health::*;
use crate::instructions::apply_settle_changes;
use crate::instructions::{charge_loan_origination_fees, OpenOrdersAmounts, OpenOrdersSlim};
use crate::instructions::charge_loan_origination_fees;
use crate::logs::Serum3OpenOrdersBalanceLogV2;
use crate::serum3_cpi::load_open_orders_ref;
use crate::serum3_cpi::{load_open_orders_ref, OpenOrdersAmounts, OpenOrdersSlim};
use crate::state::*;
pub fn serum3_liq_force_cancel_orders(

View File

@ -6,97 +6,19 @@ use crate::state::*;
use crate::accounts_ix::*;
use crate::logs::{Serum3OpenOrdersBalanceLogV2, TokenBalanceLog};
use crate::serum3_cpi::{load_market_state, load_open_orders_ref};
use crate::serum3_cpi::{
load_market_state, load_open_orders_ref, OpenOrdersAmounts, OpenOrdersSlim,
};
use anchor_lang::prelude::*;
use fixed::types::I80F48;
use serum_dex::instruction::NewOrderInstructionV3;
use serum_dex::state::OpenOrders;
/// For loan origination fees bookkeeping purposes
#[derive(Debug)]
pub struct OpenOrdersSlim {
native_coin_free: u64,
native_coin_total: u64,
native_pc_free: u64,
native_pc_total: u64,
referrer_rebates_accrued: u64,
}
impl OpenOrdersSlim {
pub fn from_oo(oo: &OpenOrders) -> Self {
Self {
native_coin_free: oo.native_coin_free,
native_coin_total: oo.native_coin_total,
native_pc_free: oo.native_pc_free,
native_pc_total: oo.native_pc_total,
referrer_rebates_accrued: oo.referrer_rebates_accrued,
}
}
}
pub trait OpenOrdersAmounts {
fn native_base_reserved(&self) -> u64;
fn native_quote_reserved(&self) -> u64;
fn native_base_free(&self) -> u64;
fn native_quote_free(&self) -> u64;
fn native_base_total(&self) -> u64;
fn native_quote_total(&self) -> u64;
fn native_rebates(&self) -> u64;
}
impl OpenOrdersAmounts for OpenOrdersSlim {
fn native_base_reserved(&self) -> u64 {
self.native_coin_total - self.native_coin_free
}
fn native_quote_reserved(&self) -> u64 {
self.native_pc_total - self.native_pc_free
}
fn native_base_free(&self) -> u64 {
self.native_coin_free
}
fn native_quote_free(&self) -> u64 {
self.native_pc_free
}
fn native_base_total(&self) -> u64 {
self.native_coin_total
}
fn native_quote_total(&self) -> u64 {
self.native_pc_total
}
fn native_rebates(&self) -> u64 {
self.referrer_rebates_accrued
}
}
impl OpenOrdersAmounts for OpenOrders {
fn native_base_reserved(&self) -> u64 {
self.native_coin_total - self.native_coin_free
}
fn native_quote_reserved(&self) -> u64 {
self.native_pc_total - self.native_pc_free
}
fn native_base_free(&self) -> u64 {
self.native_coin_free
}
fn native_quote_free(&self) -> u64 {
self.native_pc_free
}
fn native_base_total(&self) -> u64 {
self.native_coin_total
}
fn native_quote_total(&self) -> u64 {
self.native_pc_total
}
fn native_rebates(&self) -> u64 {
self.referrer_rebates_accrued
}
}
#[allow(clippy::too_many_arguments)]
pub fn serum3_place_order(
ctx: Context<Serum3PlaceOrder>,
side: Serum3Side,
limit_price: u64,
limit_price_lots: u64,
max_base_qty: u64,
max_native_quote_qty_including_fees: u64,
self_trade_behavior: Serum3SelfTradeBehavior,
@ -104,6 +26,9 @@ pub fn serum3_place_order(
client_order_id: u64,
limit: u16,
) -> Result<()> {
// Also required by serum3's place order
require_gt!(limit_price_lots, 0);
let serum_market = ctx.accounts.serum_market.load()?;
require!(
!serum_market.is_reduce_only(),
@ -179,19 +104,28 @@ pub fn serum3_place_order(
let before_vault = ctx.accounts.payer_vault.amount;
let before_oo_free_slots;
let before_had_bids;
let before_had_asks;
let before_oo = {
let oo_ai = &ctx.accounts.open_orders.as_ref();
let open_orders = load_open_orders_ref(oo_ai)?;
before_oo_free_slots = open_orders.free_slot_bits;
before_had_bids = (!open_orders.free_slot_bits & open_orders.is_bid_bits) != 0;
before_had_asks = (!open_orders.free_slot_bits & !open_orders.is_bid_bits) != 0;
OpenOrdersSlim::from_oo(&open_orders)
};
// Provide a readable error message in case the vault doesn't have enough tokens
let base_lot_size;
let quote_lot_size;
{
let base_lot_size = load_market_state(
let market_state = load_market_state(
&ctx.accounts.serum_market_external,
&ctx.accounts.serum_program.key(),
)?
.coin_lot_size;
)?;
base_lot_size = market_state.coin_lot_size;
quote_lot_size = market_state.pc_lot_size;
let needed_amount = match side {
Serum3Side::Ask => {
@ -216,7 +150,7 @@ pub fn serum3_place_order(
//
let order = serum_dex::instruction::NewOrderInstructionV3 {
side: u8::try_from(side).unwrap().try_into().unwrap(),
limit_price: limit_price.try_into().unwrap(),
limit_price: limit_price_lots.try_into().unwrap(),
max_coin_qty: max_base_qty.try_into().unwrap(),
max_native_pc_qty_including_fees: max_native_quote_qty_including_fees.try_into().unwrap(),
self_trade_behavior: u8::try_from(self_trade_behavior)
@ -233,24 +167,65 @@ pub fn serum3_place_order(
//
// After-order tracking
//
let after_oo_free_slots;
let after_oo = {
let oo_ai = &ctx.accounts.open_orders.as_ref();
let open_orders = load_open_orders_ref(oo_ai)?;
after_oo_free_slots = open_orders.free_slot_bits;
OpenOrdersSlim::from_oo(&open_orders)
};
let oo_difference = OODifference::new(&before_oo, &after_oo);
//
// Track the highest bid and lowest ask, to be able to evaluate worst-case health even
// when they cross the oracle
//
let serum = account.serum3_orders_mut(serum_market.market_index)?;
if !before_had_bids {
// The 0 state means uninitialized/no value
serum.highest_placed_bid_inv = 0.0;
}
if !before_had_asks {
serum.lowest_placed_ask = 0.0;
}
let new_order_on_book = after_oo_free_slots != before_oo_free_slots;
if new_order_on_book {
match side {
Serum3Side::Ask => {
// in the normal quote per base units
let limit_price =
limit_price_lots as f64 * quote_lot_size as f64 / base_lot_size as f64;
serum.lowest_placed_ask = if serum.lowest_placed_ask == 0.0 {
limit_price
} else {
serum.lowest_placed_ask.min(limit_price)
};
}
Serum3Side::Bid => {
// in base per quote units, to avoid a division in health
let limit_price_inv =
base_lot_size as f64 / (limit_price_lots as f64 * quote_lot_size as f64);
serum.highest_placed_bid_inv = if serum.highest_placed_bid_inv == 0.0 {
limit_price_inv
} else {
// the highest bid has the lowest _inv value
serum.highest_placed_bid_inv.min(limit_price_inv)
};
}
}
}
emit!(Serum3OpenOrdersBalanceLogV2 {
mango_group: ctx.accounts.group.key(),
mango_account: ctx.accounts.account.key(),
market_index: serum_market.market_index,
base_token_index: serum_market.base_token_index,
quote_token_index: serum_market.quote_token_index,
base_total: after_oo.native_coin_total,
base_free: after_oo.native_coin_free,
quote_total: after_oo.native_pc_total,
quote_free: after_oo.native_pc_free,
referrer_rebates_accrued: after_oo.referrer_rebates_accrued,
base_total: after_oo.native_base_total(),
base_free: after_oo.native_base_free(),
quote_total: after_oo.native_quote_total(),
quote_free: after_oo.native_quote_free(),
referrer_rebates_accrued: after_oo.native_rebates(),
});
ctx.accounts.payer_vault.reload()?;
@ -293,7 +268,13 @@ pub fn serum3_place_order(
}
vault_difference.adjust_health_cache_token_balance(&mut health_cache, &payer_bank)?;
oo_difference.adjust_health_cache_serum3_state(&mut health_cache, &serum_market)?;
let serum_account = account.serum3_orders(serum_market.market_index)?;
oo_difference.recompute_health_cache_serum3_state(
&mut health_cache,
&serum_account,
&after_oo,
)?;
// Check the receiver's reduce only flag.
//
@ -322,8 +303,6 @@ pub fn serum3_place_order(
}
pub struct OODifference {
reserved_base_change: I80F48,
reserved_quote_change: I80F48,
free_base_change: I80F48,
free_quote_change: I80F48,
}
@ -331,10 +310,6 @@ pub struct OODifference {
impl OODifference {
pub fn new(before_oo: &OpenOrdersSlim, after_oo: &OpenOrdersSlim) -> Self {
Self {
reserved_base_change: I80F48::from(after_oo.native_base_reserved())
- I80F48::from(before_oo.native_base_reserved()),
reserved_quote_change: I80F48::from(after_oo.native_quote_reserved())
- I80F48::from(before_oo.native_quote_reserved()),
free_base_change: I80F48::from(after_oo.native_base_free())
- I80F48::from(before_oo.native_base_free()),
free_quote_change: I80F48::from(after_oo.native_quote_free())
@ -342,18 +317,16 @@ impl OODifference {
}
}
pub fn adjust_health_cache_serum3_state(
pub fn recompute_health_cache_serum3_state(
&self,
health_cache: &mut HealthCache,
market: &Serum3Market,
serum_account: &Serum3Orders,
open_orders: &OpenOrdersSlim,
) -> Result<()> {
health_cache.adjust_serum3_reserved(
market.market_index,
market.base_token_index,
self.reserved_base_change,
health_cache.recompute_serum3_info(
serum_account,
open_orders,
self.free_base_change,
market.quote_token_index,
self.reserved_quote_change,
self.free_quote_change,
)
}
@ -516,8 +489,12 @@ pub fn apply_settle_changes(
base_difference.adjust_health_cache_token_balance(health_cache, &base_bank)?;
quote_difference.adjust_health_cache_token_balance(health_cache, &quote_bank)?;
OODifference::new(&before_oo, &after_oo)
.adjust_health_cache_serum3_state(health_cache, serum_market)?;
let serum_account = account.serum3_orders(serum_market.market_index)?;
OODifference::new(&before_oo, &after_oo).recompute_health_cache_serum3_state(
health_cache,
serum_account,
after_oo,
)?;
}
Ok(())

View File

@ -2,10 +2,10 @@ use anchor_lang::prelude::*;
use fixed::types::I80F48;
use crate::error::*;
use crate::serum3_cpi::load_open_orders_ref;
use crate::serum3_cpi::{load_open_orders_ref, OpenOrdersAmounts, OpenOrdersSlim};
use crate::state::*;
use super::{apply_settle_changes, OpenOrdersAmounts, OpenOrdersSlim};
use super::apply_settle_changes;
use crate::accounts_ix::*;
use crate::logs::Serum3OpenOrdersBalanceLogV2;
use crate::logs::{LoanOriginationFeeInstruction, WithdrawLoanLog};

View File

@ -1,5 +1,5 @@
use anchor_lang::prelude::*;
use serum_dex::state::ToAlignedBytes;
use serum_dex::state::{OpenOrders, ToAlignedBytes};
use std::cell::{Ref, RefMut};
use std::cmp::min;
@ -128,6 +128,85 @@ pub fn pubkey_from_u64_array(d: [u64; 4]) -> Pubkey {
Pubkey::from(b)
}
/// For loan origination fees bookkeeping purposes
#[derive(Debug)]
pub struct OpenOrdersSlim {
native_coin_free: u64,
native_coin_total: u64,
native_pc_free: u64,
native_pc_total: u64,
referrer_rebates_accrued: u64,
}
impl OpenOrdersSlim {
pub fn from_oo(oo: &OpenOrders) -> Self {
Self {
native_coin_free: oo.native_coin_free,
native_coin_total: oo.native_coin_total,
native_pc_free: oo.native_pc_free,
native_pc_total: oo.native_pc_total,
referrer_rebates_accrued: oo.referrer_rebates_accrued,
}
}
}
pub trait OpenOrdersAmounts {
fn native_base_reserved(&self) -> u64;
fn native_quote_reserved(&self) -> u64;
fn native_base_free(&self) -> u64;
fn native_quote_free(&self) -> u64;
fn native_base_total(&self) -> u64;
fn native_quote_total(&self) -> u64;
fn native_rebates(&self) -> u64;
}
impl OpenOrdersAmounts for OpenOrdersSlim {
fn native_base_reserved(&self) -> u64 {
self.native_coin_total - self.native_coin_free
}
fn native_quote_reserved(&self) -> u64 {
self.native_pc_total - self.native_pc_free
}
fn native_base_free(&self) -> u64 {
self.native_coin_free
}
fn native_quote_free(&self) -> u64 {
self.native_pc_free
}
fn native_base_total(&self) -> u64 {
self.native_coin_total
}
fn native_quote_total(&self) -> u64 {
self.native_pc_total
}
fn native_rebates(&self) -> u64 {
self.referrer_rebates_accrued
}
}
impl OpenOrdersAmounts for OpenOrders {
fn native_base_reserved(&self) -> u64 {
self.native_coin_total - self.native_coin_free
}
fn native_quote_reserved(&self) -> u64 {
self.native_pc_total - self.native_pc_free
}
fn native_base_free(&self) -> u64 {
self.native_coin_free
}
fn native_quote_free(&self) -> u64 {
self.native_pc_free
}
fn native_base_total(&self) -> u64 {
self.native_coin_total
}
fn native_quote_total(&self) -> u64 {
self.native_pc_total
}
fn native_rebates(&self) -> u64 {
self.referrer_rebates_accrued
}
}
pub struct InitOpenOrders<'info> {
/// CHECK: cpi
pub program: AccountInfo<'info>,

View File

@ -133,10 +133,25 @@ pub struct Serum3Orders {
#[derivative(Debug = "ignore")]
pub padding: [u8; 2],
/// Track something like the highest open bid / lowest open ask, in native/native units.
///
/// Tracking it exactly isn't possible since we don't see fills. So instead track
/// the min/max of the _placed_ bids and asks.
///
/// The value is reset in serum3_place_order when a new order is placed without an
/// existing one on the book.
///
/// 0 is a special "unset" state.
pub highest_placed_bid_inv: f64,
pub lowest_placed_ask: f64,
#[derivative(Debug = "ignore")]
pub reserved: [u8; 64],
pub reserved: [u8; 48],
}
const_assert_eq!(size_of::<Serum3Orders>(), 32 + 8 * 2 + 2 * 3 + 2 + 64);
const_assert_eq!(
size_of::<Serum3Orders>(),
32 + 8 * 2 + 2 * 3 + 2 + 2 * 8 + 48
);
const_assert_eq!(size_of::<Serum3Orders>(), 120);
const_assert_eq!(size_of::<Serum3Orders>() % 8, 0);
@ -157,10 +172,12 @@ impl Default for Serum3Orders {
market_index: Serum3MarketIndex::MAX,
base_token_index: TokenIndex::MAX,
quote_token_index: TokenIndex::MAX,
reserved: [0; 64],
padding: Default::default(),
base_borrows_without_fee: 0,
quote_borrows_without_fee: 0,
highest_placed_bid_inv: 0.0,
lowest_placed_ask: 0.0,
reserved: [0; 48],
}
}
}

View File

@ -2,7 +2,7 @@
use super::*;
use mango_v4::accounts_ix::{Serum3OrderType, Serum3SelfTradeBehavior, Serum3Side};
use mango_v4::{instructions::OpenOrdersSlim, serum3_cpi::load_open_orders_bytes};
use mango_v4::serum3_cpi::{load_open_orders_bytes, OpenOrdersSlim};
use std::sync::Arc;
struct SerumOrderPlacer {
@ -134,6 +134,34 @@ impl SerumOrderPlacer {
.unwrap();
}
async fn cancel_all(&self) {
let open_orders = self.serum.load_open_orders(self.open_orders).await;
let orders = open_orders.orders;
for (idx, order_id) in orders.iter().enumerate() {
if *order_id == 0 {
continue;
}
let side = if open_orders.is_bid_bits & (1u128 << idx) == 0 {
Serum3Side::Ask
} else {
Serum3Side::Bid
};
send_tx(
&self.solana,
Serum3CancelOrderInstruction {
side,
order_id: *order_id,
account: self.account,
owner: self.owner,
serum_market: self.serum_market,
},
)
.await
.unwrap();
}
}
async fn settle(&self) {
self.settle_v2(true).await
}
@ -1082,6 +1110,152 @@ async fn test_serum_place_reducing_when_liquidatable() -> Result<(), TransportEr
Ok(())
}
#[tokio::test]
async fn test_serum_track_bid_ask() -> Result<(), TransportError> {
let mut test_builder = TestContextBuilder::new();
test_builder.test().set_compute_max_units(150_000); // Serum3PlaceOrder needs lots
let context = test_builder.start_default().await;
//
// SETUP: Create a group, accounts, market etc
//
let deposit_amount = 10000;
let CommonSetup {
serum_market_cookie,
mut order_placer,
..
} = common_setup(&context, deposit_amount).await;
//
// TEST: highest bid/lowest ask updating
//
assert_eq!(
order_placer
.mango_serum_orders()
.await
.highest_placed_bid_inv,
0.0
);
assert_eq!(
order_placer.mango_serum_orders().await.lowest_placed_ask,
0.0
);
order_placer.bid_maker(10.0, 100).await.unwrap();
assert_eq!(
order_placer
.mango_serum_orders()
.await
.highest_placed_bid_inv,
1.0 / 10.0
);
order_placer.bid_maker(9.0, 100).await.unwrap();
assert_eq!(
order_placer
.mango_serum_orders()
.await
.highest_placed_bid_inv,
1.0 / 10.0
);
order_placer.bid_maker(11.0, 100).await.unwrap();
assert_eq!(
order_placer
.mango_serum_orders()
.await
.highest_placed_bid_inv,
1.0 / 11.0
);
assert_eq!(
order_placer.mango_serum_orders().await.lowest_placed_ask,
0.0
);
order_placer.ask(20.0, 100).await.unwrap();
assert_eq!(
order_placer.mango_serum_orders().await.lowest_placed_ask,
20.0
);
order_placer.ask(19.0, 100).await.unwrap();
assert_eq!(
order_placer.mango_serum_orders().await.lowest_placed_ask,
19.0
);
order_placer.ask(21.0, 100).await.unwrap();
assert_eq!(
order_placer.mango_serum_orders().await.lowest_placed_ask,
19.0
);
assert_eq!(
order_placer
.mango_serum_orders()
.await
.highest_placed_bid_inv,
1.0 / 11.0
);
//
// TEST: cancellation allows for resets
//
order_placer.cancel_all().await;
assert_eq!(
order_placer.mango_serum_orders().await.lowest_placed_ask,
19.0
);
assert_eq!(
order_placer
.mango_serum_orders()
.await
.highest_placed_bid_inv,
1.0 / 11.0
);
// Process events such that the OutEvent deactivates the closed order on open_orders
context
.serum
.consume_spot_events(&serum_market_cookie, &[order_placer.open_orders])
.await;
// takes new value for bid, resets ask
order_placer.bid_maker(1.0, 100).await.unwrap();
assert_eq!(
order_placer.mango_serum_orders().await.lowest_placed_ask,
0.0
);
assert_eq!(
order_placer
.mango_serum_orders()
.await
.highest_placed_bid_inv,
1.0
);
//
// TEST: can reset even when there's still an order on the other side
//
let (oid, _) = order_placer.ask(10.0, 100).await.unwrap();
assert_eq!(
order_placer.mango_serum_orders().await.lowest_placed_ask,
10.0
);
order_placer.cancel(oid).await;
context
.serum
.consume_spot_events(&serum_market_cookie, &[order_placer.open_orders])
.await;
order_placer.ask(9.0, 100).await.unwrap();
assert_eq!(
order_placer.mango_serum_orders().await.lowest_placed_ask,
9.0
);
Ok(())
}
struct CommonSetup {
group_with_tokens: GroupWithTokens,
serum_market_cookie: SpotMarketCookie,