merge deploy changes to dev (#586)

* expose perp order type on perp order

Signed-off-by: microwavedcola1 <microwavedcola@gmail.com>

* v0.9.17

* Fix funding rate method

Signed-off-by: microwavedcola1 <microwavedcola@gmail.com>

* Fix scrript

Signed-off-by: microwavedcola1 <microwavedcola@gmail.com>

* v0.9.18

* ts-client v0.9.19

* fix script

* update reduce only and force close flags in ts client

Signed-off-by: microwavedcola1 <microwavedcola@gmail.com>

* v0.13.1

* expose underlying property

Signed-off-by: microwavedcola1 <microwavedcola@gmail.com>

* 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 <microwavedcola@gmail.com>

* Fixes from review

Signed-off-by: microwavedcola1 <microwavedcola@gmail.com>

* Fixes from review

Signed-off-by: microwavedcola1 <microwavedcola@gmail.com>

* Fixes from review

Signed-off-by: microwavedcola1 <microwavedcola@gmail.com>

* Fixes from review

Signed-off-by: microwavedcola1 <microwavedcola@gmail.com>

* Fixes from review

Signed-off-by: microwavedcola1 <microwavedcola@gmail.com>

---------

Signed-off-by: microwavedcola1 <microwavedcola@gmail.com>

* v0.13.3

* fix client code for building health accounts

Signed-off-by: microwavedcola1 <microwavedcola@gmail.com>

* v0.13.4

* Fix bug in sim max serum3 bid

Signed-off-by: microwavedcola1 <microwavedcola@gmail.com>

* v0.13.5

* increase charge

Signed-off-by: microwavedcola1 <microwavedcola@gmail.com>

* 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 <microwavedcola@gmail.com>

* Fix branch

Signed-off-by: microwavedcola1 <microwavedcola@gmail.com>

---------

Signed-off-by: microwavedcola1 <microwavedcola@gmail.com>

* rename

Signed-off-by: microwavedcola1 <microwavedcola@gmail.com>

* Increase iterations for max swap to fix some edge case, fix debug script since fees are already accounted for

Signed-off-by: microwavedcola1 <microwavedcola@gmail.com>

* v0.14.2

* Risk notification bot (#565)

* risk stuff

Signed-off-by: microwavedcola1 <microwavedcola@gmail.com>

* Fixes from review

Signed-off-by: microwavedcola1 <microwavedcola@gmail.com>

* Fixes from review

Signed-off-by: microwavedcola1 <microwavedcola@gmail.com>

* Fixes from review

Signed-off-by: microwavedcola1 <microwavedcola@gmail.com>

* cleanup

Signed-off-by: microwavedcola1 <microwavedcola@gmail.com>

* client function

Signed-off-by: microwavedcola1 <microwavedcola@gmail.com>

---------

Signed-off-by: microwavedcola1 <microwavedcola@gmail.com>

* fix

Signed-off-by: microwavedcola1 <microwavedcola@gmail.com>

* v0.15.0

* fix risk computati

Signed-off-by: microwavedcola1 <microwavedcola@gmail.com>

* Fixes from review

Signed-off-by: microwavedcola1 <microwavedcola@gmail.com>

* v0.15.2

* Fix units

Signed-off-by: microwavedcola1 <microwavedcola@gmail.com>

* dont drop or rally stable assets

Signed-off-by: microwavedcola1 <microwavedcola@gmail.com>

* dont skip usdc

Signed-off-by: microwavedcola1 <microwavedcola@gmail.com>

* Fixes from review

Signed-off-by: microwavedcola1 <microwavedcola@gmail.com>

* v0.15.3

* Fix

Signed-off-by: microwavedcola1 <microwavedcola@gmail.com>

* v0.15.4

* update

Signed-off-by: microwavedcola1 <microwavedcola@gmail.com>

* v0.15.5

* update

Signed-off-by: microwavedcola1 <microwavedcola@gmail.com>

* v0.15.6

* add highlight

Signed-off-by: microwavedcola1 <microwavedcola@gmail.com>

* v0.15.7

* Fix math

Signed-off-by: microwavedcola1 <microwavedcola@gmail.com>

* Fix

Signed-off-by: microwavedcola1 <microwavedcola@gmail.com>

* v0.15.10

* Fix

Signed-off-by: microwavedcola1 <microwavedcola@gmail.com>

* v0.15.12

---------

Signed-off-by: microwavedcola1 <microwavedcola@gmail.com>
Co-authored-by: Christian Kamm <mail@ckamm.de>
This commit is contained in:
microwavedcola1 2023-05-16 19:20:43 +02:00 committed by GitHub
parent 163f42e998
commit 5d31d6bf32
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
7 changed files with 272 additions and 503 deletions

View File

@ -1,6 +1,6 @@
{ {
"name": "@blockworks-foundation/mango-v4", "name": "@blockworks-foundation/mango-v4",
"version": "0.13.1", "version": "0.15.12",
"description": "Typescript Client for mango-v4 program.", "description": "Typescript Client for mango-v4 program.",
"repository": "https://github.com/blockworks-foundation/mango-v4", "repository": "https://github.com/blockworks-foundation/mango-v4",
"author": { "author": {

View File

@ -11,7 +11,7 @@
"requoteThresh": 0.0002, "requoteThresh": 0.0002,
"takeSpammers": true, "takeSpammers": true,
"spammerCharge": 2, "spammerCharge": 2,
"charge": 0.002, "charge": 0.003,
"krakenCode": "XXBTZUSD" "krakenCode": "XXBTZUSD"
} }
}, },
@ -23,7 +23,7 @@
"requoteThresh": 0.0002, "requoteThresh": 0.0002,
"takeSpammers": true, "takeSpammers": true,
"spammerCharge": 2, "spammerCharge": 2,
"charge": 0.002, "charge": 0.003,
"krakenCode": "SOLUSD" "krakenCode": "SOLUSD"
} }
}, },
@ -35,7 +35,7 @@
"requoteThresh": 0.0002, "requoteThresh": 0.0002,
"takeSpammers": true, "takeSpammers": true,
"spammerCharge": 2, "spammerCharge": 2,
"charge": 0.002, "charge": 0.003,
"krakenCode": "XETHZUSD" "krakenCode": "XETHZUSD"
} }
} }

