diff --git a/client/src/perp_pnl.rs b/client/src/perp_pnl.rs index 848ff384b..64cce8160 100644 --- a/client/src/perp_pnl.rs +++ b/client/src/perp_pnl.rs @@ -59,7 +59,7 @@ pub async fn fetch_top( let mut perp_pos = perp_pos.unwrap().clone(); perp_pos.settle_funding(&perp_market); perp_pos.update_settle_limit(&perp_market, now_ts); - let pnl = perp_pos.pnl_for_price(&perp_market, oracle_price).unwrap(); + let pnl = perp_pos.unsettled_pnl(&perp_market, oracle_price).unwrap(); let limited_pnl = perp_pos.apply_pnl_settle_limit(&perp_market, pnl); if limited_pnl >= 0 && direction == Direction::MaxNegative || limited_pnl <= 0 && direction == Direction::MaxPositive diff --git a/programs/mango-v4/src/instructions/perp_liq_quote_and_bankruptcy.rs b/programs/mango-v4/src/instructions/perp_liq_quote_and_bankruptcy.rs index e2c273677..91eef286a 100644 --- a/programs/mango-v4/src/instructions/perp_liq_quote_and_bankruptcy.rs +++ b/programs/mango-v4/src/instructions/perp_liq_quote_and_bankruptcy.rs @@ -159,7 +159,7 @@ pub fn perp_liq_quote_and_bankruptcy( liqee_perp_position.settle_funding(&perp_market); liqor_perp_position.settle_funding(&perp_market); - let liqee_pnl = liqee_perp_position.pnl_for_price(&perp_market, oracle_price)?; + let liqee_pnl = liqee_perp_position.unsettled_pnl(&perp_market, oracle_price)?; // TODO: deal with positive liqee pnl! Maybe another instruction? require!(liqee_pnl < 0, MangoError::ProfitabilityMismatch); @@ -221,7 +221,7 @@ pub fn perp_liq_quote_and_bankruptcy( // let insurance_transfer = if settlement == max_settlement_liqee { let liqee_perp_position = liqee.perp_position_mut(perp_market_index)?; - let liqee_pnl = liqee_perp_position.pnl_for_price(&perp_market, oracle_price)?; + let liqee_pnl = liqee_perp_position.unsettled_pnl(&perp_market, oracle_price)?; let max_liab_transfer_from_liqee = (-liqee_pnl).min(-liqee_init_health).max(I80F48::ZERO); let liab_transfer = max_liab_transfer_from_liqee diff --git a/programs/mango-v4/src/instructions/perp_settle_fees.rs b/programs/mango-v4/src/instructions/perp_settle_fees.rs index b5e267ef4..04501965e 100644 --- a/programs/mango-v4/src/instructions/perp_settle_fees.rs +++ b/programs/mango-v4/src/instructions/perp_settle_fees.rs @@ -70,7 +70,7 @@ pub fn perp_settle_fees(ctx: Context, max_settle_amount: u64) -> perp_position.settle_funding(&perp_market); // Calculate PnL - let pnl = perp_position.pnl_for_price(&perp_market, oracle_price)?; + let pnl = perp_position.unsettled_pnl(&perp_market, oracle_price)?; // Account perp position must have a loss to be able to settle against the fee account require!(pnl.is_negative(), MangoError::ProfitabilityMismatch); diff --git a/programs/mango-v4/src/instructions/perp_settle_pnl.rs b/programs/mango-v4/src/instructions/perp_settle_pnl.rs index 08afb1cab..de23060af 100644 --- a/programs/mango-v4/src/instructions/perp_settle_pnl.rs +++ b/programs/mango-v4/src/instructions/perp_settle_pnl.rs @@ -112,8 +112,8 @@ pub fn perp_settle_pnl(ctx: Context) -> Result<()> { let b_perp_position = account_b.perp_position_mut(perp_market_index)?; a_perp_position.settle_funding(&perp_market); b_perp_position.settle_funding(&perp_market); - let a_pnl = a_perp_position.pnl_for_price(&perp_market, oracle_price)?; - let b_pnl = b_perp_position.pnl_for_price(&perp_market, oracle_price)?; + let a_pnl = a_perp_position.unsettled_pnl(&perp_market, oracle_price)?; + let b_pnl = b_perp_position.unsettled_pnl(&perp_market, oracle_price)?; // PnL must have opposite signs for there to be a settlement: // Account A must be profitable, and B must be unprofitable. diff --git a/programs/mango-v4/src/state/mango_account_components.rs b/programs/mango-v4/src/state/mango_account_components.rs index cb4813c8e..6be63b040 100644 --- a/programs/mango-v4/src/state/mango_account_components.rs +++ b/programs/mango-v4/src/state/mango_account_components.rs @@ -240,12 +240,21 @@ pub struct PerpPosition { /// value of the realization. It magnitude decreases when realized pnl drops below its value. pub settle_pnl_limit_realized_trade: i64, + /// Trade pnl, fees, funding that were added over the current position's lifetime. + /// + /// Reset when the position changes sign or goes to zero. + /// Not decreased by settling. + /// + /// This is tracked for display purposes: this value plus the difference between entry + /// price and current price of the base position is the overall pnl. + pub realized_pnl_for_position_native: I80F48, + #[derivative(Debug = "ignore")] - pub reserved: [u8; 104], + pub reserved: [u8; 88], } const_assert_eq!( size_of::(), - 2 + 2 + 4 + 8 + 8 + 16 + 8 + 16 * 2 + 8 * 2 + 8 * 2 + 8 * 5 + 8 + 2 * 16 + 8 + 104 + 2 + 2 + 4 + 8 + 8 + 16 + 8 + 16 * 2 + 8 * 2 + 8 * 2 + 8 * 5 + 8 + 2 * 16 + 8 + 16 + 88 ); const_assert_eq!(size_of::(), 304); const_assert_eq!(size_of::() % 8, 0); @@ -275,7 +284,8 @@ impl Default for PerpPosition { settle_pnl_limit_window: 0, settle_pnl_limit_settled_in_current_window_native: 0, settle_pnl_limit_realized_trade: 0, - reserved: [0; 104], + realized_pnl_for_position_native: I80F48::ZERO, + reserved: [0; 88], } } } @@ -353,6 +363,7 @@ impl PerpPosition { let funding = self.unsettled_funding(perp_market); cm!(self.quote_position_native -= funding); cm!(self.realized_other_pnl_native -= funding); + cm!(self.realized_pnl_for_position_native -= funding); if self.base_position_lots.is_positive() { self.cumulative_long_funding += funding.to_num::(); @@ -386,9 +397,10 @@ impl PerpPosition { if new_position == 0 { reduced_lots = -old_position; - // clear out entry and break-even prices + // clear out display fields that live only while the position lasts self.avg_entry_price_per_base_lot = 0.0; self.quote_running_native = 0; + self.realized_pnl_for_position_native = I80F48::ZERO; // There can't be unrealized pnl without a base position, so fix the // realized_trade_pnl to cover everything that isn't realized_other_pnl. @@ -412,6 +424,9 @@ impl PerpPosition { // Set entry and break-even based on the new_position entered self.avg_entry_price_per_base_lot = new_avg_entry; self.quote_running_native = (-new_position * new_avg_entry) as i64; + + // New position without realized pnl + self.realized_pnl_for_position_native = I80F48::ZERO; } else { // The old and new position have the same sign @@ -438,6 +453,7 @@ impl PerpPosition { newly_realized_pnl = cm!(quote_change_native + I80F48::from(base_change) * avg_entry); cm!(self.realized_trade_pnl_native += newly_realized_pnl); + cm!(self.realized_pnl_for_position_native += newly_realized_pnl); } } @@ -584,7 +600,7 @@ impl PerpPosition { } /// Calculate the PnL of the position for a given price - pub fn pnl_for_price(&self, perp_market: &PerpMarket, price: I80F48) -> Result { + pub fn unsettled_pnl(&self, perp_market: &PerpMarket, price: I80F48) -> Result { require_eq!(self.market_index, perp_market.perp_market_index); let base_native = self.base_position_native(perp_market); let pnl = cm!(self.quote_position_native() + base_native * price); @@ -708,6 +724,7 @@ impl PerpPosition { pub fn record_trading_fee(&mut self, fee: I80F48) { self.change_quote_position(-fee); cm!(self.realized_other_pnl_native -= fee); + cm!(self.realized_pnl_for_position_native -= fee); } /// Adds immediately-settleable realized pnl when a liqor takes over pnl during liquidation @@ -805,6 +822,7 @@ mod tests { assert_eq!(pos.avg_entry_price(&market), 10.0); assert_eq!(pos.break_even_price(&market), 10.0); assert_eq!(pos.realized_trade_pnl_native, I80F48::from(0)); + assert_eq!(pos.realized_pnl_for_position_native, I80F48::from(0)); assert_eq!(pos.settle_pnl_limit_realized_trade, 0); } @@ -818,6 +836,7 @@ mod tests { assert_eq!(pos.avg_entry_price(&market), 10.0); assert_eq!(pos.break_even_price(&market), 10.0); assert_eq!(pos.realized_trade_pnl_native, I80F48::from(0)); + assert_eq!(pos.realized_pnl_for_position_native, I80F48::from(0)); assert_eq!(pos.settle_pnl_limit_realized_trade, 0); } @@ -831,6 +850,7 @@ mod tests { assert_eq!(pos.avg_entry_price(&market), 20.0); assert_eq!(pos.break_even_price(&market), 20.0); assert_eq!(pos.realized_trade_pnl_native, I80F48::from(0)); + assert_eq!(pos.realized_pnl_for_position_native, I80F48::from(0)); assert_eq!(pos.settle_pnl_limit_realized_trade, 0); } @@ -844,6 +864,7 @@ mod tests { assert_eq!(pos.avg_entry_price(&market), 20.0); assert_eq!(pos.break_even_price(&market), 20.0); assert_eq!(pos.realized_trade_pnl_native, I80F48::from(0)); + assert_eq!(pos.realized_pnl_for_position_native, I80F48::from(0)); assert_eq!(pos.settle_pnl_limit_realized_trade, 0); } @@ -857,6 +878,7 @@ mod tests { assert_eq!(pos.avg_entry_price(&market), 10.0); // Entry price remains the same when decreasing assert_eq!(pos.break_even_price(&market), -30.0); // The short can't break even anymore assert_eq!(pos.realized_trade_pnl_native, I80F48::from(-200)); + assert_eq!(pos.realized_pnl_for_position_native, I80F48::from(-200)); assert_eq!(pos.settle_pnl_limit_realized_trade, -5 * 10 / 5 - 1); } @@ -870,6 +892,7 @@ mod tests { assert_eq!(pos.avg_entry_price(&market), 10.0); // Entry price remains the same when decreasing assert_eq!(pos.break_even_price(&market), -30.0); // Already broke even assert_eq!(pos.realized_trade_pnl_native, I80F48::from(200)); + assert_eq!(pos.realized_pnl_for_position_native, I80F48::from(200)); assert_eq!(pos.settle_pnl_limit_realized_trade, 5 * 10 / 5 + 1); } @@ -883,6 +906,7 @@ mod tests { assert_eq!(pos.avg_entry_price(&market), 0.0); // Entry price zero when no position assert_eq!(pos.break_even_price(&market), 0.0); assert_eq!(pos.realized_trade_pnl_native, I80F48::from(150)); + assert_eq!(pos.realized_pnl_for_position_native, I80F48::from(0)); assert_eq!(pos.settle_pnl_limit_realized_trade, 10 * 10 / 5 + 1); } @@ -896,6 +920,7 @@ mod tests { assert_eq!(pos.avg_entry_price(&market), 0.0); // Entry price zero when no position assert_eq!(pos.break_even_price(&market), 0.0); assert_eq!(pos.realized_trade_pnl_native, I80F48::from(-150)); + assert_eq!(pos.realized_pnl_for_position_native, I80F48::from(0)); assert_eq!(pos.settle_pnl_limit_realized_trade, -10 * 10 / 5 - 1); } @@ -909,6 +934,7 @@ mod tests { assert_eq!(pos.avg_entry_price(&market), 20.0); assert_eq!(pos.break_even_price(&market), 20.0); assert_eq!(pos.realized_trade_pnl_native, I80F48::from(100)); + assert_eq!(pos.realized_pnl_for_position_native, I80F48::from(0)); assert_eq!(pos.settle_pnl_limit_realized_trade, 10 * 10 / 5 + 1); } @@ -922,6 +948,7 @@ mod tests { assert_eq!(pos.avg_entry_price(&market), 20.0); assert_eq!(pos.break_even_price(&market), 20.0); assert_eq!(pos.realized_trade_pnl_native, I80F48::from(-100)); + assert_eq!(pos.realized_pnl_for_position_native, I80F48::from(0)); assert_eq!(pos.settle_pnl_limit_realized_trade, -10 * 10 / 5 - 1); } @@ -937,6 +964,7 @@ mod tests { assert_eq!(pos.base_position_lots, 10); assert_eq!(pos.break_even_price(&market), 9_800.0); // We made 2k on the trade, so we can sell our contract up to a loss of 200 each assert_eq!(pos.realized_trade_pnl_native, I80F48::from(2_000)); + assert_eq!(pos.realized_pnl_for_position_native, I80F48::from(2_000)); assert_eq!(pos.settle_pnl_limit_realized_trade, 1 * 10 / 5 + 1); } @@ -956,6 +984,7 @@ mod tests { assert_eq!(pos.avg_entry_price(&market), 10_000.0); assert_eq!(pos.break_even_price(&market), 9_800.0); assert_eq!(pos.realized_trade_pnl_native, I80F48::from(20_000)); + assert_eq!(pos.realized_pnl_for_position_native, I80F48::from(20_000)); } #[test] @@ -968,21 +997,25 @@ mod tests { // Sell 1 @ 11,000 pos.record_trade(&mut market, -1, I80F48::from(11_000)); assert_eq!(pos.realized_trade_pnl_native, I80F48::from(1_000)); + assert_eq!(pos.realized_pnl_for_position_native, I80F48::from(1_000)); assert_eq!(pos.settle_pnl_limit_realized_trade, 1 * 10 / 5 + 1); // Sell 1 @ 11,000 -- increases limit pos.record_trade(&mut market, -1, I80F48::from(11_000)); assert_eq!(pos.realized_trade_pnl_native, I80F48::from(2_000)); + assert_eq!(pos.realized_pnl_for_position_native, I80F48::from(2_000)); assert_eq!(pos.settle_pnl_limit_realized_trade, 2 * (10 / 5 + 1)); // Sell 1 @ 9,000 -- a loss, but doesn't flip realized_trade_pnl_native sign, no change to limit pos.record_trade(&mut market, -1, I80F48::from(9_000)); assert_eq!(pos.realized_trade_pnl_native, I80F48::from(1_000)); + assert_eq!(pos.realized_pnl_for_position_native, I80F48::from(1_000)); assert_eq!(pos.settle_pnl_limit_realized_trade, 2 * (10 / 5 + 1)); // Sell 1 @ 8,000 -- flips sign, changes pnl limit pos.record_trade(&mut market, -1, I80F48::from(8_000)); assert_eq!(pos.realized_trade_pnl_native, I80F48::from(-1_000)); + assert_eq!(pos.realized_pnl_for_position_native, I80F48::from(-1_000)); assert_eq!(pos.settle_pnl_limit_realized_trade, -(1 * 10 / 5 + 1)); } @@ -997,11 +1030,13 @@ mod tests { // Sell 1 @ 10,000 pos.record_trade(&mut market, -1, I80F48::from(10_000)); assert_eq!(pos.realized_trade_pnl_native, I80F48::from(0)); + assert_eq!(pos.realized_pnl_for_position_native, I80F48::from(0)); assert_eq!(pos.settle_pnl_limit_realized_trade, 0); // Sell 10 @ 10,000 pos.record_trade(&mut market, -10, I80F48::from(10 * 10_000)); assert_eq!(pos.realized_trade_pnl_native, I80F48::from(0)); + assert_eq!(pos.realized_pnl_for_position_native, I80F48::from(0)); assert_eq!(pos.settle_pnl_limit_realized_trade, 0); assert_eq!(pos.base_position_lots, 0); @@ -1027,6 +1062,7 @@ mod tests { assert_eq!(pos.realized_other_pnl_native, I80F48::from(100)); assert_eq!(pos.realized_trade_pnl_native, I80F48::from(1_000)); + assert_eq!(pos.realized_pnl_for_position_native, I80F48::from(0)); assert_eq!(pos.settle_pnl_limit_realized_trade, 1 * 10 / 5 + 1); } @@ -1100,15 +1136,15 @@ mod tests { market.base_lot_size = 10; let long_pos = create_perp_position(&market, 50, 100); - let pnl = long_pos.pnl_for_price(&market, I80F48::from(11)).unwrap(); + let pnl = long_pos.unsettled_pnl(&market, I80F48::from(11)).unwrap(); assert_eq!(pnl, I80F48::from(50 * 10 * 1), "long profitable"); - let pnl = long_pos.pnl_for_price(&market, I80F48::from(9)).unwrap(); + let pnl = long_pos.unsettled_pnl(&market, I80F48::from(9)).unwrap(); assert_eq!(pnl, I80F48::from(50 * 10 * -1), "long unprofitable"); let short_pos = create_perp_position(&market, -50, 100); - let pnl = short_pos.pnl_for_price(&market, I80F48::from(11)).unwrap(); + let pnl = short_pos.unsettled_pnl(&market, I80F48::from(11)).unwrap(); assert_eq!(pnl, I80F48::from(50 * 10 * -1), "short unprofitable"); - let pnl = short_pos.pnl_for_price(&market, I80F48::from(9)).unwrap(); + let pnl = short_pos.unsettled_pnl(&market, I80F48::from(9)).unwrap(); assert_eq!(pnl, I80F48::from(50 * 10 * 1), "short profitable"); } diff --git a/programs/mango-v4/tests/test_liq_perps.rs b/programs/mango-v4/tests/test_liq_perps.rs index e4f8214c3..a4443d219 100644 --- a/programs/mango-v4/tests/test_liq_perps.rs +++ b/programs/mango-v4/tests/test_liq_perps.rs @@ -719,10 +719,10 @@ async fn test_liq_perps_base_position_and_bankruptcy() -> Result<(), TransportEr // the remainder got socialized via funding payments let perp_market = solana.get_account::(perp_market).await; let pnl_before = liqee_before.perps[0] - .pnl_for_price(&perp_market, I80F48::ONE) + .unsettled_pnl(&perp_market, I80F48::ONE) .unwrap(); let pnl_after = liqee_after.perps[0] - .pnl_for_price(&perp_market, I80F48::ONE) + .unsettled_pnl(&perp_market, I80F48::ONE) .unwrap(); let socialized_amount = (pnl_after - pnl_before).to_num::() - liq_perp_quote_amount; assert!(assert_equal( diff --git a/programs/mango-v4/tests/test_perp.rs b/programs/mango-v4/tests/test_perp.rs index 8df586e76..9239addc5 100644 --- a/programs/mango-v4/tests/test_perp.rs +++ b/programs/mango-v4/tests/test_perp.rs @@ -1,6 +1,7 @@ #![cfg(all(feature = "test-bpf"))] use anchor_lang::prelude::Pubkey; +use fixed::types::I80F48; use fixed_macro::types::I80F48; use mango_v4::state::*; use program_test::*; @@ -843,6 +844,218 @@ async fn test_perp_oracle_peg() -> Result<(), TransportError> { Ok(()) } +#[tokio::test] +async fn test_perp_realize_partially() -> Result<(), TransportError> { + let context = TestContext::new().await; + let solana = &context.solana.clone(); + + let admin = TestKeypair::new(); + let owner = context.users[0].key; + let payer = context.users[1].key; + let mints = &context.mints[0..2]; + + // + // SETUP: Create a group and an account + // + + let GroupWithTokens { group, tokens, .. } = GroupWithTokensConfig { + admin, + payer, + mints: mints.to_vec(), + ..GroupWithTokensConfig::default() + } + .create(solana) + .await; + + let deposit_amount = 1000; + let account_0 = create_funded_account( + &solana, + group, + owner, + 0, + &context.users[1], + mints, + deposit_amount, + 0, + ) + .await; + let account_1 = create_funded_account( + &solana, + group, + owner, + 1, + &context.users[1], + mints, + deposit_amount, + 0, + ) + .await; + + // + // TEST: Create a perp market + // + let mango_v4::accounts::PerpCreateMarket { perp_market, .. } = send_tx( + solana, + PerpCreateMarketInstruction { + group, + admin, + payer, + perp_market_index: 0, + quote_lot_size: 10, + base_lot_size: 100, + maint_base_asset_weight: 0.975, + init_base_asset_weight: 0.95, + maint_base_liab_weight: 1.025, + init_base_liab_weight: 1.05, + liquidation_fee: 0.012, + maker_fee: 0.0000, + taker_fee: 0.0000, + settle_pnl_limit_factor: -1.0, + settle_pnl_limit_window_size_ts: 24 * 60 * 60, + ..PerpCreateMarketInstruction::with_new_book_and_queue(&solana, &tokens[1]).await + }, + ) + .await + .unwrap(); + + let perp_market_data = solana.get_account::(perp_market).await; + let price_lots = perp_market_data.native_price_to_lot(I80F48!(1000)); + set_perp_stub_oracle_price(solana, group, perp_market, &tokens[1], admin, 1000.0).await; + + // + // SETUP: Place a bid, corresponding ask, and consume event + // + send_tx( + solana, + PerpPlaceOrderInstruction { + account: account_0, + perp_market, + owner, + side: Side::Bid, + price_lots, + max_base_lots: 2, + max_quote_lots: i64::MAX, + reduce_only: false, + client_order_id: 5, + }, + ) + .await + .unwrap(); + + send_tx( + solana, + PerpPlaceOrderInstruction { + account: account_1, + perp_market, + owner, + side: Side::Ask, + price_lots, + max_base_lots: 2, + max_quote_lots: i64::MAX, + reduce_only: false, + client_order_id: 6, + }, + ) + .await + .unwrap(); + + send_tx( + solana, + PerpConsumeEventsInstruction { + perp_market, + mango_accounts: vec![account_0, account_1], + }, + ) + .await + .unwrap(); + + let mango_account_0 = solana.get_account::(account_0).await; + let perp_0 = mango_account_0.perps[0]; + assert_eq!(perp_0.base_position_lots(), 2); + + let mango_account_1 = solana.get_account::(account_1).await; + let perp_1 = mango_account_1.perps[0]; + assert_eq!(perp_1.base_position_lots(), -2); + + // + // SETUP: Sell one lot again at increased price + // + set_perp_stub_oracle_price(solana, group, perp_market, &tokens[1], admin, 1500.0).await; + send_tx( + solana, + PerpPlaceOrderInstruction { + account: account_0, + perp_market, + owner, + side: Side::Ask, + price_lots: perp_market_data.native_price_to_lot(I80F48::from_num(1500)), + max_base_lots: 1, + max_quote_lots: i64::MAX, + reduce_only: false, + client_order_id: 5, + }, + ) + .await + .unwrap(); + + send_tx( + solana, + PerpPlaceOrderInstruction { + account: account_1, + perp_market, + owner, + side: Side::Bid, + price_lots: perp_market_data.native_price_to_lot(I80F48::from_num(1500)), + max_base_lots: 1, + max_quote_lots: i64::MAX, + reduce_only: false, + client_order_id: 6, + }, + ) + .await + .unwrap(); + + send_tx( + solana, + PerpConsumeEventsInstruction { + perp_market, + mango_accounts: vec![account_0, account_1], + }, + ) + .await + .unwrap(); + + let mango_account_0 = solana.get_account::(account_0).await; + let perp_0 = mango_account_0.perps[0]; + assert_eq!(perp_0.base_position_lots(), 1); + assert!(assert_equal( + perp_0.quote_position_native(), + -200_000.0 + 150_000.0, + 0.001 + )); + assert!(assert_equal( + perp_0.realized_pnl_for_position_native, + 50_000.0, + 0.001 + )); + + let mango_account_1 = solana.get_account::(account_1).await; + let perp_1 = mango_account_1.perps[0]; + assert_eq!(perp_1.base_position_lots(), -1); + assert!(assert_equal( + perp_1.quote_position_native(), + 200_000.0 - 150_000.0, + 0.001 + )); + assert!(assert_equal( + perp_1.realized_pnl_for_position_native, + -50_000.0, + 0.001 + )); + + Ok(()) +} + async fn assert_no_perp_orders(solana: &SolanaCookie, account_0: Pubkey) { let mango_account_0 = solana.get_account::(account_0).await; diff --git a/programs/mango-v4/tests/test_perp_settle.rs b/programs/mango-v4/tests/test_perp_settle.rs index 35ccff15d..95c59532a 100644 --- a/programs/mango-v4/tests/test_perp_settle.rs +++ b/programs/mango-v4/tests/test_perp_settle.rs @@ -390,6 +390,14 @@ async fn test_perp_settle_pnl() -> Result<(), TransportError> { mango_account_1.perp_spot_transfers, -expected_total_settle, "net_settled on account 1 updated with loss from settlement" ); + assert_eq!( + mango_account_0.perps[0].perp_spot_transfers, expected_total_settle, + "net_settled on account 0 updated with profit from settlement" + ); + assert_eq!( + mango_account_1.perps[0].perp_spot_transfers, -expected_total_settle, + "net_settled on account 1 updated with loss from settlement" + ); } // Change the oracle to a reasonable price in other direction diff --git a/ts/client/src/accounts/healthCache.spec.ts b/ts/client/src/accounts/healthCache.spec.ts index fe1d13c19..c62124e42 100644 --- a/ts/client/src/accounts/healthCache.spec.ts +++ b/ts/client/src/accounts/healthCache.spec.ts @@ -111,6 +111,7 @@ describe('Health Cache', () => { ZERO_I80F48(), ZERO_I80F48(), new BN(0), + ZERO_I80F48(), ); const pi1 = PerpInfo.fromPerpPosition(pM, pp); @@ -227,6 +228,7 @@ describe('Health Cache', () => { ZERO_I80F48(), ZERO_I80F48(), new BN(0), + ZERO_I80F48(), ); const pi1 = PerpInfo.fromPerpPosition(pM, pp); diff --git a/ts/client/src/accounts/mangoAccount.ts b/ts/client/src/accounts/mangoAccount.ts index 6a2e2b3af..95a174f4a 100644 --- a/ts/client/src/accounts/mangoAccount.ts +++ b/ts/client/src/accounts/mangoAccount.ts @@ -175,7 +175,6 @@ export class MangoAccount { public getPerpPosition( perpMarketIndex: PerpMarketIndex, - useEventQueue?: boolean, ): PerpPosition | undefined { return this.perps.find((pp) => pp.marketIndex == perpMarketIndex); } @@ -1180,6 +1179,7 @@ export class PerpPosition { I80F48.from(dto.realizedTradePnlNative), I80F48.from(dto.realizedOtherPnlNative), dto.settlePnlLimitRealizedTrade, + I80F48.from(dto.realizedPnlForPositionNative), ); } @@ -1208,6 +1208,7 @@ export class PerpPosition { ZERO_I80F48(), ZERO_I80F48(), new BN(0), + ZERO_I80F48(), ); } @@ -1233,12 +1234,17 @@ export class PerpPosition { public realizedTradePnlNative: I80F48, public realizedOtherPnlNative: I80F48, public settlePnlLimitRealizedTrade: BN, + public realizedPnlForPositionNative: I80F48, ) {} isActive(): boolean { return this.marketIndex !== PerpPosition.PerpMarketIndexUnset; } + public getBasePositionNative(perpMarket: PerpMarket): I80F48 { + return I80F48.fromI64(this.basePositionLots.mul(perpMarket.baseLotSize)); + } + public getBasePositionUi( perpMarket: PerpMarket, useEventQueue?: boolean, @@ -1316,13 +1322,15 @@ export class PerpPosition { ); } - public getAverageEntryPriceUi(perpMarket: PerpMarket): number { - if (perpMarket.perpMarketIndex !== this.marketIndex) { - throw new Error("PerpPosition doesn't belong to the given market!"); - } + public getAverageEntryPrice(perpMarket: PerpMarket): I80F48 { + return I80F48.fromNumber(this.avgEntryPricePerBaseLot).mul( + I80F48.fromI64(perpMarket.baseLotSize), + ); + } + public getAverageEntryPriceUi(perpMarket: PerpMarket): number { return perpMarket.priceNativeToUi( - this.avgEntryPricePerBaseLot / perpMarket.baseLotSize.toNumber(), + this.getAverageEntryPrice(perpMarket).toNumber(), ); } @@ -1340,15 +1348,40 @@ export class PerpPosition { ); } - public getPnl(perpMarket: PerpMarket): I80F48 { + public cumulativePnlOverPositionLifetimeUi( + group: Group, + perpMarket: PerpMarket, + ): number { + if (perpMarket.perpMarketIndex !== this.marketIndex) { + throw new Error("PerpPosition doesn't belong to the given market!"); + } + + const priceChange = perpMarket.price.sub( + this.getAverageEntryPrice(perpMarket), + ); + + return toUiDecimals( + this.realizedPnlForPositionNative.add( + this.getBasePositionNative(perpMarket).mul(priceChange), + ), + group.getMintDecimalsByTokenIndex(perpMarket.settleTokenIndex), + ); + } + + public getUnsettledPnl(perpMarket: PerpMarket): I80F48 { if (perpMarket.perpMarketIndex !== this.marketIndex) { throw new Error("PerpPosition doesn't belong to the given market!"); } return this.quotePositionNative.add( - I80F48.fromI64(this.basePositionLots.mul(perpMarket.baseLotSize)).mul( - perpMarket.price, - ), + this.getBasePositionNative(perpMarket).mul(perpMarket.price), + ); + } + + public getUnsettledPnlUi(group: Group, perpMarket: PerpMarket): number { + return toUiDecimals( + this.getUnsettledPnl(perpMarket), + group.getMintDecimalsByTokenIndex(perpMarket.settleTokenIndex), ); } @@ -1432,7 +1465,10 @@ export class PerpPosition { throw new Error("PerpPosition doesn't belong to the given market!"); } - return this.applyPnlSettleLimit(this.getPnl(perpMarket), perpMarket); + return this.applyPnlSettleLimit( + this.getUnsettledPnl(perpMarket), + perpMarket, + ); } } @@ -1459,6 +1495,7 @@ export class PerpPositionDto { public realizedTradePnlNative: I80F48Dto, public realizedOtherPnlNative: I80F48Dto, public settlePnlLimitRealizedTrade: BN, + public realizedPnlForPositionNative: I80F48Dto, ) {} } diff --git a/ts/client/src/mango_v4.ts b/ts/client/src/mango_v4.ts index 4a87cd35f..71cbbec5b 100644 --- a/ts/client/src/mango_v4.ts +++ b/ts/client/src/mango_v4.ts @@ -5365,12 +5365,27 @@ export type MangoV4 = { ], "type": "i64" }, + { + "name": "realizedPnlForPositionNative", + "docs": [ + "Trade pnl, fees, funding that were added over the current position's lifetime.", + "", + "Reset when the position changes sign or goes to zero.", + "Not decreased by settling.", + "", + "This is tracked for display purposes: this value plus the difference between entry", + "price and current price of the base position is the overall pnl." + ], + "type": { + "defined": "I80F48" + } + }, { "name": "reserved", "type": { "array": [ "u8", - 104 + 88 ] } } @@ -13119,12 +13134,27 @@ export const IDL: MangoV4 = { ], "type": "i64" }, + { + "name": "realizedPnlForPositionNative", + "docs": [ + "Trade pnl, fees, funding that were added over the current position's lifetime.", + "", + "Reset when the position changes sign or goes to zero.", + "Not decreased by settling.", + "", + "This is tracked for display purposes: this value plus the difference between entry", + "price and current price of the base position is the overall pnl." + ], + "type": { + "defined": "I80F48" + } + }, { "name": "reserved", "type": { "array": [ "u8", - 104 + 88 ] } } diff --git a/ts/client/src/scripts/mm/taker.ts b/ts/client/src/scripts/mm/taker.ts index c360ea26a..20a7cf7a2 100644 --- a/ts/client/src/scripts/mm/taker.ts +++ b/ts/client/src/scripts/mm/taker.ts @@ -31,7 +31,7 @@ async function settlePnl( const pp = mangoAccount .perpActive() .find((pp) => pp.marketIndex === perpMarket.perpMarketIndex)!; - const pnl = pp.getPnl(perpMarket); + const pnl = pp.getUnsettledPnl(perpMarket); console.log( `Avg entry price - ${pp.getAverageEntryPriceUi(