From 5d31d6bf32d09f65015e6fa3aa1de922dc2fa753 Mon Sep 17 00:00:00 2001 From: microwavedcola1 <89031858+microwavedcola1@users.noreply.github.com> Date: Tue, 16 May 2023 19:20:43 +0200 Subject: [PATCH] merge deploy changes to dev (#586) * expose perp order type on perp order Signed-off-by: microwavedcola1 * v0.9.17 * Fix funding rate method Signed-off-by: microwavedcola1 * Fix scrript Signed-off-by: microwavedcola1 * v0.9.18 * ts-client v0.9.19 * fix script * update reduce only and force close flags in ts client Signed-off-by: microwavedcola1 * v0.13.1 * expose underlying property Signed-off-by: microwavedcola1 * v0.13.2 * Fix bug in closing mango account (#559) * reafactor code for collecting health accounts, fix bug where bank oracle was skipped while closing account Signed-off-by: microwavedcola1 * Fixes from review Signed-off-by: microwavedcola1 * Fixes from review Signed-off-by: microwavedcola1 * Fixes from review Signed-off-by: microwavedcola1 * Fixes from review Signed-off-by: microwavedcola1 * Fixes from review Signed-off-by: microwavedcola1 --------- Signed-off-by: microwavedcola1 * v0.13.3 * fix client code for building health accounts Signed-off-by: microwavedcola1 * v0.13.4 * Fix bug in sim max serum3 bid Signed-off-by: microwavedcola1 * v0.13.5 * increase charge Signed-off-by: microwavedcola1 * ts-client v0.14.0 * Fix getBorrowRate() to include loan upkeep * ts-client v0.14.1 * Client: Move jup's CU ix outside of flash loan That makes a flash loan based jup swap usable with delegates. * liquidator: Don't attempt to close in-use token positions This could happen if the user manually used serum on the liquidator account. * Mc/ci cd (#570) * prettier Signed-off-by: microwavedcola1 * Fix branch Signed-off-by: microwavedcola1 --------- Signed-off-by: microwavedcola1 * rename Signed-off-by: microwavedcola1 * Increase iterations for max swap to fix some edge case, fix debug script since fees are already accounted for Signed-off-by: microwavedcola1 * v0.14.2 * Risk notification bot (#565) * risk stuff Signed-off-by: microwavedcola1 * Fixes from review Signed-off-by: microwavedcola1 * Fixes from review Signed-off-by: microwavedcola1 * Fixes from review Signed-off-by: microwavedcola1 * cleanup Signed-off-by: microwavedcola1 * client function Signed-off-by: microwavedcola1 --------- Signed-off-by: microwavedcola1 * fix Signed-off-by: microwavedcola1 * v0.15.0 * fix risk computati Signed-off-by: microwavedcola1 * Fixes from review Signed-off-by: microwavedcola1 * v0.15.2 * Fix units Signed-off-by: microwavedcola1 * dont drop or rally stable assets Signed-off-by: microwavedcola1 * dont skip usdc Signed-off-by: microwavedcola1 * Fixes from review Signed-off-by: microwavedcola1 * v0.15.3 * Fix Signed-off-by: microwavedcola1 * v0.15.4 * update Signed-off-by: microwavedcola1 * v0.15.5 * update Signed-off-by: microwavedcola1 * v0.15.6 * add highlight Signed-off-by: microwavedcola1 * v0.15.7 * Fix math Signed-off-by: microwavedcola1 * Fix Signed-off-by: microwavedcola1 * v0.15.10 * Fix Signed-off-by: microwavedcola1 * v0.15.12 --------- Signed-off-by: microwavedcola1 Co-authored-by: Christian Kamm --- package.json | 2 +- ts/client/scripts/mm/params/default.json | 6 +- ts/client/scripts/risk.ts | 414 +---------------------- ts/client/src/accounts/bank.ts | 17 +- ts/client/src/accounts/mangoAccount.ts | 2 +- ts/client/src/client.ts | 50 +-- ts/client/src/risk.ts | 284 ++++++++++++---- 7 files changed, 272 insertions(+), 503 deletions(-) diff --git a/package.json b/package.json index 8c038be53..25f26ba85 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@blockworks-foundation/mango-v4", - "version": "0.13.1", + "version": "0.15.12", "description": "Typescript Client for mango-v4 program.", "repository": "https://github.com/blockworks-foundation/mango-v4", "author": { diff --git a/ts/client/scripts/mm/params/default.json b/ts/client/scripts/mm/params/default.json index b29f8306a..b677439e8 100644 --- a/ts/client/scripts/mm/params/default.json +++ b/ts/client/scripts/mm/params/default.json @@ -11,7 +11,7 @@ "requoteThresh": 0.0002, "takeSpammers": true, "spammerCharge": 2, - "charge": 0.002, + "charge": 0.003, "krakenCode": "XXBTZUSD" } }, @@ -23,7 +23,7 @@ "requoteThresh": 0.0002, "takeSpammers": true, "spammerCharge": 2, - "charge": 0.002, + "charge": 0.003, "krakenCode": "SOLUSD" } }, @@ -35,7 +35,7 @@ "requoteThresh": 0.0002, "takeSpammers": true, "spammerCharge": 2, - "charge": 0.002, + "charge": 0.003, "krakenCode": "XETHZUSD" } } diff --git a/ts/client/scripts/risk.ts b/ts/client/scripts/risk.ts index cda420a07..fd46cccff 100644 --- a/ts/client/scripts/risk.ts +++ b/ts/client/scripts/risk.ts @@ -1,14 +1,8 @@ -import { AnchorProvider, BN, Wallet } from '@coral-xyz/anchor'; +import { AnchorProvider, Wallet } from '@coral-xyz/anchor'; import { Connection, Keypair, PublicKey } from '@solana/web3.js'; -import { Table } from 'console-table-printer'; -import cloneDeep from 'lodash/cloneDeep'; -import fetch from 'node-fetch'; -import { Group } from '../src/accounts/group'; -import { HealthType, MangoAccount } from '../src/accounts/mangoAccount'; import { MangoClient } from '../src/client'; import { MANGO_V4_ID } from '../src/constants'; -import { I80F48, ONE_I80F48, ZERO_I80F48 } from '../src/numbers/I80F48'; -import { toUiDecimals, toUiDecimalsForQuote } from '../src/utils'; +import { getRiskStats } from '../src/risk'; const { MB_CLUSTER_URL } = process.env; @@ -33,408 +27,14 @@ async function buildClient(): Promise { ); } -async function computePriceImpactOnJup( - amount: string, - inputMint: string, - outputMint: string, -): Promise<{ outAmount: number; priceImpactPct: number }> { - const url = `https://quote-api.jup.ag/v4/quote?inputMint=${inputMint}&outputMint=${outputMint}&amount=${amount}&swapMode=ExactIn&slippageBps=10000&onlyDirectRoutes=false&asLegacyTransaction=false`; - const response = await fetch(url); - - try { - let res = await response.json(); - res = res.data[0]; - return { - outAmount: parseFloat(res.outAmount), - priceImpactPct: parseFloat(res.priceImpactPct), - }; - } catch (e) { - console.log(url); - console.log(e); - throw e; - } -} - -async function computePriceImpactForLiqor( - group: Group, - mangoAccounts: MangoAccount[], - healthThresh: number, - title: string, -): Promise { - // Filter mango accounts below a certain health ration threshold - const mangoAccountsWithHealth = mangoAccounts - .map((a: MangoAccount) => { - return { - account: a, - health: a.getHealth(group, HealthType.liquidationEnd), - healthRatio: a.getHealthRatioUi(group, HealthType.liquidationEnd), - liabs: toUiDecimalsForQuote( - a.getLiabsValue(group, HealthType.liquidationEnd), - ), - }; - }) - .filter((a) => a.healthRatio < healthThresh); - - const table = new Table({ - columns: [ - { name: 'Coin', alignment: 'right' }, - { name: 'Oracle Price', alignment: 'right' }, - { name: 'On-Chain Price', alignment: 'right' }, - { name: 'Future Price', alignment: 'right' }, - { name: 'V4 Liq Fee', alignment: 'right' }, - { name: 'Liabs', alignment: 'right' }, - { name: 'Liabs slippage', alignment: 'right' }, - { name: 'Assets Sum', alignment: 'right' }, - { name: 'Assets Slippage', alignment: 'right' }, - ], - }); - - const USDC_MINT = 'EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v'; - const usdcBank = group.getFirstBankByMint(new PublicKey(USDC_MINT)); - - // For each token - for (const banks of group.banksMapByMint.values()) { - const bank = banks[0]; - - const onChainPrice = ( - await ( - await fetch(`https://price.jup.ag/v4/price?ids=${bank.mint}`) - ).json() - )['data'][bank.mint.toBase58()]['price']; - - if (bank.tokenIndex === usdcBank.tokenIndex) { - continue; - } - - // Sum of all liabs, these liabs would be acquired by liqor, - // who would immediately want to reduce them to 0 - // Assuming liabs need to be bought using USDC - const liabs = - // Max liab of a particular token that would be liquidated to bring health above 0 - mangoAccountsWithHealth.reduce((sum, a) => { - // How much would health increase for every unit liab moved to liqor - // liabprice * (liabweight - (1+fee)*assetweight) - const tokenLiabHealthContrib = bank.price.mul( - bank.initLiabWeight.sub( - ONE_I80F48().add(bank.liquidationFee).mul(usdcBank.initAssetWeight), - ), - ); - // Abs liab/borrow - const maxTokenLiab = a.account - .getEffectiveTokenBalance(group, bank) - .min(ZERO_I80F48()) - .abs(); - // Health under 0 - const maxLiab = a.health - .min(ZERO_I80F48()) - .abs() - .div(tokenLiabHealthContrib) - .min(maxTokenLiab); - - return sum.add(maxLiab); - }, ZERO_I80F48()); - const liabsInUsdc = - // convert to usdc, this is an approximation - liabs - .mul(bank.price) - .floor() - // jup oddity - .min(I80F48.fromNumber(99999999999)); - const pi1 = !liabsInUsdc.eq(ZERO_I80F48()) - ? await computePriceImpactOnJup( - liabsInUsdc.toString(), - USDC_MINT, - bank.mint.toBase58(), - ) - : { priceImpactPct: 0, outAmount: 0 }; - - // Sum of all assets which would be acquired in exchange for also acquiring - // liabs by the liqor, who would immediately want to reduce to 0 - // Assuming assets need to be sold to USDC - const assets = mangoAccountsWithHealth.reduce((sum, a) => { - // How much would health increase for every unit liab moved to liqor - // assetprice * (liabweight/(1+liabliqfee) - assetweight) - const liabBank = Array.from(group.banksMapByTokenIndex.values()) - .flat() - .reduce((prev, curr) => - prev.initLiabWeight.lt(curr.initLiabWeight) ? prev : curr, - ); - const tokenAssetHealthContrib = bank.price.mul( - liabBank.initLiabWeight - .div(ONE_I80F48().add(liabBank.liquidationFee)) - .sub(bank.initAssetWeight), - ); - // Abs collateral/asset - const maxTokenHealthAsset = a.account - .getEffectiveTokenBalance(group, bank) - .max(ZERO_I80F48()); - const maxAsset = a.health - .min(ZERO_I80F48()) - .abs() - .div(tokenAssetHealthContrib) - .min(maxTokenHealthAsset); - - return sum.add(maxAsset); - }, ZERO_I80F48()); - - const pi2 = !assets.eq(ZERO_I80F48()) - ? await computePriceImpactOnJup( - assets.floor().toString(), - bank.mint.toBase58(), - USDC_MINT, - ) - : { priceImpactPct: 0 }; - - table.addRow({ - Coin: bank.name, - 'Oracle Price': - bank['oldUiPrice'] < 0.1 - ? bank['oldUiPrice'] - : bank['oldUiPrice'].toFixed(2), - 'On-Chain Price': - onChainPrice < 0.1 ? onChainPrice : onChainPrice.toFixed(2), - 'Future Price': - bank._uiPrice! < 0.1 ? bank._uiPrice! : bank._uiPrice!.toFixed(2), - - 'V4 Liq Fee': (bank.liquidationFee.toNumber() * 100).toFixed(2) + '%', - Liabs: toUiDecimalsForQuote(liabsInUsdc).toLocaleString() + '$', - 'Liabs slippage': (pi1.priceImpactPct * 100).toFixed(2) + '%', - 'Assets Sum': - ( - toUiDecimals(assets, bank.mintDecimals) * bank.uiPrice - ).toLocaleString() + '$', - 'Assets Slippage': (pi2.priceImpactPct * 100).toFixed(2) + '%', - }); - } - - const msg = title + '\n```\n' + table.render() + '\n```'; - console.log(msg); - console.log(); -} - -async function computePerpPositionsToBeLiquidated( - group: Group, - mangoAccounts: MangoAccount[], - healthThresh: number, - title: string, -): Promise { - const mangoAccountsWithHealth = mangoAccounts - .map((a: MangoAccount) => { - return { - account: a, - health: a.getHealth(group, HealthType.liquidationEnd), - healthRatio: a.getHealthRatioUi(group, HealthType.liquidationEnd), - liabs: toUiDecimalsForQuote( - a.getLiabsValue(group, HealthType.liquidationEnd), - ), - }; - }) - .filter((a) => a.healthRatio < healthThresh); - - const table = new Table({ - columns: [ - { name: 'Market', alignment: 'right' }, - { name: 'Price', alignment: 'right' }, - { name: 'Future Price', alignment: 'right' }, - { name: 'Notional Position', alignment: 'right' }, - ], - }); - - for (const pm of Array.from( - group.perpMarketsMapByMarketIndex.values(), - ).filter((pm) => !pm.name.includes('OLD'))) { - const baseLots = mangoAccountsWithHealth - .filter((a) => a.account.getPerpPosition(pm.perpMarketIndex)) - .reduce((sum, a) => { - const baseLots = a.account.getPerpPosition( - pm.perpMarketIndex, - )!.basePositionLots; - const unweightedHealthPerLot = baseLots.gt(new BN(0)) - ? I80F48.fromNumber(-1) - .mul(pm.price) - .mul(I80F48.fromU64(pm.baseLotSize)) - .mul(pm.initBaseAssetWeight) - .add( - I80F48.fromU64(pm.baseLotSize) - .mul(pm.price) - .mul( - ONE_I80F48() // quoteInitAssetWeight - .mul(ONE_I80F48().sub(pm.baseLiquidationFee)), - ), - ) - : pm.price - .mul(I80F48.fromU64(pm.baseLotSize)) - .mul(pm.initBaseLiabWeight) - .sub( - I80F48.fromU64(pm.baseLotSize) - .mul(pm.price) - .mul(ONE_I80F48()) // quoteInitLiabWeight - .mul(ONE_I80F48().add(pm.baseLiquidationFee)), - ); - - const maxBaseLots = a.health - .min(ZERO_I80F48()) - .abs() - .div(unweightedHealthPerLot.abs()) - .min(I80F48.fromU64(baseLots).abs()); - - return sum.add(maxBaseLots); - }, ONE_I80F48()); - - const notionalPositionUi = toUiDecimalsForQuote( - baseLots.mul(I80F48.fromU64(pm.baseLotSize).mul(pm.price)), - ); - - table.addRow({ - Market: pm.name, - Price: - pm['oldUiPrice'] < 0.1 ? pm['oldUiPrice'] : pm['oldUiPrice'].toFixed(2), - 'Future Price': - pm._uiPrice! < 0.1 ? pm._uiPrice! : pm._uiPrice!.toFixed(2), - 'Notional Position': notionalPositionUi.toLocaleString() + '$', - }); - } - const msg = title + '\n```\n' + table.render() + '\n```'; - console.log(msg); - console.log(); -} - -async function logLiqorEquity( - client: MangoClient, - group: Group, - mangoAccounts: PublicKey[], - title: string, -): Promise { - const table = new Table({ - columns: [ - { name: 'Account', alignment: 'right' }, - { name: 'Equity', alignment: 'right' }, - ], - }); - - // Filter mango accounts which might be closed - const liqors = ( - await client.connection.getMultipleAccountsInfo(mangoAccounts) - ) - .map((ai, i) => { - return { ai: ai, pk: mangoAccounts[i] }; - }) - .filter((val) => val.ai) - .map((val) => val.pk); - const liqorMangoAccounts = await Promise.all( - liqors.map((liqor) => client.getMangoAccount(liqor, true)), - ); - liqorMangoAccounts.forEach((a: MangoAccount) => { - table.addRow({ - Account: a.publicKey.toBase58(), - Equity: toUiDecimalsForQuote(a.getEquity(group)).toLocaleString() + '$', - }); - }); - const msg = title + '\n```\n' + table.render() + '\n```'; - console.log(msg); - console.log(); -} - async function main(): Promise { const client = await buildClient(); const group = await client.getGroup(new PublicKey(GROUP_PK)); - const mangoAccounts = await client.getAllMangoAccounts(group, true); - - const change = 0.4; - - const drop = 1 - change; - const groupBear: Group = cloneDeep(group); - Array.from(groupBear.banksMapByTokenIndex.values()) - .flat() - .forEach((b) => { - b['oldUiPrice'] = b._uiPrice; - b._uiPrice = b._uiPrice! * drop; - b._price = b._price?.mul(I80F48.fromNumber(drop)); - }); - Array.from(groupBear.perpMarketsMapByMarketIndex.values()).forEach((p) => { - p['oldUiPrice'] = p._uiPrice; - p._uiPrice = p._uiPrice! * drop; - p._price = p._price?.mul(I80F48.fromNumber(drop)); - }); - - const rally = 1 + change; - const groupBull: Group = cloneDeep(group); - Array.from(groupBull.banksMapByTokenIndex.values()) - .flat() - .forEach((b) => { - b['oldUiPrice'] = b._uiPrice; - b._uiPrice = b._uiPrice! * rally; - b._price = b._price?.mul(I80F48.fromNumber(rally)); - }); - Array.from(groupBull.perpMarketsMapByMarketIndex.values()).forEach((p) => { - p['oldUiPrice'] = p._uiPrice; - p._uiPrice = p._uiPrice! * rally; - p._price = p._price?.mul(I80F48.fromNumber(rally)); - }); - - const healthThresh = 0; - - let tableName = `Liqors acquire liabs and assets. The assets and liabs are sum of max assets and max - liabs for any token which would be liquidated to fix the health of a mango account. - This would be the slippage they would face on buying-liabs/offloading-assets tokens acquired from unhealth accounts after a`; - await computePriceImpactForLiqor( - groupBear, - mangoAccounts, - healthThresh, - `Table 1a: ${tableName} 20% drop`, - ); - await computePriceImpactForLiqor( - groupBull, - mangoAccounts, - healthThresh, - `Table 1b: ${tableName} 20% rally`, - ); - - tableName = 'Perp notional that liqor need to liquidate after a '; - await computePerpPositionsToBeLiquidated( - groupBear, - mangoAccounts, - healthThresh, - `Table 2a: ${tableName} 20% drop`, - ); - await computePerpPositionsToBeLiquidated( - groupBull, - mangoAccounts, - healthThresh, - `Table 2b: ${tableName} 20% rally`, - ); - - await logLiqorEquity( - client, - group, - ( - await ( - await fetch( - `https://api.mngo.cloud/data/v4/stats/liqors-over_period?over_period=1MONTH`, // alternative - 1WEEK, - ) - ).json() - ).map((data) => new PublicKey(data['liqor'])), - `Table 3: Equity of known liqors from last month`, - ); - - await logLiqorEquity( - client, - group, - [ - new PublicKey('CtHuPg2ctVVV7nqmvVEcMtcWyJAgtZw9YcNHFQidjPgF'), - new PublicKey('F1SZxEDxxCSLVjEBbMEjDYqajWRJQRCZBwPQnmcVvTLV'), - new PublicKey('BGYWnqfaauCeebFQXEfYuDCktiVG8pqpprrsD4qfqL53'), - new PublicKey('9XJt2tvSZghsMAhWto1VuPBrwXsiimPtsTR8XwGgDxK2'), - ], - `Table 4: Equity of known makers from last month`, - ); - - // TODO warning when wrapper asset on chain price has too much difference to oracle - // TODO warning when slippage is higher than liquidation fee - // TODO warning when liqors equity is too low - // TODO warning when mm equity is too low - - // TODO all awaits are linear, should be parallelised to speed up script + try { + console.log(JSON.stringify(await getRiskStats(client, group), null, 2)); + } catch (error) { + console.log(error); + } } main(); diff --git a/ts/client/src/accounts/bank.ts b/ts/client/src/accounts/bank.ts index ccd566fa6..9ee9e9d34 100644 --- a/ts/client/src/accounts/bank.ts +++ b/ts/client/src/accounts/bank.ts @@ -407,18 +407,15 @@ export class Bank implements BankForHealth { /** * - * @returns borrow rate, 0 is 0% where 1 is 100% + * @returns borrow rate, 0 is 0% where 1 is 100%; not including loan upkeep rate */ - getBorrowRate(): I80F48 { + getBorrowRateWithoutUpkeepRate(): I80F48 { const totalBorrows = this.nativeBorrows(); const totalDeposits = this.nativeDeposits(); if (totalDeposits.isZero() && totalBorrows.isZero()) { return ZERO_I80F48(); } - if (totalDeposits.lte(totalBorrows)) { - return this.maxRate; - } const utilization = totalBorrows.div(totalDeposits); if (utilization.lte(this.util0)) { @@ -439,7 +436,15 @@ export class Bank implements BankForHealth { /** * - * @returns borrow rate percentage + * @returns total borrow rate, 0 is 0% where 1 is 100% (including loan upkeep rate) + */ + getBorrowRate(): I80F48 { + return this.getBorrowRateWithoutUpkeepRate().add(this.loanFeeRate); + } + + /** + * + * @returns total borrow rate percentage (including loan upkeep rate) */ getBorrowRateUi(): number { return this.getBorrowRate().toNumber() * 100; diff --git a/ts/client/src/accounts/mangoAccount.ts b/ts/client/src/accounts/mangoAccount.ts index 4e1c220b5..1c26d4530 100644 --- a/ts/client/src/accounts/mangoAccount.ts +++ b/ts/client/src/accounts/mangoAccount.ts @@ -717,7 +717,7 @@ export class MangoAccount { quoteAmount = quoteAmount.div( ONE_I80F48().add(I80F48.fromNumber(serum3Market.getFeeRates(true))), ); - return toUiDecimals(nativeAmount, quoteBank.mintDecimals); + return toUiDecimals(quoteAmount, quoteBank.mintDecimals); } /** diff --git a/ts/client/src/client.ts b/ts/client/src/client.ts index db8ee5451..a89331bd0 100644 --- a/ts/client/src/client.ts +++ b/ts/client/src/client.ts @@ -1582,12 +1582,8 @@ export class MangoClient { const baseTokenIndex = serum3Market.baseTokenIndex; const quoteTokenIndex = serum3Market.quoteTokenIndex; // only include banks if no deposit has been previously made for same token - if (!mangoAccount.getToken(baseTokenIndex)?.isActive()) { - banks.push(group.getFirstBankByTokenIndex(baseTokenIndex)); - } - if (!mangoAccount.getToken(quoteTokenIndex)?.isActive()) { - banks.push(group.getFirstBankByTokenIndex(quoteTokenIndex)); - } + banks.push(group.getFirstBankByTokenIndex(quoteTokenIndex)); + banks.push(group.getFirstBankByTokenIndex(baseTokenIndex)); } const healthRemainingAccounts: PublicKey[] = @@ -3113,11 +3109,9 @@ export class MangoClient { ): PublicKey[] { const healthRemainingAccounts: PublicKey[] = []; - const tokenPositionIndices = uniq( - mangoAccounts - .map((mangoAccount) => mangoAccount.tokens.map((t) => t.tokenIndex)) - .flat(), - ); + const tokenPositionIndices = mangoAccounts + .map((mangoAccount) => mangoAccount.tokens.map((t) => t.tokenIndex)) + .flat(); for (const bank of banks) { const tokenPositionExists = tokenPositionIndices.indexOf(bank.tokenIndex) > -1; @@ -3130,9 +3124,14 @@ export class MangoClient { } } } - const mintInfos = tokenPositionIndices - .filter((tokenIndex) => tokenIndex !== TokenPosition.TokenIndexUnset) - .map((tokenIndex) => group.mintInfosMapByTokenIndex.get(tokenIndex)!); + const mintInfos = uniq( + tokenPositionIndices + .filter((tokenIndex) => tokenIndex !== TokenPosition.TokenIndexUnset) + .map((tokenIndex) => group.mintInfosMapByTokenIndex.get(tokenIndex)!), + (mintInfo) => { + mintInfo.tokenIndex; + }, + ); healthRemainingAccounts.push( ...mintInfos.map((mintInfo) => mintInfo.firstBank()), ); @@ -3141,11 +3140,9 @@ export class MangoClient { ); // Insert any extra perp markets in the free perp position slots - const perpPositionsMarketIndices = uniq( - mangoAccounts - .map((mangoAccount) => mangoAccount.perps.map((p) => p.marketIndex)) - .flat(), - ); + const perpPositionsMarketIndices = mangoAccounts + .map((mangoAccount) => mangoAccount.perps.map((p) => p.marketIndex)) + .flat(); for (const perpMarket of perpMarkets) { const perpPositionExists = perpPositionsMarketIndices.indexOf(perpMarket.perpMarketIndex) > -1; @@ -3159,12 +3156,15 @@ export class MangoClient { } } } - const allPerpMarkets = perpPositionsMarketIndices - .filter( - (perpMarktIndex) => - perpMarktIndex !== PerpPosition.PerpMarketIndexUnset, - ) - .map((perpIdx) => group.getPerpMarketByMarketIndex(perpIdx)!); + const allPerpMarkets = uniq( + perpPositionsMarketIndices + .filter( + (perpMarktIndex) => + perpMarktIndex !== PerpPosition.PerpMarketIndexUnset, + ) + .map((perpIdx) => group.getPerpMarketByMarketIndex(perpIdx)!), + (pm) => pm.perpMarketIndex, + ); healthRemainingAccounts.push( ...allPerpMarkets.map((perp) => perp.publicKey), ); diff --git a/ts/client/src/risk.ts b/ts/client/src/risk.ts index a40eb5fda..f98f89203 100644 --- a/ts/client/src/risk.ts +++ b/ts/client/src/risk.ts @@ -22,32 +22,34 @@ async function buildFetch(): Promise< } export interface LiqorPriceImpact { - Coin: string; - 'Oracle Price': number; - 'On-Chain Price': number; - 'Future Price': number; - 'V4 Liq Fee': number; - Liabs: number; - 'Liabs slippage': number; - Assets: number; - 'Assets Slippage': number; + Coin: { val: string; highlight: boolean }; + 'Oracle Price': { val: number; highlight: boolean }; + 'Jup Price': { val: number; highlight: boolean }; + 'Future Price': { val: number; highlight: boolean }; + 'V4 Liq Fee': { val: number; highlight: boolean }; + Liabs: { val: number; highlight: boolean }; + 'Liabs Slippage': { val: number; highlight: boolean }; + Assets: { val: number; highlight: boolean }; + 'Assets Slippage': { val: number; highlight: boolean }; } export interface PerpPositionsToBeLiquidated { - Market: string; - Price: number; - 'Future Price': number; - 'Notional Position': number; + Market: { val: string; highlight: boolean }; + Price: { val: number; highlight: boolean }; + 'Future Price': { val: number; highlight: boolean }; + 'Notional Position': { val: number; highlight: boolean }; } export interface AccountEquity { - Account: PublicKey; - Equity: number; + Account: { val: PublicKey; highlight: boolean }; + Equity: { val: number; highlight: boolean }; } export interface Risk { assetRally: { title: string; data: LiqorPriceImpact[] }; assetDrop: { title: string; data: LiqorPriceImpact[] }; + usdcDepeg: { title: string; data: LiqorPriceImpact[] }; + usdtDepeg: { title: string; data: LiqorPriceImpact[] }; perpRally: { title: string; data: PerpPositionsToBeLiquidated[] }; perpDrop: { title: string; data: PerpPositionsToBeLiquidated[] }; marketMakerEquity: { title: string; data: AccountEquity[] }; @@ -63,18 +65,42 @@ export async function computePriceImpactOnJup( const response = await (await buildFetch())(url); try { - let res = await response.json(); - res = res.data[0]; - return { - outAmount: parseFloat(res.outAmount), - priceImpactPct: parseFloat(res.priceImpactPct), - }; + const res = await response.json(); + if (res['data'] && res.data.length > 0 && res.data[0].outAmount) { + return { + outAmount: parseFloat(res.data[0].outAmount), + priceImpactPct: parseFloat(res.data[0].priceImpactPct), + }; + } else { + return { + outAmount: -1 / 10000, + priceImpactPct: -1 / 10000, + }; + } } catch (e) { console.log(e); - throw e; + return { + outAmount: -1 / 10000, + priceImpactPct: -1 / 10000, + }; } } +export async function getOnChainPriceForMints( + mints: string[], +): Promise { + return await Promise.all( + mints.map(async (mint) => { + let data = await ( + await buildFetch() + )(`https://price.jup.ag/v4/price?ids=${mint}`); + data = await data.json(); + data = data['data']; + return data[mint]['price']; + }), + ); +} + export async function getPriceImpactForLiqor( group: Group, mangoAccounts: MangoAccount[], @@ -95,7 +121,7 @@ export async function getPriceImpactForLiqor( return await Promise.all( Array.from(group.banksMapByMint.values()) - .filter((banks) => banks[0].tokenIndex !== usdcBank.tokenIndex) + .sort((a, b) => a[0].name.localeCompare(b[0].name)) .map(async (banks) => { const bank = banks[0]; @@ -107,11 +133,28 @@ export async function getPriceImpactForLiqor( mangoAccountsWithHealth.reduce((sum, a) => { // How much would health increase for every unit liab moved to liqor // liabprice * (liabweight - (1+fee)*assetweight) + // Choose the most valuable asset the user has + const assetBank = Array.from(group.banksMapByTokenIndex.values()) + .flat() + .reduce((prev, curr) => + prev.initAssetWeight + .mul(a.account.getEffectiveTokenBalance(group, prev)) + .mul(prev._price!) + .gt( + curr.initAssetWeight.mul( + a.account + .getEffectiveTokenBalance(group, curr) + .mul(curr._price!), + ), + ) + ? prev + : curr, + ); const tokenLiabHealthContrib = bank.price.mul( bank.initLiabWeight.sub( ONE_I80F48() .add(bank.liquidationFee) - .mul(usdcBank.initAssetWeight), + .mul(assetBank.initAssetWeight), ), ); // Abs liab/borrow @@ -119,6 +162,11 @@ export async function getPriceImpactForLiqor( .getEffectiveTokenBalance(group, bank) .min(ZERO_I80F48()) .abs(); + + if (tokenLiabHealthContrib.eq(ZERO_I80F48())) { + return sum.add(maxTokenLiab); + } + // Health under 0 const maxLiab = a.health .min(ZERO_I80F48()) @@ -142,20 +190,38 @@ export async function getPriceImpactForLiqor( const assets = mangoAccountsWithHealth.reduce((sum, a) => { // How much would health increase for every unit liab moved to liqor // assetprice * (liabweight/(1+liabliqfee) - assetweight) + // Choose the smallest liability the user has const liabBank = Array.from(group.banksMapByTokenIndex.values()) .flat() .reduce((prev, curr) => - prev.initLiabWeight.lt(curr.initLiabWeight) ? prev : curr, + prev.initLiabWeight + .mul(a.account.getEffectiveTokenBalance(group, prev)) + .mul(prev._price!) + .lt( + curr.initLiabWeight.mul( + a.account + .getEffectiveTokenBalance(group, curr) + .mul(curr._price!), + ), + ) + ? prev + : curr, ); const tokenAssetHealthContrib = bank.price.mul( liabBank.initLiabWeight .div(ONE_I80F48().add(liabBank.liquidationFee)) .sub(bank.initAssetWeight), ); + // Abs collateral/asset const maxTokenHealthAsset = a.account .getEffectiveTokenBalance(group, bank) .max(ZERO_I80F48()); + + if (tokenAssetHealthContrib.eq(ZERO_I80F48())) { + return sum.add(maxTokenHealthAsset); + } + const maxAsset = a.health .min(ZERO_I80F48()) .abs() @@ -165,16 +231,7 @@ export async function getPriceImpactForLiqor( return sum.add(maxAsset); }, ZERO_I80F48()); - let data; - data = await ( - await buildFetch() - )(`https://price.jup.ag/v4/price?ids=${bank.mint}`); - data = await data.json(); - data = data['data']; - - const [onChainPrice, pi1, pi2] = await Promise.all([ - data[bank.mint.toBase58()]['price'], - + const [pi1, pi2] = await Promise.all([ !liabsInUsdc.eq(ZERO_I80F48()) ? computePriceImpactOnJup( liabsInUsdc.toString(), @@ -193,15 +250,50 @@ export async function getPriceImpactForLiqor( ]); return { - Coin: bank.name, - 'Oracle Price': bank['oldUiPrice'], - 'On-Chain Price': onChainPrice, - 'Future Price': bank._uiPrice!, - 'V4 Liq Fee': bank.liquidationFee.toNumber() * 100, - Liabs: toUiDecimalsForQuote(liabsInUsdc), - 'Liabs slippage': pi1.priceImpactPct * 100, - Assets: toUiDecimals(assets, bank.mintDecimals) * bank.uiPrice, - 'Assets Slippage': pi2.priceImpactPct * 100, + Coin: { val: bank.name, highlight: false }, + 'Oracle Price': { + val: bank['oldUiPrice'] ? bank['oldUiPrice'] : bank._uiPrice!, + highlight: false, + }, + 'Jup Price': { + val: bank['onChainPrice'], + highlight: + Math.abs( + (bank['onChainPrice'] - + (bank['oldUiPrice'] ? bank['oldUiPrice'] : bank._uiPrice!)) / + (bank['oldUiPrice'] ? bank['oldUiPrice'] : bank._uiPrice!), + ) > 0.05, + }, + 'Future Price': { val: bank._uiPrice!, highlight: false }, + 'V4 Liq Fee': { + val: Math.round(bank.liquidationFee.toNumber() * 10000), + highlight: false, + }, + Liabs: { + val: Math.round(toUiDecimalsForQuote(liabsInUsdc)), + highlight: Math.round(toUiDecimalsForQuote(liabsInUsdc)) > 5000, + }, + 'Liabs Slippage': { + val: Math.round(pi1.priceImpactPct * 10000), + highlight: + Math.round(pi1.priceImpactPct * 10000) > + Math.round(bank.liquidationFee.toNumber() * 10000), + }, + Assets: { + val: Math.round( + toUiDecimals(assets, bank.mintDecimals) * bank.uiPrice, + ), + highlight: + Math.round( + toUiDecimals(assets, bank.mintDecimals) * bank.uiPrice, + ) > 5000, + }, + 'Assets Slippage': { + val: Math.round(pi2.priceImpactPct * 10000), + highlight: + Math.round(pi2.priceImpactPct * 10000) > + Math.round(bank.liquidationFee.toNumber() * 10000), + }, }; }), ); @@ -268,10 +360,13 @@ export async function getPerpPositionsToBeLiquidated( ); return { - Market: pm.name, - Price: pm['oldUiPrice'], - 'Future Price': pm._uiPrice, - 'Notional Position': notionalPositionUi, + Market: { val: pm.name, highlight: false }, + Price: { val: pm['oldUiPrice'], highlight: false }, + 'Future Price': { val: pm._uiPrice, highlight: false }, + 'Notional Position': { + val: Math.round(notionalPositionUi), + highlight: Math.round(notionalPositionUi) > 5000, + }, }; }); } @@ -295,12 +390,17 @@ export async function getEquityForMangoAccounts( liqors.map((liqor) => client.getMangoAccount(liqor, true)), ); - return liqorMangoAccounts.map((a: MangoAccount) => { + const accountsWithEquity = liqorMangoAccounts.map((a: MangoAccount) => { return { - Account: a.publicKey, - Equity: toUiDecimalsForQuote(a.getEquity(group)), + Account: { val: a.publicKey, highlight: false }, + Equity: { + val: Math.round(toUiDecimalsForQuote(a.getEquity(group))), + highlight: false, + }, }; }); + accountsWithEquity.sort((a, b) => b.Equity.val - a.Equity.val); + return accountsWithEquity; } export async function getRiskStats( @@ -334,12 +434,43 @@ export async function getRiskStats( // Get all mango accounts const mangoAccounts = await client.getAllMangoAccounts(group, true); + // const mangoAccounts = [ + // await client.getMangoAccount( + // new PublicKey('5G9XriaoqQy1V4s9RmnbczWAozzbv6h2RuEeAHk4R6Lb'), // https://app.mango.markets/stats?token=SOL + // true, + // ), + // ]; - // Clone group, and simulate change % price drop for all assets + // Get on chain prices + const mints = [ + ...new Set( + Array.from(group.banksMapByTokenIndex.values()) + .flat() + .map((bank) => bank.mint.toString()), + ), + ]; + const prices = await getOnChainPriceForMints([ + ...new Set( + Array.from(group.banksMapByTokenIndex.values()) + .flat() + .map((bank) => bank.mint.toString()), + ), + ]); + const onChainPrices = Object.fromEntries( + prices.map((price, i) => [mints[i], price]), + ); + Array.from(group.banksMapByTokenIndex.values()) + .flat() + .forEach((b) => { + b['onChainPrice'] = onChainPrices[b.mint.toBase58()]; + }); + + // Clone group, and simulate change % price drop for all assets except stables const drop = 1 - change; const groupDrop: Group = cloneDeep(group); Array.from(groupDrop.banksMapByTokenIndex.values()) .flat() + .filter((b) => !b.name.includes('USD')) .forEach((b) => { b['oldUiPrice'] = b._uiPrice; b._uiPrice = b._uiPrice! * drop; @@ -351,11 +482,34 @@ export async function getRiskStats( p._price = p._price?.mul(I80F48.fromNumber(drop)); }); - // Clone group, and simulate change % price rally for all assets + // Clone group, and simulate change % price drop for usdc + const groupUsdcDepeg: Group = cloneDeep(group); + Array.from(groupDrop.banksMapByTokenIndex.values()) + .flat() + .filter((b) => b.name.includes('USDC')) + .forEach((b) => { + b['oldUiPrice'] = b._uiPrice; + b._uiPrice = b._uiPrice! * drop; + b._price = b._price?.mul(I80F48.fromNumber(drop)); + }); + + // Clone group, and simulate change % price drop for usdt + const groupUsdtDepeg: Group = cloneDeep(group); + Array.from(groupDrop.banksMapByTokenIndex.values()) + .flat() + .filter((b) => b.name.includes('USDT')) + .forEach((b) => { + b['oldUiPrice'] = b._uiPrice; + b._uiPrice = b._uiPrice! * drop; + b._price = b._price?.mul(I80F48.fromNumber(drop)); + }); + + // Clone group, and simulate change % price rally for all assets except stables const rally = 1 + change; const groupRally: Group = cloneDeep(group); Array.from(groupRally.banksMapByTokenIndex.values()) .flat() + .filter((b) => !b.name.includes('USD')) .forEach((b) => { b['oldUiPrice'] = b._uiPrice; b._uiPrice = b._uiPrice! * rally; @@ -370,13 +524,17 @@ export async function getRiskStats( const [ assetDrop, assetRally, + usdcDepeg, + usdtDepeg, perpDrop, perpRally, liqorEquity, marketMakerEquity, ] = await Promise.all([ getPriceImpactForLiqor(groupDrop, mangoAccounts), - getPriceImpactForLiqor(groupDrop, mangoAccounts), + getPriceImpactForLiqor(groupRally, mangoAccounts), + getPriceImpactForLiqor(groupUsdcDepeg, mangoAccounts), + getPriceImpactForLiqor(groupUsdtDepeg, mangoAccounts), getPerpPositionsToBeLiquidated(groupDrop, mangoAccounts), getPerpPositionsToBeLiquidated(groupRally, mangoAccounts), getEquityForMangoAccounts(client, group, liqors), @@ -387,21 +545,27 @@ export async function getRiskStats( assetDrop: { title: `Table 1a: Liqors acquire liabs and assets. The assets and liabs are sum of max assets and max liabs for any token which would be liquidated to fix the health of a mango account. - This would be the slippage they would face on buying-liabs/offloading-assets tokens acquired from unhealth accounts after a 20% drop`, + This would be the slippage they would face on buying-liabs/offloading-assets tokens acquired from unhealth accounts after a 40% drop to all non-stable oracles`, data: assetDrop, }, assetRally: { - title: `Table 1b: Liqors acquire liabs and assets. The assets and liabs are sum of max assets and max - liabs for any token which would be liquidated to fix the health of a mango account. - This would be the slippage they would face on buying-liabs/offloading-assets tokens acquired from unhealth accounts after a 20% rally`, + title: `Table 1b: ... same as above but with a 40% rally to all non-stable oracles instead of drop`, data: assetRally, }, + usdcDepeg: { + title: `Table 1c: ... same as above but with a 40% drop to only usdc oracle`, + data: usdcDepeg, + }, + usdtDepeg: { + title: `Table 1d: ... same as above but with a 40% drop to only usdt oracle`, + data: usdtDepeg, + }, perpDrop: { - title: `Table 2a: Perp notional that liqor need to liquidate after a 20% drop`, + title: `Table 2a: Perp notional that liqor need to liquidate after a 40% drop`, data: perpDrop, }, perpRally: { - title: `Table 2b: Perp notional that liqor need to liquidate after a 20% rally`, + title: `Table 2b: Perp notional that liqor need to liquidate after a 40% rally`, data: perpRally, }, liqorEquity: {