Liquidation: Fix amounts when stable price != oracle price (#440)

Previously liquidation would overestimate the amount required, because
it used the oracle price for computing the health gain from
liquidation.

Now it uses the correct (stable price adjusted) price for figuring out
the amount of liquidation required, while still executing at
fee-adjusted oracle price.
This commit is contained in:
Christian Kamm 2023-02-08 09:05:17 +01:00 committed by GitHub
parent e4bcb218a2
commit 2cb4da8b58
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
3 changed files with 358 additions and 72 deletions

View File

@ -260,7 +260,8 @@ pub(crate) fn liquidation_action(
let perp_info = liqee_health_cache.perp_info(perp_market_index)?;
let oracle_price = perp_info.prices.oracle;
let price_per_lot = cm!(I80F48::from(perp_market.base_lot_size) * oracle_price);
let base_lot_size = I80F48::from(perp_market.base_lot_size);
let oracle_price_per_lot = cm!(base_lot_size * oracle_price);
let liqee_positive_settle_limit = liqee_perp_position.settle_limit(&perp_market).1;
@ -289,8 +290,11 @@ pub(crate) fn liquidation_action(
let quote_init_asset_weight = I80F48::ONE;
direction = -1;
fee_factor = cm!(I80F48::ONE - perp_market.base_liquidation_fee);
unweighted_health_per_lot = cm!(price_per_lot
* (-perp_market.init_base_asset_weight + quote_init_asset_weight * fee_factor));
let asset_price = perp_info.prices.asset(HealthType::Init);
unweighted_health_per_lot = cm!(-asset_price
* base_lot_size
* perp_market.init_base_asset_weight
+ oracle_price_per_lot * quote_init_asset_weight * fee_factor);
} else {
// liqee_base_lots <= 0
require_msg!(
@ -303,8 +307,11 @@ pub(crate) fn liquidation_action(
let quote_init_liab_weight = I80F48::ONE;
direction = 1;
fee_factor = cm!(I80F48::ONE + perp_market.base_liquidation_fee);
unweighted_health_per_lot = cm!(price_per_lot
* (perp_market.init_base_liab_weight - quote_init_liab_weight * fee_factor));
let liab_price = perp_info.prices.liab(HealthType::Init);
unweighted_health_per_lot = cm!(liab_price
* base_lot_size
* perp_market.init_base_liab_weight
- oracle_price_per_lot * quote_init_liab_weight * fee_factor);
};
assert!(unweighted_health_per_lot > 0);
@ -424,7 +431,7 @@ pub(crate) fn liquidation_action(
// liqee and liqors entry and break even prices.
//
let base_transfer = cm!(direction * base_reduction);
let quote_transfer = cm!(-I80F48::from(base_transfer) * price_per_lot * fee_factor);
let quote_transfer = cm!(-I80F48::from(base_transfer) * oracle_price_per_lot * fee_factor);
if base_transfer != 0 {
msg!(
"transfering: {} base lots and {} quote",
@ -570,7 +577,7 @@ mod tests {
}
}
fn run(&self, max_base: i64, max_pnl: u64) -> Result<Self> {
fn liqee_health_cache(&self) -> HealthCache {
let mut setup = self.clone();
let ais = vec![
@ -582,12 +589,14 @@ mod tests {
let retriever =
ScanningAccountRetriever::new_with_staleness(&ais, &setup.group, None).unwrap();
let mut liqee_health_cache =
health::new_health_cache(&setup.liqee.borrow(), &retriever).unwrap();
let liqee_init_health = liqee_health_cache.health(HealthType::Init);
health::new_health_cache(&setup.liqee.borrow(), &retriever).unwrap()
}
drop(retriever);
drop(ais);
fn run(&self, max_base: i64, max_pnl: u64) -> Result<Self> {
let mut setup = self.clone();
let mut liqee_health_cache = setup.liqee_health_cache();
let liqee_init_health = liqee_health_cache.health(HealthType::Init);
liquidation_action(
setup.perp_market.data(),
@ -838,4 +847,57 @@ mod tests {
);
}
}
#[test]
fn test_liq_base_or_positive_pnl_stable_price() {
let mut setup = TestSetup::new();
{
let pm = setup.perp_market.data();
pm.stable_price_model.stable_price = 0.5;
}
{
perp_p(&mut setup.liqee).record_trade(
setup.perp_market.data(),
10,
I80F48::from_num(-10),
);
let settle_bank = setup.settle_bank.data();
settle_bank
.change_without_fee(
token_p(&mut setup.liqee),
I80F48::from_num(5.0),
0,
I80F48::from(1),
)
.unwrap();
settle_bank
.change_without_fee(
token_p(&mut setup.liqor),
I80F48::from_num(1000.0),
0,
I80F48::from(1),
)
.unwrap();
}
let hc = setup.liqee_health_cache();
assert_eq_f!(
hc.health(HealthType::Init),
5.0 + (-10.0 + 10.0 * 0.5 * 0.8),
0.1
);
let mut result = setup.run(100, 0).unwrap();
let liqee_perp = perp_p(&mut result.liqee);
assert_eq!(liqee_perp.base_position_lots(), 8);
let hc = result.liqee_health_cache();
assert_eq_f!(
hc.health(HealthType::Init),
5.0 + (-8.0 + 8.0 * 0.5 * 0.8),
0.1
);
}
}

View File

@ -67,7 +67,7 @@ pub fn token_liq_with_token(
// Initial liqee health check
let mut liqee_health_cache = new_health_cache(&liqee.borrow(), &account_retriever)
.context("create liqee health cache")?;
let init_health = liqee_health_cache.health(HealthType::Init);
let liqee_init_health = liqee_health_cache.health(HealthType::Init);
liqee_health_cache.require_after_phase1_liquidation()?;
if !liqee.check_liquidatable(&liqee_health_cache)? {
@ -78,14 +78,51 @@ pub fn token_liq_with_token(
// Transfer some liab_token from liqor to liqee and
// transfer some asset_token from liqee to liqor.
//
let now_ts = Clock::get()?.unix_timestamp.try_into().unwrap();
liquidation_action(
&mut account_retriever,
liab_token_index,
asset_token_index,
&mut liqor.borrow_mut(),
ctx.accounts.liqor.key(),
&mut liqee.borrow_mut(),
ctx.accounts.liqee.key(),
&mut liqee_health_cache,
liqee_init_health,
now_ts,
max_liab_transfer,
)?;
// Check liqor's health
if !liqor.fixed.is_in_health_region() {
let liqor_health = compute_health(&liqor.borrow(), HealthType::Init, &account_retriever)
.context("compute liqor health")?;
require!(liqor_health >= 0, MangoError::HealthMustBePositive);
}
Ok(())
}
pub(crate) fn liquidation_action(
account_retriever: &mut ScanningAccountRetriever,
liab_token_index: TokenIndex,
asset_token_index: TokenIndex,
liqor: &mut MangoAccountRefMut,
liqor_key: Pubkey,
liqee: &mut MangoAccountRefMut,
liqee_key: Pubkey,
liqee_health_cache: &mut HealthCache,
liqee_init_health: I80F48,
now_ts: u64,
max_liab_transfer: I80F48,
) -> Result<()> {
// Get the mut banks and oracle prices
//
// This must happen _after_ the health computation, since immutable borrows of
// the bank are not allowed at the same time.
let (asset_bank, asset_price, opt_liab_bank_and_price) =
let (asset_bank, asset_oracle_price, opt_liab_bank_and_price) =
account_retriever.banks_mut_and_oracles(asset_token_index, liab_token_index)?;
let (liab_bank, liab_price) = opt_liab_bank_and_price.unwrap();
let (liab_bank, liab_oracle_price) = opt_liab_bank_and_price.unwrap();
// The main complication here is that we can't keep the liqee_asset_position and liqee_liab_position
// borrows alive at the same time. Possibly adding get_mut_pair() would be helpful.
@ -102,37 +139,61 @@ pub fn token_liq_with_token(
// Liquidation fees work by giving the liqor more assets than the oracle price would
// indicate. Specifically we choose
// assets =
// liabs * liab_price/asset_price * (1 + liab_liq_fee + asset_liq_fee)
// Which means that we use a increased liab price and reduced asset price for the conversion.
// liabs * liab_oracle_price/asset_oracle_price * (1 + liab_liq_fee + asset_liq_fee)
// Which means that we use a increased liab oracle price and reduced asset oracle price for
// the conversion.
// It would be more fully correct to use (1+liab_liq_fee)*(1+asset_liq_fee), but for small
// fee amounts that is nearly identical.
// For simplicity we write
// assets = liabs * liab_price / asset_price * fee_factor
// assets = liabs * liab_price_adjusted / asset_price
// assets = liabs * liab_oracle_price / asset_oracle_price * fee_factor
// assets = liabs * liab_oracle_price_adjusted / asset_oracle_price
// = liabs * lopa / aop
let fee_factor = cm!(I80F48::ONE + asset_bank.liquidation_fee + liab_bank.liquidation_fee);
let liab_price_adjusted = cm!(liab_price * fee_factor);
let liab_oracle_price_adjusted = cm!(liab_oracle_price * fee_factor);
let init_asset_weight = asset_bank.init_asset_weight;
let init_liab_weight = liab_bank.init_liab_weight;
// The price the health computation uses for a liability of one native liab token
let liab_init_liab_price = liqee_health_cache
.token_info(liab_token_index)
.unwrap()
.prices
.liab(HealthType::Init);
// Health price for an asset of one native asset token
let asset_init_asset_price = liqee_health_cache
.token_info(asset_token_index)
.unwrap()
.prices
.asset(HealthType::Init);
// How much asset would need to be exchanged to liab in order to bring health to 0?
//
// That means: what is x (unit: native liab tokens) such that
// init_health + x * ilw * lp - y * iaw * ap = 0
// init_health
// + x * ilw * lilp health gain from reducing liabs
// - y * iaw * aiap health loss from paying asset
// = 0
// where
// ilw = init_liab_weight, lp = liab_price
// iap = init_asset_weight, ap = asset_price
// ff = fee_factor, lpa = lp * ff
// ilw = init_liab_weight,
// lilp = liab_init_liab_price,
// lopa = liab_oracle_price_adjusted, (see above)
// iap = init_asset_weight,
// aiap = asset_init_asset_price,
// aop = asset_oracle_price
// ff = fee_factor
// and the asset cost of getting x native units of liab is:
// y = x * lp / ap * ff = x * lpa / ap (native asset tokens)
// y = x * lopa / aop (native asset tokens, see above)
//
// Result: x = -init_health / (lp * ilw - iaw * lpa)
let liab_needed =
cm!(-init_health
/ (liab_price * init_liab_weight - init_asset_weight * liab_price_adjusted));
// Result: x = -init_health / (ilw * lilp - iaw * lopa * aiap / aop)
let liab_needed = cm!(-liqee_init_health
/ (liab_init_liab_price * init_liab_weight
- liab_oracle_price_adjusted
* init_asset_weight
* (asset_init_asset_price / asset_oracle_price)));
// How much liab can we get at most for the asset balance?
let liab_possible = cm!(liqee_asset_native * asset_price / liab_price_adjusted);
let liab_possible = cm!(liqee_asset_native * asset_oracle_price / liab_oracle_price_adjusted);
// The amount of liab native tokens we will transfer
let liab_transfer = min(
@ -141,7 +202,7 @@ pub fn token_liq_with_token(
);
// The amount of asset native tokens we will give up for them
let asset_transfer = cm!(liab_transfer * liab_price_adjusted / asset_price);
let asset_transfer = cm!(liab_transfer * liab_oracle_price_adjusted / asset_oracle_price);
// During liquidation, we mustn't leave small positive balances in the liqee. Those
// could break bankruptcy-detection. Thus we dust them even if the token position
@ -149,11 +210,8 @@ pub fn token_liq_with_token(
// Apply the balance changes to the liqor and liqee accounts
let liqee_liab_position = liqee.token_position_mut_by_raw_index(liqee_liab_raw_index);
let liqee_liab_active = liab_bank.deposit_with_dusting(
liqee_liab_position,
liab_transfer,
Clock::get()?.unix_timestamp.try_into().unwrap(),
)?;
let liqee_liab_active =
liab_bank.deposit_with_dusting(liqee_liab_position, liab_transfer, now_ts)?;
let liqee_liab_indexed_position = liqee_liab_position.indexed_position;
let (liqor_liab_position, liqor_liab_raw_index, _) =
@ -161,27 +219,23 @@ pub fn token_liq_with_token(
let (liqor_liab_active, loan_origination_fee) = liab_bank.withdraw_with_fee(
liqor_liab_position,
liab_transfer,
Clock::get()?.unix_timestamp.try_into().unwrap(),
liab_price,
now_ts,
liab_oracle_price,
)?;
let liqor_liab_indexed_position = liqor_liab_position.indexed_position;
let liqee_liab_native_after = liqee_liab_position.native(liab_bank);
let (liqor_asset_position, liqor_asset_raw_index, _) =
liqor.ensure_token_position(asset_token_index)?;
let liqor_asset_active = asset_bank.deposit(
liqor_asset_position,
asset_transfer,
Clock::get()?.unix_timestamp.try_into().unwrap(),
)?;
let liqor_asset_active = asset_bank.deposit(liqor_asset_position, asset_transfer, now_ts)?;
let liqor_asset_indexed_position = liqor_asset_position.indexed_position;
let liqee_asset_position = liqee.token_position_mut_by_raw_index(liqee_asset_raw_index);
let liqee_asset_active = asset_bank.withdraw_without_fee_with_dusting(
liqee_asset_position,
asset_transfer,
Clock::get()?.unix_timestamp.try_into().unwrap(),
asset_price,
now_ts,
asset_oracle_price,
)?;
let liqee_asset_indexed_position = liqee_asset_position.indexed_position;
let liqee_assets_native_after = liqee_asset_position.native(asset_bank);
@ -202,8 +256,8 @@ pub fn token_liq_with_token(
// liqee asset
emit!(TokenBalanceLog {
mango_group: ctx.accounts.group.key(),
mango_account: ctx.accounts.liqee.key(),
mango_group: liqee.fixed.group,
mango_account: liqee_key,
token_index: asset_token_index,
indexed_position: liqee_asset_indexed_position.to_bits(),
deposit_index: asset_bank.deposit_index.to_bits(),
@ -211,8 +265,8 @@ pub fn token_liq_with_token(
});
// liqee liab
emit!(TokenBalanceLog {
mango_group: ctx.accounts.group.key(),
mango_account: ctx.accounts.liqee.key(),
mango_group: liqee.fixed.group,
mango_account: liqee_key,
token_index: liab_token_index,
indexed_position: liqee_liab_indexed_position.to_bits(),
deposit_index: liab_bank.deposit_index.to_bits(),
@ -220,8 +274,8 @@ pub fn token_liq_with_token(
});
// liqor asset
emit!(TokenBalanceLog {
mango_group: ctx.accounts.group.key(),
mango_account: ctx.accounts.liqor.key(),
mango_group: liqee.fixed.group,
mango_account: liqor_key,
token_index: asset_token_index,
indexed_position: liqor_asset_indexed_position.to_bits(),
deposit_index: asset_bank.deposit_index.to_bits(),
@ -229,8 +283,8 @@ pub fn token_liq_with_token(
});
// liqor liab
emit!(TokenBalanceLog {
mango_group: ctx.accounts.group.key(),
mango_account: ctx.accounts.liqor.key(),
mango_group: liqee.fixed.group,
mango_account: liqor_key,
token_index: liab_token_index,
indexed_position: liqor_liab_indexed_position.to_bits(),
deposit_index: liab_bank.deposit_index.to_bits(),
@ -239,8 +293,8 @@ pub fn token_liq_with_token(
if loan_origination_fee.is_positive() {
emit!(WithdrawLoanOriginationFeeLog {
mango_group: ctx.accounts.group.key(),
mango_account: ctx.accounts.liqor.key(),
mango_group: liqee.fixed.group,
mango_account: liqor_key,
token_index: liab_token_index,
loan_origination_fee: loan_origination_fee.to_bits(),
instruction: LoanOriginationFeeInstruction::LiqTokenWithToken
@ -249,16 +303,16 @@ pub fn token_liq_with_token(
// Since we use a scanning account retriever, it's safe to deactivate inactive token positions
if !liqee_asset_active {
liqee.deactivate_token_position_and_log(liqee_asset_raw_index, ctx.accounts.liqee.key());
liqee.deactivate_token_position_and_log(liqee_asset_raw_index, liqee_key);
}
if !liqee_liab_active {
liqee.deactivate_token_position_and_log(liqee_liab_raw_index, ctx.accounts.liqee.key());
liqee.deactivate_token_position_and_log(liqee_liab_raw_index, liqee_key);
}
if !liqor_asset_active {
liqor.deactivate_token_position_and_log(liqor_asset_raw_index, ctx.accounts.liqor.key());
liqor.deactivate_token_position_and_log(liqor_asset_raw_index, liqor_key);
}
if !liqor_liab_active {
liqor.deactivate_token_position_and_log(liqor_liab_raw_index, ctx.accounts.liqor.key())
liqor.deactivate_token_position_and_log(liqor_liab_raw_index, liqor_key)
}
// Check liqee health again
@ -267,25 +321,195 @@ pub fn token_liq_with_token(
.fixed
.maybe_recover_from_being_liquidated(liqee_init_health);
// Check liqor's health
if !liqor.fixed.is_in_health_region() {
let liqor_health = compute_health(&liqor.borrow(), HealthType::Init, &account_retriever)
.context("compute liqor health")?;
require!(liqor_health >= 0, MangoError::HealthMustBePositive);
}
emit!(TokenLiqWithTokenLog {
mango_group: ctx.accounts.group.key(),
liqee: ctx.accounts.liqee.key(),
liqor: ctx.accounts.liqor.key(),
mango_group: liqee.fixed.group,
liqee: liqee_key,
liqor: liqor_key,
asset_token_index,
liab_token_index,
asset_transfer: asset_transfer.to_bits(),
liab_transfer: liab_transfer.to_bits(),
asset_price: asset_price.to_bits(),
liab_price: liab_price.to_bits(),
asset_price: asset_oracle_price.to_bits(),
liab_price: liab_oracle_price.to_bits(),
bankruptcy: !liqee_health_cache.has_phase2_liquidatable() & liqee_init_health.is_negative()
});
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use crate::health::{self, test::*};
#[derive(Clone)]
struct TestSetup {
group: Pubkey,
asset_bank: TestAccount<Bank>,
liab_bank: TestAccount<Bank>,
asset_oracle: TestAccount<StubOracle>,
liab_oracle: TestAccount<StubOracle>,
liqee: MangoAccountValue,
liqor: MangoAccountValue,
}
impl TestSetup {
fn new() -> Self {
let group = Pubkey::new_unique();
let (asset_bank, asset_oracle) = mock_bank_and_oracle(group, 0, 1.0, 0.0, 0.0);
let (liab_bank, liab_oracle) = mock_bank_and_oracle(group, 1, 1.0, 0.0, 0.0);
let liqee_buffer = MangoAccount::default_for_tests().try_to_vec().unwrap();
let mut liqee = MangoAccountValue::from_bytes(&liqee_buffer).unwrap();
{
liqee.ensure_token_position(0).unwrap();
liqee.ensure_token_position(1).unwrap();
}
let liqor_buffer = MangoAccount::default_for_tests().try_to_vec().unwrap();
let mut liqor = MangoAccountValue::from_bytes(&liqor_buffer).unwrap();
{
liqor.ensure_token_position(0).unwrap();
liqor.ensure_token_position(1).unwrap();
}
Self {
group,
asset_bank,
liab_bank,
asset_oracle,
liab_oracle,
liqee,
liqor,
}
}
fn liqee_health_cache(&self) -> HealthCache {
let mut setup = self.clone();
let ais = vec![
setup.asset_bank.as_account_info(),
setup.liab_bank.as_account_info(),
setup.asset_oracle.as_account_info(),
setup.liab_oracle.as_account_info(),
];
let retriever =
ScanningAccountRetriever::new_with_staleness(&ais, &setup.group, None).unwrap();
health::new_health_cache(&setup.liqee.borrow(), &retriever).unwrap()
}
fn run(&self, max_liab_transfer: I80F48) -> Result<Self> {
let mut setup = self.clone();
let ais = vec![
setup.asset_bank.as_account_info(),
setup.liab_bank.as_account_info(),
setup.asset_oracle.as_account_info(),
setup.liab_oracle.as_account_info(),
];
let mut retriever =
ScanningAccountRetriever::new_with_staleness(&ais, &setup.group, None).unwrap();
let mut liqee_health_cache =
health::new_health_cache(&setup.liqee.borrow(), &retriever).unwrap();
let liqee_init_health = liqee_health_cache.health(HealthType::Init);
liquidation_action(
&mut retriever,
1,
0,
&mut setup.liqor.borrow_mut(),
Pubkey::new_unique(),
&mut setup.liqee.borrow_mut(),
Pubkey::new_unique(),
&mut liqee_health_cache,
liqee_init_health,
0,
max_liab_transfer,
)?;
drop(retriever);
drop(ais);
Ok(setup)
}
}
fn asset_p(account: &mut MangoAccountValue) -> &mut TokenPosition {
account.token_position_mut(0).unwrap().0
}
fn liab_p(account: &mut MangoAccountValue) -> &mut TokenPosition {
account.token_position_mut(1).unwrap().0
}
macro_rules! assert_eq_f {
($value:expr, $expected:expr, $max_error:expr) => {
let value = $value;
let expected = $expected;
let ok = (value.to_num::<f64>() - expected).abs() < $max_error;
assert!(ok, "value: {value}, expected: {expected}");
};
}
#[test]
fn test_liq_with_token_stable_price() {
let mut setup = TestSetup::new();
{
let ab = setup.asset_bank.data();
ab.stable_price_model.stable_price = 0.5;
let lb = setup.liab_bank.data();
lb.stable_price_model.stable_price = 1.25;
}
{
let asset_bank = setup.asset_bank.data();
asset_bank
.change_without_fee(
asset_p(&mut setup.liqee),
I80F48::from_num(10.0),
0,
I80F48::from(1),
)
.unwrap();
asset_bank
.change_without_fee(
asset_p(&mut setup.liqor),
I80F48::from_num(1000.0),
0,
I80F48::from(1),
)
.unwrap();
let liab_bank = setup.liab_bank.data();
liab_bank
.change_without_fee(
liab_p(&mut setup.liqor),
I80F48::from_num(1000.0),
0,
I80F48::from(1),
)
.unwrap();
liab_bank
.change_without_fee(
liab_p(&mut setup.liqee),
I80F48::from_num(-5.0),
0,
I80F48::from(1),
)
.unwrap();
}
let hc = setup.liqee_health_cache();
assert_eq_f!(hc.health(HealthType::Init), 10.0 * 0.5 - 5.0 * 1.25, 0.1);
let mut result = setup.run(I80F48::from(100)).unwrap();
let liqee_asset = asset_p(&mut result.liqee);
assert_eq_f!(liqee_asset.native(&result.asset_bank.data()), 8.335, 0.01);
let liqee_liab = liab_p(&mut result.liqee);
assert_eq_f!(liqee_liab.native(&result.liab_bank.data()), -3.335, 0.01);
let hc = result.liqee_health_cache();
assert_eq_f!(hc.health(HealthType::Init), 0.0, 0.01);
}
}

View File

@ -3,7 +3,7 @@ use super::*;
#[tokio::test]
async fn test_liq_perps_positive_pnl() -> Result<(), TransportError> {
let mut test_builder = TestContextBuilder::new();
test_builder.test().set_compute_max_units(120_000); // PerpLiqNegativePnlOrBankruptcy takes a lot of CU
test_builder.test().set_compute_max_units(140_000); // PerpLiqNegativePnlOrBankruptcy takes a lot of CU
let context = test_builder.start_default().await;
let solana = &context.solana.clone();