From 44d0170ea94f5acbbee2023f4f528c4df6ee200f Mon Sep 17 00:00:00 2001 From: microwavedcola1 <89031858+microwavedcola1@users.noreply.github.com> Date: Mon, 21 Nov 2022 20:36:13 +0100 Subject: [PATCH] mc/settle pnl ts client (#292) * oracle peg client support Signed-off-by: microwavedcola1 * Fixes from review Signed-off-by: microwavedcola1 * Fixes from review Signed-off-by: microwavedcola1 * perp pnl settle, candidate finder, and example Signed-off-by: microwavedcola1 Signed-off-by: microwavedcola1 --- anchor | 2 +- client/src/perp_pnl.rs | 1 + ts/client/src/accounts/healthCache.ts | 28 ++++++++ ts/client/src/accounts/mangoAccount.ts | 19 +++++ ts/client/src/accounts/perp.ts | 88 +++++++++++++++++++++++- ts/client/src/scripts/mm/market-maker.ts | 14 ++-- ts/client/src/scripts/mm/taker.ts | 60 ++++++++++++++++ 7 files changed, 205 insertions(+), 7 deletions(-) diff --git a/anchor b/anchor index fbd238fb7..b52f23614 160000 --- a/anchor +++ b/anchor @@ -1 +1 @@ -Subproject commit fbd238fb7c5a99557faa89620499858cb9806d0e +Subproject commit b52f23614601652a99ec6c27aec77bd327363b31 diff --git a/client/src/perp_pnl.rs b/client/src/perp_pnl.rs index 27cddb7a9..105691bb8 100644 --- a/client/src/perp_pnl.rs +++ b/client/src/perp_pnl.rs @@ -14,6 +14,7 @@ pub enum Direction { } /// Returns up to `count` accounts with highest abs pnl (by `direction`) in descending order. +/// Note: keep in sync with perp.ts:getSettlePnlCandidates pub fn fetch_top( context: &crate::context::MangoGroupContext, account_fetcher: &impl AccountFetcher, diff --git a/ts/client/src/accounts/healthCache.ts b/ts/client/src/accounts/healthCache.ts index 5b9c95e9f..9369a15bd 100644 --- a/ts/client/src/accounts/healthCache.ts +++ b/ts/client/src/accounts/healthCache.ts @@ -134,6 +134,34 @@ export class HealthCache { return health; } + // Note: only considers positive perp pnl contributions, see program code for more reasoning + public perpSettleHealth(): I80F48 { + const health = ZERO_I80F48(); + for (const tokenInfo of this.tokenInfos) { + const contrib = tokenInfo.healthContribution(HealthType.maint); + // console.log(` - ti ${contrib}`); + health.iadd(contrib); + } + for (const serum3Info of this.serum3Infos) { + const contrib = serum3Info.healthContribution( + HealthType.maint, + this.tokenInfos, + ); + // console.log(` - si ${contrib}`); + health.iadd(contrib); + } + for (const perpInfo of this.perpInfos) { + if (perpInfo.trustedMarket) { + const positiveContrib = perpInfo + .healthContribution(HealthType.maint) + .max(ZERO_I80F48()); + // console.log(` - pi ${positiveContrib}`); + health.iadd(positiveContrib); + } + } + return health; + } + public assets(healthType: HealthType): I80F48 { const assets = ZERO_I80F48(); for (const tokenInfo of this.tokenInfos) { diff --git a/ts/client/src/accounts/mangoAccount.ts b/ts/client/src/accounts/mangoAccount.ts index fd71e730b..c44041aa9 100644 --- a/ts/client/src/accounts/mangoAccount.ts +++ b/ts/client/src/accounts/mangoAccount.ts @@ -142,6 +142,12 @@ export class MangoAccount { return this.serum3.filter((serum3) => serum3.isActive()); } + public perpPositionExistsForMarket(perpMarket: PerpMarket): boolean { + return this.perps.some( + (pp) => pp.isActive() && pp.marketIndex == perpMarket.perpMarketIndex, + ); + } + public perpActive(): PerpPosition[] { return this.perps.filter((perp) => perp.isActive()); } @@ -268,6 +274,11 @@ export class MangoAccount { return hc.health(healthType); } + public getPerpSettleHealth(group: Group): I80F48 { + const hc = HealthCache.fromMangoAccount(group, this); + return hc.perpSettleHealth(); + } + /** * Health ratio, which is computed so `100 * (assets-liabs)/liabs` * Note: health ratio is technically ∞ if liabs are 0 @@ -1234,6 +1245,14 @@ export class PerpPosition { .div(this.basePositionLots.mul(perpMarket.baseLotSize)) .abs(); } + + public getPnl(perpMarket: PerpMarket): I80F48 { + return this.quotePositionNative.add( + I80F48.fromI64(this.basePositionLots.mul(perpMarket.baseLotSize)).mul( + perpMarket.price, + ), + ); + } } export class PerpPositionDto { diff --git a/ts/client/src/accounts/perp.ts b/ts/client/src/accounts/perp.ts index 2700a917e..37f28fc91 100644 --- a/ts/client/src/accounts/perp.ts +++ b/ts/client/src/accounts/perp.ts @@ -3,9 +3,11 @@ import { utf8 } from '@project-serum/anchor/dist/cjs/utils/bytes'; import { PublicKey } from '@solana/web3.js'; import Big from 'big.js'; import { MangoClient } from '../client'; -import { I80F48, I80F48Dto } from '../numbers/I80F48'; +import { I80F48, I80F48Dto, ZERO_I80F48 } from '../numbers/I80F48'; import { As, toNative, U64_MAX_BN } from '../utils'; import { OracleConfig, QUOTE_DECIMALS, TokenIndex } from './bank'; +import { Group } from './group'; +import { MangoAccount } from './mangoAccount'; export type PerpMarketIndex = number & As<'perp-market-index'>; @@ -329,6 +331,90 @@ export class PerpMarket { return parseFloat(quantity.toString()) * this.quoteLotsToUiConverter; } + /** + * Returns a list of (upto count) accounts, and the pnl that is settle'able on this perp market, + * the list is sorted ascending for 'negative' direction and descending for 'positive' direction. + * + * NOTE: keep in sync with perp_pnl.rs:fetch_top + * + * TODO: replace with a more performant offchain service call + * @param client + * @param group + * @param direction + * @returns + */ + public async getSettlePnlCandidates( + client: MangoClient, + group: Group, + direction: 'negative' | 'positive', + count = 2, + ): Promise<{ account: MangoAccount; settleablePnl: I80F48 }[]> { + let accs = (await client.getAllMangoAccounts(group)) + .filter((acc) => + // need a perp position in this market + acc.perpPositionExistsForMarket(this), + ) + .map((acc) => { + return { + account: acc, + settleablePnl: acc + .perpActive() + .find((pp) => pp.marketIndex === this.perpMarketIndex)! + .getPnl(this), + }; + }); + + accs = accs + .filter( + (acc) => + // need perp positions with -ve pnl to settle +ve pnl and vice versa + (direction === 'negative' && acc.settleablePnl.lt(ZERO_I80F48())) || + (direction === 'positive' && acc.settleablePnl.gt(ZERO_I80F48())), + ) + .sort((a, b) => + direction === 'negative' + ? // most negative + a.settleablePnl.cmp(b.settleablePnl) + : // most positive + b.settleablePnl.cmp(a.settleablePnl), + ); + + if (direction === 'negative') { + let stable = 0; + for (let i = 0; i < accs.length; i++) { + const acc = accs[i]; + const nextPnl = + i + 1 < accs.length ? accs[i + 1].settleablePnl : ZERO_I80F48(); + + const perpSettleHealth = acc.account.getPerpSettleHealth(group); + acc.settleablePnl = + // need positive health to settle against +ve pnl + perpSettleHealth.gt(ZERO_I80F48()) && !acc.account.beingLiquidated + ? // can only settle min + acc.settleablePnl.max(perpSettleHealth.neg()) + : ZERO_I80F48(); + + // If the ordering was unchanged `count` times we know we have the top `count` accounts + if (acc.settleablePnl.lte(nextPnl)) { + stable += 1; + if (stable >= count) { + break; + } + } + } + } + + accs.sort((a, b) => + direction === 'negative' + ? // most negative + a.settleablePnl.cmp(b.settleablePnl) + : // most positive + b.settleablePnl.cmp(a.settleablePnl), + ); + + return accs.slice(0, count); + } + toString(): string { return ( 'PerpMarket ' + diff --git a/ts/client/src/scripts/mm/market-maker.ts b/ts/client/src/scripts/mm/market-maker.ts index 7cab41b32..2c32a947c 100644 --- a/ts/client/src/scripts/mm/market-maker.ts +++ b/ts/client/src/scripts/mm/market-maker.ts @@ -319,10 +319,9 @@ async function fullMarketMaker() { // Calculate pf level values let pfQuoteValue: number | undefined = 0; for (const mc of Array.from(marketContexts.values())) { - const pos = mangoAccount.getPerpPositionUi( - group, - mc.perpMarket.perpMarketIndex, - ); + const pos = mangoAccount.perpPositionExistsForMarket(mc.perpMarket) + ? mangoAccount.getPerpPositionUi(group, mc.perpMarket.perpMarketIndex) + : 0; const mid = (mc.binanceBid! + mc.binanceAsk!) / 2; if (mid) { pfQuoteValue += pos * mid; @@ -405,11 +404,15 @@ async function makeMarketUpdateInstructions( const sizePerc = mc.params.sizePerc; const quoteSize = equity * sizePerc; const size = quoteSize / fairValue; + // console.log(`equity ${equity}`); // console.log(`sizePerc ${sizePerc}`); // console.log(`fairValue ${fairValue}`); // console.log(`size ${size}`); - const basePos = mangoAccount.getPerpPositionUi(group, perpMarketIndex, true); + + const basePos = mangoAccount.perpPositionExistsForMarket(mc.perpMarket) + ? mangoAccount.getPerpPositionUi(group, perpMarketIndex, true) + : 0; const lean = (-leanCoeff * basePos) / size; const pfQuoteLeanCoeff = params.pfQuoteLeanCoeff || 0.001; // How much to move if pf pos is equal to equity const pfQuoteLean = (pfQuoteValue / equity) * -pfQuoteLeanCoeff; @@ -508,6 +511,7 @@ async function makeMarketUpdateInstructions( ); const posAsTradeSizes = basePos / size; + // console.log( // `basePos ${basePos}, posAsTradeSizes ${posAsTradeSizes}, size ${size}`, // ); diff --git a/ts/client/src/scripts/mm/taker.ts b/ts/client/src/scripts/mm/taker.ts index 14e279d48..577eb9198 100644 --- a/ts/client/src/scripts/mm/taker.ts +++ b/ts/client/src/scripts/mm/taker.ts @@ -6,6 +6,7 @@ import { MangoAccount } from '../../accounts/mangoAccount'; import { PerpMarket, PerpOrderSide, PerpOrderType } from '../../accounts/perp'; import { MangoClient } from '../../client'; import { MANGO_V4_ID } from '../../constants'; +import { ZERO_I80F48 } from '../../numbers/I80F48'; import { toUiDecimalsForQuote } from '../../utils'; // For easy switching between mainnet and devnet, default is mainnet @@ -17,6 +18,60 @@ const USER_KEYPAIR = process.env.USER_KEYPAIR_OVERRIDE || process.env.MB_PAYER_KEYPAIR; const MANGO_ACCOUNT_PK = process.env.MANGO_ACCOUNT_PK || ''; +async function settlePnl( + mangoAccount: MangoAccount, + perpMarket: PerpMarket, + client: MangoClient, + group: Group, +) { + if (!mangoAccount.perpPositionExistsForMarket(perpMarket)) { + return; + } + + const pp = mangoAccount + .perpActive() + .find((pp) => pp.marketIndex === perpMarket.perpMarketIndex)!; + const pnl = pp.getPnl(perpMarket); + + let profitableAccount, unprofitableAccount; + + if (pnl.gt(ZERO_I80F48())) { + profitableAccount = mangoAccount; + unprofitableAccount = ( + await perpMarket.getSettlePnlCandidates(client, group, 'negative') + )[0].account; + const sig = await client.perpSettlePnl( + group, + profitableAccount, + unprofitableAccount, + mangoAccount, + perpMarket.perpMarketIndex, + ); + console.log( + `Settled pnl, sig https://explorer.solana.com/tx/${sig}?cluster=${ + CLUSTER == 'devnet' ? 'devnet' : '' + }`, + ); + } else if (pnl.lt(ZERO_I80F48())) { + unprofitableAccount = mangoAccount; + profitableAccount = ( + await perpMarket.getSettlePnlCandidates(client, group, 'positive') + )[0].account; + const sig = await client.perpSettlePnl( + group, + profitableAccount, + unprofitableAccount, + mangoAccount, + perpMarket.perpMarketIndex, + ); + console.log( + `Settled pnl, sig https://explorer.solana.com/tx/${sig}?cluster=${ + CLUSTER == 'devnet' ? 'devnet' : '' + }`, + ); + } +} + async function takeOrder( client: MangoClient, group: Group, @@ -105,6 +160,11 @@ async function main() { // Take on OB const perpMarket = group.getPerpMarketByName('BTC-PERP'); while (true) { + await group.reloadAll(client); + + // Settle pnl + await settlePnl(mangoAccount, perpMarket, client, group); + await takeOrder(client, group, mangoAccount, perpMarket, PerpOrderSide.bid); await takeOrder(client, group, mangoAccount, perpMarket, PerpOrderSide.ask); }