View File

@ -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 { 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 { MangoClient } from '../src/client';
import { MANGO_V4_ID } from '../src/constants'; import { MANGO_V4_ID } from '../src/constants';
import { I80F48, ONE_I80F48, ZERO_I80F48 } from '../src/numbers/I80F48'; import { getRiskStats } from '../src/risk';
import { toUiDecimals, toUiDecimalsForQuote } from '../src/utils';
const { MB_CLUSTER_URL } = process.env; const { MB_CLUSTER_URL } = process.env;
@ -33,408 +27,14 @@ async function buildClient(): Promise<MangoClient> {
); );
} }
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<void> {
// 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<void> {
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<void> {
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<void> { async function main(): Promise<void> {
const client = await buildClient(); const client = await buildClient();
const group = await client.getGroup(new PublicKey(GROUP_PK)); const group = await client.getGroup(new PublicKey(GROUP_PK));
const mangoAccounts = await client.getAllMangoAccounts(group, true); try {
console.log(JSON.stringify(await getRiskStats(client, group), null, 2));
const change = 0.4; } catch (error) {
console.log(error);
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
} }
main(); main();

View File

@ -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 totalBorrows = this.nativeBorrows();
const totalDeposits = this.nativeDeposits(); const totalDeposits = this.nativeDeposits();
if (totalDeposits.isZero() && totalBorrows.isZero()) { if (totalDeposits.isZero() && totalBorrows.isZero()) {
return ZERO_I80F48(); return ZERO_I80F48();
} }
if (totalDeposits.lte(totalBorrows)) {
return this.maxRate;
}
const utilization = totalBorrows.div(totalDeposits); const utilization = totalBorrows.div(totalDeposits);
if (utilization.lte(this.util0)) { 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 { getBorrowRateUi(): number {
return this.getBorrowRate().toNumber() * 100; return this.getBorrowRate().toNumber() * 100;

View File

@ -717,7 +717,7 @@ export class MangoAccount {
quoteAmount = quoteAmount.div( quoteAmount = quoteAmount.div(
ONE_I80F48().add(I80F48.fromNumber(serum3Market.getFeeRates(true))), ONE_I80F48().add(I80F48.fromNumber(serum3Market.getFeeRates(true))),
); );
return toUiDecimals(nativeAmount, quoteBank.mintDecimals); return toUiDecimals(quoteAmount, quoteBank.mintDecimals);
} }
/** /**

View File

@ -1582,12 +1582,8 @@ export class MangoClient {
const baseTokenIndex = serum3Market.baseTokenIndex; const baseTokenIndex = serum3Market.baseTokenIndex;
const quoteTokenIndex = serum3Market.quoteTokenIndex; const quoteTokenIndex = serum3Market.quoteTokenIndex;
// only include banks if no deposit has been previously made for same token // only include banks if no deposit has been previously made for same token
if (!mangoAccount.getToken(baseTokenIndex)?.isActive()) { banks.push(group.getFirstBankByTokenIndex(quoteTokenIndex));
banks.push(group.getFirstBankByTokenIndex(baseTokenIndex)); banks.push(group.getFirstBankByTokenIndex(baseTokenIndex));
}
if (!mangoAccount.getToken(quoteTokenIndex)?.isActive()) {
banks.push(group.getFirstBankByTokenIndex(quoteTokenIndex));
}
} }
const healthRemainingAccounts: PublicKey[] = const healthRemainingAccounts: PublicKey[] =
@ -3113,11 +3109,9 @@ export class MangoClient {
): PublicKey[] { ): PublicKey[] {
const healthRemainingAccounts: PublicKey[] = []; const healthRemainingAccounts: PublicKey[] = [];
const tokenPositionIndices = uniq( const tokenPositionIndices = mangoAccounts
mangoAccounts .map((mangoAccount) => mangoAccount.tokens.map((t) => t.tokenIndex))
.map((mangoAccount) => mangoAccount.tokens.map((t) => t.tokenIndex)) .flat();
.flat(),
);
for (const bank of banks) { for (const bank of banks) {
const tokenPositionExists = const tokenPositionExists =
tokenPositionIndices.indexOf(bank.tokenIndex) > -1; tokenPositionIndices.indexOf(bank.tokenIndex) > -1;
@ -3130,9 +3124,14 @@ export class MangoClient {
} }
} }
} }
const mintInfos = tokenPositionIndices const mintInfos = uniq(
.filter((tokenIndex) => tokenIndex !== TokenPosition.TokenIndexUnset) tokenPositionIndices
.map((tokenIndex) => group.mintInfosMapByTokenIndex.get(tokenIndex)!); .filter((tokenIndex) => tokenIndex !== TokenPosition.TokenIndexUnset)
.map((tokenIndex) => group.mintInfosMapByTokenIndex.get(tokenIndex)!),
(mintInfo) => {
mintInfo.tokenIndex;
},
);
healthRemainingAccounts.push( healthRemainingAccounts.push(
...mintInfos.map((mintInfo) => mintInfo.firstBank()), ...mintInfos.map((mintInfo) => mintInfo.firstBank()),
); );
@ -3141,11 +3140,9 @@ export class MangoClient {
); );
// Insert any extra perp markets in the free perp position slots // Insert any extra perp markets in the free perp position slots
const perpPositionsMarketIndices = uniq( const perpPositionsMarketIndices = mangoAccounts
mangoAccounts .map((mangoAccount) => mangoAccount.perps.map((p) => p.marketIndex))
.map((mangoAccount) => mangoAccount.perps.map((p) => p.marketIndex)) .flat();
.flat(),
);
for (const perpMarket of perpMarkets) { for (const perpMarket of perpMarkets) {
const perpPositionExists = const perpPositionExists =
perpPositionsMarketIndices.indexOf(perpMarket.perpMarketIndex) > -1; perpPositionsMarketIndices.indexOf(perpMarket.perpMarketIndex) > -1;
@ -3159,12 +3156,15 @@ export class MangoClient {
} }
} }
} }
const allPerpMarkets = perpPositionsMarketIndices const allPerpMarkets = uniq(
.filter( perpPositionsMarketIndices
(perpMarktIndex) => .filter(
perpMarktIndex !== PerpPosition.PerpMarketIndexUnset, (perpMarktIndex) =>
) perpMarktIndex !== PerpPosition.PerpMarketIndexUnset,
.map((perpIdx) => group.getPerpMarketByMarketIndex(perpIdx)!); )
.map((perpIdx) => group.getPerpMarketByMarketIndex(perpIdx)!),
(pm) => pm.perpMarketIndex,
);
healthRemainingAccounts.push( healthRemainingAccounts.push(
...allPerpMarkets.map((perp) => perp.publicKey), ...allPerpMarkets.map((perp) => perp.publicKey),
); );

View File

@ -22,32 +22,34 @@ async function buildFetch(): Promise<
} }
export interface LiqorPriceImpact { export interface LiqorPriceImpact {
Coin: string; Coin: { val: string; highlight: boolean };
'Oracle Price': number; 'Oracle Price': { val: number; highlight: boolean };
'On-Chain Price': number; 'Jup Price': { val: number; highlight: boolean };
'Future Price': number; 'Future Price': { val: number; highlight: boolean };
'V4 Liq Fee': number; 'V4 Liq Fee': { val: number; highlight: boolean };
Liabs: number; Liabs: { val: number; highlight: boolean };
'Liabs slippage': number; 'Liabs Slippage': { val: number; highlight: boolean };
Assets: number; Assets: { val: number; highlight: boolean };
'Assets Slippage': number; 'Assets Slippage': { val: number; highlight: boolean };
} }
export interface PerpPositionsToBeLiquidated { export interface PerpPositionsToBeLiquidated {
Market: string; Market: { val: string; highlight: boolean };
Price: number; Price: { val: number; highlight: boolean };
'Future Price': number; 'Future Price': { val: number; highlight: boolean };
'Notional Position': number; 'Notional Position': { val: number; highlight: boolean };
} }
export interface AccountEquity { export interface AccountEquity {
Account: PublicKey; Account: { val: PublicKey; highlight: boolean };
Equity: number; Equity: { val: number; highlight: boolean };
} }
export interface Risk { export interface Risk {
assetRally: { title: string; data: LiqorPriceImpact[] }; assetRally: { title: string; data: LiqorPriceImpact[] };
assetDrop: { title: string; data: LiqorPriceImpact[] }; assetDrop: { title: string; data: LiqorPriceImpact[] };
usdcDepeg: { title: string; data: LiqorPriceImpact[] };
usdtDepeg: { title: string; data: LiqorPriceImpact[] };
perpRally: { title: string; data: PerpPositionsToBeLiquidated[] }; perpRally: { title: string; data: PerpPositionsToBeLiquidated[] };
perpDrop: { title: string; data: PerpPositionsToBeLiquidated[] }; perpDrop: { title: string; data: PerpPositionsToBeLiquidated[] };
marketMakerEquity: { title: string; data: AccountEquity[] }; marketMakerEquity: { title: string; data: AccountEquity[] };
@ -63,18 +65,42 @@ export async function computePriceImpactOnJup(
const response = await (await buildFetch())(url); const response = await (await buildFetch())(url);
try { try {
let res = await response.json(); const res = await response.json();
res = res.data[0]; if (res['data'] && res.data.length > 0 && res.data[0].outAmount) {
return { return {
outAmount: parseFloat(res.outAmount), outAmount: parseFloat(res.data[0].outAmount),
priceImpactPct: parseFloat(res.priceImpactPct), priceImpactPct: parseFloat(res.data[0].priceImpactPct),
}; };
} else {
return {
outAmount: -1 / 10000,
priceImpactPct: -1 / 10000,
};
}
} catch (e) { } catch (e) {
console.log(e); console.log(e);
throw e; return {
outAmount: -1 / 10000,
priceImpactPct: -1 / 10000,
};
} }
} }
export async function getOnChainPriceForMints(
mints: string[],
): Promise<number[]> {
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( export async function getPriceImpactForLiqor(
group: Group, group: Group,
mangoAccounts: MangoAccount[], mangoAccounts: MangoAccount[],
@ -95,7 +121,7 @@ export async function getPriceImpactForLiqor(
return await Promise.all( return await Promise.all(
Array.from(group.banksMapByMint.values()) 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) => { .map(async (banks) => {
const bank = banks[0]; const bank = banks[0];
@ -107,11 +133,28 @@ export async function getPriceImpactForLiqor(
mangoAccountsWithHealth.reduce((sum, a) => { mangoAccountsWithHealth.reduce((sum, a) => {
// How much would health increase for every unit liab moved to liqor // How much would health increase for every unit liab moved to liqor
// liabprice * (liabweight - (1+fee)*assetweight) // 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( const tokenLiabHealthContrib = bank.price.mul(
bank.initLiabWeight.sub( bank.initLiabWeight.sub(
ONE_I80F48() ONE_I80F48()
.add(bank.liquidationFee) .add(bank.liquidationFee)
.mul(usdcBank.initAssetWeight), .mul(assetBank.initAssetWeight),
), ),
); );
// Abs liab/borrow // Abs liab/borrow
@ -119,6 +162,11 @@ export async function getPriceImpactForLiqor(
.getEffectiveTokenBalance(group, bank) .getEffectiveTokenBalance(group, bank)
.min(ZERO_I80F48()) .min(ZERO_I80F48())
.abs(); .abs();
if (tokenLiabHealthContrib.eq(ZERO_I80F48())) {
return sum.add(maxTokenLiab);
}
// Health under 0 // Health under 0
const maxLiab = a.health const maxLiab = a.health
.min(ZERO_I80F48()) .min(ZERO_I80F48())
@ -142,20 +190,38 @@ export async function getPriceImpactForLiqor(
const assets = mangoAccountsWithHealth.reduce((sum, a) => { const assets = mangoAccountsWithHealth.reduce((sum, a) => {
// How much would health increase for every unit liab moved to liqor // How much would health increase for every unit liab moved to liqor
// assetprice * (liabweight/(1+liabliqfee) - assetweight) // assetprice * (liabweight/(1+liabliqfee) - assetweight)
// Choose the smallest liability the user has
const liabBank = Array.from(group.banksMapByTokenIndex.values()) const liabBank = Array.from(group.banksMapByTokenIndex.values())
.flat() .flat()
.reduce((prev, curr) => .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( const tokenAssetHealthContrib = bank.price.mul(
liabBank.initLiabWeight liabBank.initLiabWeight
.div(ONE_I80F48().add(liabBank.liquidationFee)) .div(ONE_I80F48().add(liabBank.liquidationFee))
.sub(bank.initAssetWeight), .sub(bank.initAssetWeight),
); );
// Abs collateral/asset // Abs collateral/asset
const maxTokenHealthAsset = a.account const maxTokenHealthAsset = a.account
.getEffectiveTokenBalance(group, bank) .getEffectiveTokenBalance(group, bank)
.max(ZERO_I80F48()); .max(ZERO_I80F48());
if (tokenAssetHealthContrib.eq(ZERO_I80F48())) {
return sum.add(maxTokenHealthAsset);
}
const maxAsset = a.health const maxAsset = a.health
.min(ZERO_I80F48()) .min(ZERO_I80F48())
.abs() .abs()
@ -165,16 +231,7 @@ export async function getPriceImpactForLiqor(
return sum.add(maxAsset); return sum.add(maxAsset);
}, ZERO_I80F48()); }, ZERO_I80F48());
let data; const [pi1, pi2] = await Promise.all([
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'],
!liabsInUsdc.eq(ZERO_I80F48()) !liabsInUsdc.eq(ZERO_I80F48())
? computePriceImpactOnJup( ? computePriceImpactOnJup(
liabsInUsdc.toString(), liabsInUsdc.toString(),
@ -193,15 +250,50 @@ export async function getPriceImpactForLiqor(
]); ]);
return { return {
Coin: bank.name, Coin: { val: bank.name, highlight: false },
'Oracle Price': bank['oldUiPrice'], 'Oracle Price': {
'On-Chain Price': onChainPrice, val: bank['oldUiPrice'] ? bank['oldUiPrice'] : bank._uiPrice!,
'Future Price': bank._uiPrice!, highlight: false,
'V4 Liq Fee': bank.liquidationFee.toNumber() * 100, },
Liabs: toUiDecimalsForQuote(liabsInUsdc), 'Jup Price': {
'Liabs slippage': pi1.priceImpactPct * 100, val: bank['onChainPrice'],
Assets: toUiDecimals(assets, bank.mintDecimals) * bank.uiPrice, highlight:
'Assets Slippage': pi2.priceImpactPct * 100, 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 { return {
Market: pm.name, Market: { val: pm.name, highlight: false },
Price: pm['oldUiPrice'], Price: { val: pm['oldUiPrice'], highlight: false },
'Future Price': pm._uiPrice, 'Future Price': { val: pm._uiPrice, highlight: false },
'Notional Position': notionalPositionUi, '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)), liqors.map((liqor) => client.getMangoAccount(liqor, true)),
); );
return liqorMangoAccounts.map((a: MangoAccount) => { const accountsWithEquity = liqorMangoAccounts.map((a: MangoAccount) => {
return { return {
Account: a.publicKey, Account: { val: a.publicKey, highlight: false },
Equity: toUiDecimalsForQuote(a.getEquity(group)), 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( export async function getRiskStats(
@ -334,12 +434,43 @@ export async function getRiskStats(
// Get all mango accounts // Get all mango accounts
const mangoAccounts = await client.getAllMangoAccounts(group, true); 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 drop = 1 - change;
const groupDrop: Group = cloneDeep(group); const groupDrop: Group = cloneDeep(group);
Array.from(groupDrop.banksMapByTokenIndex.values()) Array.from(groupDrop.banksMapByTokenIndex.values())
.flat() .flat()
.filter((b) => !b.name.includes('USD'))
.forEach((b) => { .forEach((b) => {
b['oldUiPrice'] = b._uiPrice; b['oldUiPrice'] = b._uiPrice;
b._uiPrice = b._uiPrice! * drop; b._uiPrice = b._uiPrice! * drop;
@ -351,11 +482,34 @@ export async function getRiskStats(
p._price = p._price?.mul(I80F48.fromNumber(drop)); 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 rally = 1 + change;
const groupRally: Group = cloneDeep(group); const groupRally: Group = cloneDeep(group);
Array.from(groupRally.banksMapByTokenIndex.values()) Array.from(groupRally.banksMapByTokenIndex.values())
.flat() .flat()
.filter((b) => !b.name.includes('USD'))
.forEach((b) => { .forEach((b) => {
b['oldUiPrice'] = b._uiPrice; b['oldUiPrice'] = b._uiPrice;
b._uiPrice = b._uiPrice! * rally; b._uiPrice = b._uiPrice! * rally;
@ -370,13 +524,17 @@ export async function getRiskStats(
const [ const [
assetDrop, assetDrop,
assetRally, assetRally,
usdcDepeg,
usdtDepeg,
perpDrop, perpDrop,
perpRally, perpRally,
liqorEquity, liqorEquity,
marketMakerEquity, marketMakerEquity,
] = await Promise.all([ ] = await Promise.all([
getPriceImpactForLiqor(groupDrop, mangoAccounts), getPriceImpactForLiqor(groupDrop, mangoAccounts),
getPriceImpactForLiqor(groupDrop, mangoAccounts), getPriceImpactForLiqor(groupRally, mangoAccounts),
getPriceImpactForLiqor(groupUsdcDepeg, mangoAccounts),
getPriceImpactForLiqor(groupUsdtDepeg, mangoAccounts),
getPerpPositionsToBeLiquidated(groupDrop, mangoAccounts), getPerpPositionsToBeLiquidated(groupDrop, mangoAccounts),
getPerpPositionsToBeLiquidated(groupRally, mangoAccounts), getPerpPositionsToBeLiquidated(groupRally, mangoAccounts),
getEquityForMangoAccounts(client, group, liqors), getEquityForMangoAccounts(client, group, liqors),
@ -387,21 +545,27 @@ export async function getRiskStats(
assetDrop: { assetDrop: {
title: `Table 1a: Liqors acquire liabs and assets. The assets and liabs are sum of max assets and max 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. 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, data: assetDrop,
}, },
assetRally: { assetRally: {
title: `Table 1b: Liqors acquire liabs and assets. The assets and liabs are sum of max assets and max title: `Table 1b: ... same as above but with a 40% rally to all non-stable oracles instead of drop`,
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`,
data: assetRally, 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: { 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, data: perpDrop,
}, },
perpRally: { 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, data: perpRally,
}, },
liqorEquity: { liqorEquity: {