diff --git a/client/src/perp_pnl.rs b/client/src/perp_pnl.rs index d69063ecc..22b1b3feb 100644 --- a/client/src/perp_pnl.rs +++ b/client/src/perp_pnl.rs @@ -18,12 +18,12 @@ pub fn fetch_top( context: &crate::context::MangoGroupContext, account_fetcher: &impl AccountFetcher, perp_market_index: PerpMarketIndex, - perp_market_address: &Pubkey, direction: Direction, count: usize, ) -> anyhow::Result> { + let perp = context.perp(perp_market_index); let perp_market = - account_fetcher_fetch_anchor_account::(account_fetcher, perp_market_address)?; + account_fetcher_fetch_anchor_account::(account_fetcher, &perp.address)?; let oracle_acc = account_fetcher.fetch_raw_account(&perp_market.oracle)?; let oracle_price = perp_market.oracle_price(&KeyedAccountSharedData::new(perp_market.oracle, oracle_acc))?; @@ -99,5 +99,5 @@ pub fn fetch_top( } // return highest abs pnl accounts - Ok(accounts_pnl[0..count].to_vec()) + Ok(accounts_pnl.into_iter().take(count).collect::>()) } diff --git a/liquidator/src/liquidate.rs b/liquidator/src/liquidate.rs index 02a9f01d5..7b09eddcb 100644 --- a/liquidator/src/liquidate.rs +++ b/liquidator/src/liquidate.rs @@ -3,8 +3,10 @@ use std::time::Duration; use client::{chain_data, health_cache, AccountFetcher, MangoClient, MangoClientError}; use mango_v4::accounts_zerocopy::KeyedAccountSharedData; use mango_v4::state::{ - Bank, HealthType, PerpMarketIndex, Serum3Orders, Side, TokenIndex, QUOTE_TOKEN_INDEX, + Bank, HealthCache, HealthType, MangoAccountValue, PerpMarketIndex, Serum3Orders, Side, + TokenIndex, QUOTE_TOKEN_INDEX, }; +use solana_sdk::signature::Signature; use itertools::Itertools; use rand::seq::SliceRandom; @@ -69,6 +71,451 @@ pub fn jupiter_market_can_sell( .is_ok() } +struct LiquidateHelper<'a> { + client: &'a MangoClient, + account_fetcher: &'a chain_data::AccountFetcher, + pubkey: &'a Pubkey, + liqee: &'a MangoAccountValue, + health_cache: &'a HealthCache, + maint_health: I80F48, + liqor_min_health_ratio: I80F48, +} + +impl<'a> LiquidateHelper<'a> { + fn serum3_close_orders(&self) -> anyhow::Result> { + // look for any open serum orders or settleable balances + let serum_force_cancels = self + .liqee + .active_serum3_orders() + .map(|orders| { + let open_orders_account = self + .account_fetcher + .fetch_raw_account(&orders.open_orders)?; + let open_orders = mango_v4::serum3_cpi::load_open_orders(&open_orders_account)?; + let can_force_cancel = open_orders.native_coin_total > 0 + || open_orders.native_pc_total > 0 + || open_orders.referrer_rebates_accrued > 0; + if can_force_cancel { + Ok(Some(*orders)) + } else { + Ok(None) + } + }) + .filter_map_ok(|v| v) + .collect::>>()?; + if serum_force_cancels.is_empty() { + return Ok(None); + } + // Cancel all orders on a random serum market + let serum_orders = serum_force_cancels.choose(&mut rand::thread_rng()).unwrap(); + let sig = self.client.serum3_liq_force_cancel_orders( + (self.pubkey, &self.liqee), + serum_orders.market_index, + &serum_orders.open_orders, + )?; + log::info!( + "Force cancelled serum orders on account {}, market index {}, maint_health was {}, tx sig {:?}", + self.pubkey, + serum_orders.market_index, + self.maint_health, + sig + ); + Ok(Some(sig)) + } + + fn perp_close_orders(&self) -> anyhow::Result> { + let perp_force_cancels = self + .liqee + .active_perp_positions() + .filter_map(|pp| pp.has_open_orders().then(|| pp.market_index)) + .collect::>(); + if perp_force_cancels.is_empty() { + return Ok(None); + } + + // Cancel all orders on a random perp market + let perp_market_index = *perp_force_cancels.choose(&mut rand::thread_rng()).unwrap(); + let sig = self + .client + .perp_liq_force_cancel_orders((self.pubkey, &self.liqee), perp_market_index)?; + log::info!( + "Force cancelled perp orders on account {}, market index {}, maint_health was {}, tx sig {:?}", + self.pubkey, + perp_market_index, + self.maint_health, + sig + ); + Ok(Some(sig)) + } + + fn liq_perp_base_position(&self) -> anyhow::Result> { + let mut perp_base_positions = self + .liqee + .active_perp_positions() + .map(|pp| { + let base_lots = pp.base_position_lots(); + if base_lots == 0 { + return Ok(None); + } + let perp = self.client.context.perp(pp.market_index); + let oracle = self + .account_fetcher + .fetch_raw_account(&perp.market.oracle)?; + let price = perp.market.oracle_price(&KeyedAccountSharedData::new( + perp.market.oracle, + oracle.into(), + ))?; + Ok(Some(( + pp.market_index, + base_lots, + price, + I80F48::from(base_lots.abs()) * price, + ))) + }) + .filter_map_ok(|v| v) + .collect::>>()?; + perp_base_positions.sort_by(|a, b| a.3.cmp(&b.3)); + + if perp_base_positions.is_empty() { + return Ok(None); + } + + // Liquidate the highest-value perp base position + let (perp_market_index, base_lots, price, _) = perp_base_positions.last().unwrap(); + let perp = self.client.context.perp(*perp_market_index); + + let (side, side_signum) = if *base_lots > 0 { + (Side::Bid, 1) + } else { + (Side::Ask, -1) + }; + + // Compute the max number of base_lots the liqor is willing to take + let max_base_transfer_abs = { + let mut liqor = self + .account_fetcher + .fetch_fresh_mango_account(&self.client.mango_account_address) + .context("getting liquidator account")?; + liqor.ensure_perp_position(*perp_market_index, QUOTE_TOKEN_INDEX)?; + let health_cache = + health_cache::new(&self.client.context, self.account_fetcher, &liqor) + .expect("always ok"); + health_cache.max_perp_for_health_ratio( + *perp_market_index, + *price, + perp.market.base_lot_size, + side, + self.liqor_min_health_ratio, + )? + }; + log::info!("computed max_base_transfer to be {max_base_transfer_abs}"); + + let sig = self.client.perp_liq_base_position( + (self.pubkey, &self.liqee), + *perp_market_index, + side_signum * max_base_transfer_abs, + )?; + log::info!( + "Liquidated base position for perp market on account {}, market index {}, maint_health was {}, tx sig {:?}", + self.pubkey, + perp_market_index, + self.maint_health, + sig + ); + Ok(Some(sig)) + } + + fn settle_perp_pnl(&self) -> anyhow::Result> { + let spot_health = self.health_cache.spot_health(HealthType::Maint); + let mut perp_settleable_pnl = self + .liqee + .active_perp_positions() + .map(|pp| { + if pp.base_position_lots() != 0 { + return Ok(None); + } + let pnl = pp.quote_position_native(); + let settleable_pnl = if pnl > 0 { + pnl + } else if pnl < 0 && spot_health > 0 { + pnl.max(-spot_health) + } else { + return Ok(None); + }; + Ok(Some((pp.market_index, settleable_pnl))) + }) + .filter_map_ok(|v| v) + .collect::>>()?; + // sort by pnl, descending + perp_settleable_pnl.sort_by(|a, b| b.1.cmp(&a.1)); + + if perp_settleable_pnl.is_empty() { + return Ok(None); + } + + for (perp_index, pnl) in perp_settleable_pnl { + let direction = if pnl > 0 { + client::perp_pnl::Direction::MaxNegative + } else { + client::perp_pnl::Direction::MaxPositive + }; + let counters = client::perp_pnl::fetch_top( + &self.client.context, + self.account_fetcher, + perp_index, + direction, + 2, + )?; + if counters.is_empty() { + // If we can't settle some positive PNL because we're lacking a suitable counterparty, + // then liquidation should continue, even though this step produced no transaction + log::info!("Could not settle perp pnl {pnl} for account {}, perp market {perp_index}: no counterparty", + self.pubkey); + continue; + } + let (counter_key, counter_acc, _) = counters.first().unwrap(); + + let (account_a, account_b) = if pnl > 0 { + (self.pubkey, (counter_key, counter_acc)) + } else { + (counter_key, (self.pubkey, self.liqee)) + }; + let sig = self + .client + .perp_settle_pnl(perp_index, account_a, account_b)?; + log::info!( + "Settled perp pnl for perp market on account {}, market index {perp_index}, maint_health was {}, tx sig {sig:?}", + self.pubkey, + self.maint_health, + ); + return Ok(Some(sig)); + } + return Ok(None); + } + + fn tokens(&self) -> anyhow::Result> { + let mut tokens = self + .liqee + .active_token_positions() + .map(|token_position| { + let token = self.client.context.token(token_position.token_index); + let bank = self + .account_fetcher + .fetch::(&token.mint_info.first_bank())?; + let oracle = self + .account_fetcher + .fetch_raw_account(&token.mint_info.oracle)?; + let price = bank.oracle_price(&KeyedAccountSharedData::new( + token.mint_info.oracle, + oracle.into(), + ))?; + Ok(( + token_position.token_index, + price, + token_position.native(&bank) * price, + )) + }) + .collect::>>()?; + tokens.sort_by(|a, b| a.2.cmp(&b.2)); + Ok(tokens) + } + + fn max_token_liab_transfer( + &self, + source: TokenIndex, + target: TokenIndex, + ) -> anyhow::Result { + let mut liqor = self + .account_fetcher + .fetch_fresh_mango_account(&self.client.mango_account_address) + .context("getting liquidator account")?; + + // Ensure the tokens are activated, so they appear in the health cache and + // max_swap_source() will work. + liqor.ensure_token_position(source)?; + liqor.ensure_token_position(target)?; + + let health_cache = health_cache::new(&self.client.context, self.account_fetcher, &liqor) + .expect("always ok"); + + let source_price = health_cache.token_info(source).unwrap().oracle_price; + let target_price = health_cache.token_info(target).unwrap().oracle_price; + // TODO: This is where we could multiply in the liquidation fee factors + let oracle_swap_price = source_price / target_price; + + let amount = health_cache + .max_swap_source_for_health_ratio( + source, + target, + oracle_swap_price, + self.liqor_min_health_ratio, + ) + .context("getting max_swap_source")?; + Ok(amount) + } + + fn liq_spot(&self) -> anyhow::Result> { + if !self.health_cache.has_borrows() || self.health_cache.can_call_spot_bankruptcy() { + return Ok(None); + } + + let tokens = self.tokens()?; + + let asset_token_index = tokens + .iter() + .rev() + .find(|(asset_token_index, _asset_price, asset_usdc_equivalent)| { + asset_usdc_equivalent.is_positive() + && jupiter_market_can_sell(self.client, *asset_token_index, QUOTE_TOKEN_INDEX) + }) + .ok_or_else(|| { + anyhow::anyhow!( + "mango account {}, has no asset tokens that are sellable for USDC: {:?}", + self.pubkey, + tokens + ) + })? + .0; + let liab_token_index = tokens + .iter() + .find(|(liab_token_index, _liab_price, liab_usdc_equivalent)| { + liab_usdc_equivalent.is_negative() + && jupiter_market_can_buy(self.client, *liab_token_index, QUOTE_TOKEN_INDEX) + }) + .ok_or_else(|| { + anyhow::anyhow!( + "mango account {}, has no liab tokens that are purchasable for USDC: {:?}", + self.pubkey, + tokens + ) + })? + .0; + + let max_liab_transfer = self + .max_token_liab_transfer(liab_token_index, asset_token_index) + .context("getting max_liab_transfer")?; + + // + // TODO: log liqor's assets in UI form + // TODO: log liquee's liab_needed, need to refactor program code to be able to be accessed from client side + // + let sig = self + .client + .token_liq_with_token( + (self.pubkey, &self.liqee), + asset_token_index, + liab_token_index, + max_liab_transfer, + ) + .context("sending liq_token_with_token")?; + log::info!( + "Liquidated token with token for {}, maint_health was {}, tx sig {:?}", + self.pubkey, + self.maint_health, + sig + ); + Ok(Some(sig)) + } + + fn bankrupt_spot(&self) -> anyhow::Result> { + if !self.health_cache.can_call_spot_bankruptcy() { + return Ok(None); + } + + let tokens = self.tokens()?; + + if tokens.is_empty() { + anyhow::bail!( + "mango account {}, is bankrupt has no active tokens", + self.pubkey + ); + } + let liab_token_index = tokens + .iter() + .find(|(liab_token_index, _liab_price, liab_usdc_equivalent)| { + liab_usdc_equivalent.is_negative() + && jupiter_market_can_buy(self.client, *liab_token_index, QUOTE_TOKEN_INDEX) + }) + .ok_or_else(|| { + anyhow::anyhow!( + "mango account {}, has no liab tokens that are purchasable for USDC: {:?}", + self.pubkey, + tokens + ) + })? + .0; + + let quote_token_index = 0; + let max_liab_transfer = + self.max_token_liab_transfer(liab_token_index, quote_token_index)?; + + let sig = self + .client + .token_liq_bankruptcy( + (self.pubkey, &self.liqee), + liab_token_index, + max_liab_transfer, + ) + .context("sending liq_token_bankruptcy")?; + log::info!( + "Liquidated bankruptcy for {}, maint_health was {}, tx sig {:?}", + self.pubkey, + self.maint_health, + sig + ); + Ok(Some(sig)) + } + + fn send_liq_tx(&self) -> anyhow::Result { + // TODO: Should we make an attempt to settle positive PNL first? + // The problem with it is that small market movements can continuously create + // small amounts of new positive PNL while base_position > 0. + // We shouldn't get stuck on this step, particularly if it's of limited value + // to the liquidators. + // if let Some(txsig) = self.perp_settle_positive_pnl()? { + // return Ok(txsig); + // } + + // Try to close orders before touching the user's positions + if let Some(txsig) = self.perp_close_orders()? { + return Ok(txsig); + } + if let Some(txsig) = self.serum3_close_orders()? { + return Ok(txsig); + } + + if let Some(txsig) = self.liq_perp_base_position()? { + return Ok(txsig); + } + + // Now that the perp base positions are zeroed the perp pnl won't + // fluctuate with the oracle price anymore. + // It's possible that some positive pnl can't be settled (if there's + // no liquid counterparty) and that some negative pnl can't be settled + // (if the liqee isn't liquid enough). + if let Some(txsig) = self.settle_perp_pnl()? { + return Ok(txsig); + } + + if let Some(txsig) = self.liq_spot()? { + return Ok(txsig); + } + + // TODO: socialize unsettleable negative pnl + // if let Some(txsig) = self.bankrupt_perp()? { + // return Ok(txsig); + // } + if let Some(txsig) = self.bankrupt_spot()? { + return Ok(txsig); + } + anyhow::bail!( + "Don't know what to do with liquidatable account {}, maint_health was {}", + self.pubkey, + self.maint_health + ); + } +} + #[allow(clippy::too_many_arguments)] pub fn maybe_liquidate_account( mango_client: &MangoClient, @@ -76,8 +523,7 @@ pub fn maybe_liquidate_account( pubkey: &Pubkey, config: &Config, ) -> anyhow::Result { - let min_health_ratio = I80F48::from_num(config.min_health_ratio); - let quote_token_index = 0; + let liqor_min_health_ratio = I80F48::from_num(config.min_health_ratio); let account = account_fetcher.fetch_mango_account(pubkey)?; let health_cache = @@ -105,264 +551,18 @@ pub fn maybe_liquidate_account( } let maint_health = health_cache.health(HealthType::Maint); - let is_spot_bankrupt = health_cache.can_call_spot_bankruptcy(); - let is_spot_liquidatable = health_cache.has_borrows() && !is_spot_bankrupt; - - // find asset and liab tokens - let mut tokens = account - .active_token_positions() - .map(|token_position| { - let token = mango_client.context.token(token_position.token_index); - let bank = account_fetcher.fetch::(&token.mint_info.first_bank())?; - let oracle = account_fetcher.fetch_raw_account(&token.mint_info.oracle)?; - let price = bank.oracle_price(&KeyedAccountSharedData::new( - token.mint_info.oracle, - oracle.into(), - ))?; - Ok(( - token_position.token_index, - price, - token_position.native(&bank) * price, - )) - }) - .collect::>>()?; - tokens.sort_by(|a, b| a.2.cmp(&b.2)); - - // look for any open serum orders or settleable balances - let serum_force_cancels = account - .active_serum3_orders() - .map(|orders| { - let open_orders_account = account_fetcher.fetch_raw_account(&orders.open_orders)?; - let open_orders = mango_v4::serum3_cpi::load_open_orders(&open_orders_account)?; - let can_force_cancel = open_orders.native_coin_total > 0 - || open_orders.native_pc_total > 0 - || open_orders.referrer_rebates_accrued > 0; - if can_force_cancel { - Ok(Some(*orders)) - } else { - Ok(None) - } - }) - .filter_map_ok(|v| v) - .collect::>>()?; - - // look for any perp open orders and base positions - let perp_force_cancels = account - .active_perp_positions() - .filter_map(|pp| pp.has_open_orders().then(|| pp.market_index)) - .collect::>(); - let mut perp_base_positions = account - .active_perp_positions() - .map(|pp| { - let base_lots = pp.base_position_lots(); - if base_lots == 0 { - return Ok(None); - } - let perp = mango_client.context.perp(pp.market_index); - let oracle = account_fetcher.fetch_raw_account(&perp.market.oracle)?; - let price = perp.market.oracle_price(&KeyedAccountSharedData::new( - perp.market.oracle, - oracle.into(), - ))?; - Ok(Some(( - pp.market_index, - base_lots, - price, - I80F48::from(base_lots.abs()) * price, - ))) - }) - .filter_map_ok(|v| v) - .collect::>>()?; - // sort by base_position_value, ascending - perp_base_positions.sort_by(|a, b| a.3.cmp(&b.3)); - - let get_max_liab_transfer = |source, target| -> anyhow::Result { - let mut liqor = account_fetcher - .fetch_fresh_mango_account(&mango_client.mango_account_address) - .context("getting liquidator account")?; - - // Ensure the tokens are activated, so they appear in the health cache and - // max_swap_source() will work. - liqor.ensure_token_position(source)?; - liqor.ensure_token_position(target)?; - - let health_cache = - health_cache::new(&mango_client.context, account_fetcher, &liqor).expect("always ok"); - - let source_price = health_cache.token_info(source).unwrap().oracle_price; - let target_price = health_cache.token_info(target).unwrap().oracle_price; - // TODO: This is where we could multiply in the liquidation fee factors - let oracle_swap_price = source_price / target_price; - - let amount = health_cache - .max_swap_source_for_health_ratio(source, target, oracle_swap_price, min_health_ratio) - .context("getting max_swap_source")?; - Ok(amount) - }; // try liquidating - let txsig = if !serum_force_cancels.is_empty() { - // Cancel all orders on a random serum market - let serum_orders = serum_force_cancels.choose(&mut rand::thread_rng()).unwrap(); - let sig = mango_client.serum3_liq_force_cancel_orders( - (pubkey, &account), - serum_orders.market_index, - &serum_orders.open_orders, - )?; - log::info!( - "Force cancelled serum orders on account {}, market index {}, maint_health was {}, tx sig {:?}", - pubkey, - serum_orders.market_index, - maint_health, - sig - ); - sig - } else if !perp_force_cancels.is_empty() { - // Cancel all orders on a random perp market - let perp_market_index = *perp_force_cancels.choose(&mut rand::thread_rng()).unwrap(); - let sig = - mango_client.perp_liq_force_cancel_orders((pubkey, &account), perp_market_index)?; - log::info!( - "Force cancelled perp orders on account {}, market index {}, maint_health was {}, tx sig {:?}", - pubkey, - perp_market_index, - maint_health, - sig - ); - sig - } else if !perp_base_positions.is_empty() { - // Liquidate the highest-value perp base position - let (perp_market_index, base_lots, price, _) = perp_base_positions.last().unwrap(); - let perp = mango_client.context.perp(*perp_market_index); - - let (side, side_signum) = if *base_lots > 0 { - (Side::Bid, 1) - } else { - (Side::Ask, -1) - }; - - // Compute the max number of base_lots the liqor is willing to take - let max_base_transfer_abs = { - let mut liqor = account_fetcher - .fetch_fresh_mango_account(&mango_client.mango_account_address) - .context("getting liquidator account")?; - liqor.ensure_perp_position(*perp_market_index, QUOTE_TOKEN_INDEX)?; - let health_cache = health_cache::new(&mango_client.context, account_fetcher, &liqor) - .expect("always ok"); - health_cache.max_perp_for_health_ratio( - *perp_market_index, - *price, - perp.market.base_lot_size, - side, - min_health_ratio, - )? - }; - log::info!("computed max_base_transfer to be {max_base_transfer_abs}"); - - let sig = mango_client.perp_liq_base_position( - (pubkey, &account), - *perp_market_index, - side_signum * max_base_transfer_abs, - )?; - log::info!( - "Liquidated base position for perp market on account {}, market index {}, maint_health was {}, tx sig {:?}", - pubkey, - perp_market_index, - maint_health, - sig - ); - sig - } else if is_spot_bankrupt { - if tokens.is_empty() { - anyhow::bail!("mango account {}, is bankrupt has no active tokens", pubkey); - } - let liab_token_index = tokens - .iter() - .find(|(liab_token_index, _liab_price, liab_usdc_equivalent)| { - liab_usdc_equivalent.is_negative() - && jupiter_market_can_buy(mango_client, *liab_token_index, QUOTE_TOKEN_INDEX) - }) - .ok_or_else(|| { - anyhow::anyhow!( - "mango account {}, has no liab tokens that are purchasable for USDC: {:?}", - pubkey, - tokens - ) - })? - .0; - - let max_liab_transfer = get_max_liab_transfer(liab_token_index, quote_token_index)?; - - let sig = mango_client - .token_liq_bankruptcy((pubkey, &account), liab_token_index, max_liab_transfer) - .context("sending liq_token_bankruptcy")?; - log::info!( - "Liquidated bankruptcy for {}, maint_health was {}, tx sig {:?}", - pubkey, - maint_health, - sig - ); - sig - } else if is_spot_liquidatable { - let asset_token_index = tokens - .iter() - .rev() - .find(|(asset_token_index, _asset_price, asset_usdc_equivalent)| { - asset_usdc_equivalent.is_positive() - && jupiter_market_can_sell(mango_client, *asset_token_index, QUOTE_TOKEN_INDEX) - }) - .ok_or_else(|| { - anyhow::anyhow!( - "mango account {}, has no asset tokens that are sellable for USDC: {:?}", - pubkey, - tokens - ) - })? - .0; - let liab_token_index = tokens - .iter() - .find(|(liab_token_index, _liab_price, liab_usdc_equivalent)| { - liab_usdc_equivalent.is_negative() - && jupiter_market_can_buy(mango_client, *liab_token_index, QUOTE_TOKEN_INDEX) - }) - .ok_or_else(|| { - anyhow::anyhow!( - "mango account {}, has no liab tokens that are purchasable for USDC: {:?}", - pubkey, - tokens - ) - })? - .0; - - let max_liab_transfer = get_max_liab_transfer(liab_token_index, asset_token_index) - .context("getting max_liab_transfer")?; - - // - // TODO: log liqor's assets in UI form - // TODO: log liquee's liab_needed, need to refactor program code to be able to be accessed from client side - // - let sig = mango_client - .token_liq_with_token( - (pubkey, &account), - asset_token_index, - liab_token_index, - max_liab_transfer, - ) - .context("sending liq_token_with_token")?; - log::info!( - "Liquidated token with token for {}, maint_health was {}, tx sig {:?}", - pubkey, - maint_health, - sig - ); - sig - } else { - anyhow::bail!( - "Don't know what to do with liquidatable account {}, maint_health was {}", - pubkey, - maint_health - ); - }; + let txsig = LiquidateHelper { + client: mango_client, + account_fetcher, + pubkey, + liqee: &account, + health_cache: &health_cache, + maint_health, + liqor_min_health_ratio, + } + .send_liq_tx()?; let slot = account_fetcher.transaction_max_slot(&[txsig])?; if let Err(e) = account_fetcher.refresh_accounts_via_rpc_until_slot( diff --git a/ts/client/src/scripts/mb-liqtest-make-candidates.ts b/ts/client/src/scripts/mb-liqtest-make-candidates.ts index f05febe3b..97ec6a545 100644 --- a/ts/client/src/scripts/mb-liqtest-make-candidates.ts +++ b/ts/client/src/scripts/mb-liqtest-make-candidates.ts @@ -22,6 +22,7 @@ const PRICES = { BTC: 20000.0, SOL: 0.04, USDC: 1, + MNGO: 0.04, }; const MAINNET_MINTS = new Map([ @@ -298,6 +299,109 @@ async function main() { } } + // borrows and positive perp pnl (but no position) + { + const name = 'LIQTEST, perp positive pnl'; + + console.log(`Creating mangoaccount...`); + let mangoAccount = await createMangoAccount(name); + console.log( + `...created mangoAccount ${mangoAccount.publicKey} for ${name}`, + ); + + const baseMint = new PublicKey(MAINNET_MINTS.get('MNGO')!); + const baseOracle = (await client.getStubOracle(group, baseMint))[0] + .publicKey; + const liabMint = new PublicKey(MAINNET_MINTS.get('USDC')!); + const collateralMint = new PublicKey(MAINNET_MINTS.get('SOL')!); + const collateralOracle = group.banksMapByName.get('SOL')![0].oracle; + + await client.tokenDepositNative( + group, + mangoAccount, + collateralMint, + 100000, + ); // valued as $0.004 maint collateral + await mangoAccount.reload(client, group); + + try { + await client.stubOracleSet(group, collateralOracle, PRICES['SOL'] * 10); + + // Spot-borrow more than the collateral is worth + await client.tokenWithdrawNative( + group, + mangoAccount, + liabMint, + -5000, + true, + ); + await mangoAccount.reload(client, group); + + // Execute two trades that leave the account with +$0.022 positive pnl + await client.stubOracleSet(group, baseOracle, PRICES['MNGO'] / 2); + await client.perpPlaceOrder( + group, + fundingAccount, + 'MNGO-PERP', + Side.ask, + 20, + 0.0011, // ui base quantity, 11 base lots, $0.022 + 0.022, // ui quote quantity + 4200, + PerpOrderType.limit, + 0, + 5, + ); + await client.perpPlaceOrder( + group, + mangoAccount, + 'MNGO-PERP', + Side.bid, + 20, + 0.0011, // ui base quantity, 11 base lots, $0.022 + 0.022, // ui quote quantity + 4200, + PerpOrderType.market, + 0, + 5, + ); + await client.perpConsumeAllEvents(group, 'MNGO-PERP'); + + await client.stubOracleSet(group, baseOracle, PRICES['MNGO']); + + await client.perpPlaceOrder( + group, + fundingAccount, + 'MNGO-PERP', + Side.bid, + 40, + 0.0011, // ui base quantity, 11 base lots, $0.044 + 0.044, // ui quote quantity + 4201, + PerpOrderType.limit, + 0, + 5, + ); + await client.perpPlaceOrder( + group, + mangoAccount, + 'MNGO-PERP', + Side.ask, + 40, + 0.0011, // ui base quantity, 11 base lots, $0.044 + 0.044, // ui quote quantity + 4201, + PerpOrderType.market, + 0, + 5, + ); + await client.perpConsumeAllEvents(group, 'MNGO-PERP'); + } finally { + await client.stubOracleSet(group, collateralOracle, PRICES['SOL']); + await client.stubOracleSet(group, baseOracle, PRICES['MNGO']); + } + } + process.exit(); }