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>
This commit is contained in:
microwavedcola1 2023-05-13 11:55:08 +02:00 committed by GitHub
parent 784ef88927
commit 08dfb0ddba
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
10 changed files with 1015 additions and 186 deletions

View File

@ -39,9 +39,13 @@
"@types/node": "^18.11.18",
"@typescript-eslint/eslint-plugin": "^5.32.0",
"@typescript-eslint/parser": "^5.32.0",
"axios": "^1.4.0",
"chai": "^4.3.4",
"cli-table3": "^0.6.3",
"console-table-printer": "^2.11.1",
"eslint": "^7.28.0",
"eslint-config-prettier": "^7.2.0",
"fast-csv": "^4.3.6",
"mocha": "^9.1.3",
"prettier": "^2.0.5",
"ts-mocha": "^10.0.0",

View File

@ -254,16 +254,24 @@ async function main(): Promise<void> {
const userWallet = new Wallet(user);
console.log(`User ${userWallet.publicKey.toBase58()}`);
const mangoAccounts = await client.getAllMangoAccounts(group);
const mangoAccounts = await client.getAllMangoAccounts(group, true);
for (const mangoAccount of mangoAccounts) {
if (
!MANGO_ACCOUNT_PK ||
mangoAccount.publicKey.equals(new PublicKey(MANGO_ACCOUNT_PK))
) {
console.log();
console.log(`MangoAccount ${mangoAccount.publicKey}`);
await debugUser(client, group, mangoAccount);
// console.log();
console.log(
`${mangoAccount.publicKey
.toBase58()
.padStart(48)}, health ${toUiDecimalsForQuote(
mangoAccount.getHealth(group, HealthType.maint),
).toFixed(2)}, ${toUiDecimalsForQuote(
mangoAccount.getHealth(group, HealthType.init),
).toFixed(2)}`,
);
// await debugUser(client, group, mangoAccount);
}
}
}

View File

@ -4,16 +4,7 @@ import { expect } from 'chai';
import fs from 'fs';
import { Group } from '../../src/accounts/group';
import { HealthType } from '../../src/accounts/mangoAccount';
import {
PerpMarketIndex,
PerpOrderSide,
PerpOrderType,
} from '../../src/accounts/perp';
import {
Serum3OrderType,
Serum3SelfTradeBehavior,
Serum3Side,
} from '../../src/accounts/serum3';
import { PerpOrderSide, PerpOrderType } from '../../src/accounts/perp';
import { MangoClient } from '../../src/client';
import { MANGO_V4_ID } from '../../src/constants';
import { toUiDecimalsForQuote } from '../../src/utils';
@ -130,6 +121,7 @@ async function main(): Promise<void> {
// deposit USDC
let oldBalance = mangoAccount.getTokenBalance(
group,
group.getFirstBankByMint(new PublicKey(DEVNET_MINTS.get('USDC')!)),
);
await client.tokenDeposit(
@ -140,6 +132,7 @@ async function main(): Promise<void> {
);
await mangoAccount.reload(client);
let newBalance = mangoAccount.getTokenBalance(
group,
group.getFirstBankByMint(new PublicKey(DEVNET_MINTS.get('USDC')!)),
);
expect(toUiDecimalsForQuote(newBalance.sub(oldBalance)).toString()).equals(
@ -167,6 +160,7 @@ async function main(): Promise<void> {
// withdraw USDC
console.log(`...withdrawing 1 USDC`);
oldBalance = mangoAccount.getTokenBalance(
group,
group.getFirstBankByMint(new PublicKey(DEVNET_MINTS.get('USDC')!)),
);
await client.tokenWithdraw(
@ -178,6 +172,7 @@ async function main(): Promise<void> {
);
await mangoAccount.reload(client);
newBalance = mangoAccount.getTokenBalance(
group,
group.getFirstBankByMint(new PublicKey(DEVNET_MINTS.get('USDC')!)),
);
expect(toUiDecimalsForQuote(oldBalance.sub(newBalance)).toString()).equals(

View File

@ -1,74 +0,0 @@
import { AnchorProvider, Wallet } from '@coral-xyz/anchor';
import { Cluster, Connection, Keypair, PublicKey } from '@solana/web3.js';
import fs from 'fs';
import { HealthType, MangoAccount } from '../src/accounts/mangoAccount';
import { MangoClient } from '../src/client';
import { MANGO_V4_ID } from '../src/constants';
import { toUiDecimalsForQuote } from '../src/utils';
const CLUSTER_URL =
process.env.CLUSTER_URL_OVERRIDE || process.env.MB_CLUSTER_URL;
const SOME_KEYPAIR =
process.env.PAYER_KEYPAIR_OVERRIDE || process.env.MB_PAYER_KEYPAIR;
const CLUSTER: Cluster =
(process.env.CLUSTER_OVERRIDE as Cluster) || 'mainnet-beta';
const GROUP_PK = '78b8f4cGCwmZ9ysPFMWLaLTkkaYnUjwMJYStWe5RTSSX';
async function main(): Promise<void> {
const options = AnchorProvider.defaultOptions();
const connection = new Connection(CLUSTER_URL!, options);
const someKeypair = Keypair.fromSecretKey(
Buffer.from(JSON.parse(fs.readFileSync(SOME_KEYPAIR!, 'utf-8'))),
);
const someWallet = new Wallet(someKeypair);
const someProvider = new AnchorProvider(connection, someWallet, options);
const client = MangoClient.connect(
someProvider,
CLUSTER,
MANGO_V4_ID[CLUSTER],
{
idsSource: 'api',
},
);
const group = await client.getGroup(new PublicKey(GROUP_PK));
const mangoAccountsWithHealth = (
await client.getAllMangoAccounts(group, true)
)
.map((a: MangoAccount) => {
return {
account: a,
healthRatio: a.getHealthRatioUi(group, HealthType.maint),
equity: toUiDecimalsForQuote(a.getEquity(group)),
};
})
.filter((a) => a.equity > 1000)
.filter((a) => a.healthRatio < 50)
.sort((a, b) => a.healthRatio - b.healthRatio);
console.log(
`${'Owner'.padStart(45)}, ${'Account'.padStart(
45,
)}, ${'Health Ratio'.padStart(10)}, ${'Equity'.padStart(10)}`,
);
for (const obj of mangoAccountsWithHealth) {
console.log(
`${obj.account.owner.toBase58().padStart(45)} ${obj.account.publicKey
.toBase58()
.padStart(45)}: ${obj.healthRatio
.toFixed(2)
.padStart(8)} %, ${obj.equity.toLocaleString().padStart(10)} $`,
);
}
process.exit();
}
try {
main();
} catch (error) {
console.log(error);
}

View File

@ -1,95 +0,0 @@
import { AnchorProvider, Wallet } from '@coral-xyz/anchor';
import { Connection, Keypair, PublicKey } from '@solana/web3.js';
import fetch from 'node-fetch';
import { MangoClient } from '../src/client';
import { MANGO_V4_ID } from '../src/constants';
import { toNative, toUiDecimalsForQuote } from '../src/utils';
const { MB_CLUSTER_URL } = process.env;
const GROUP_PK = '78b8f4cGCwmZ9ysPFMWLaLTkkaYnUjwMJYStWe5RTSSX';
async function buildClient(): Promise<MangoClient> {
const clientKeypair = new Keypair();
const options = AnchorProvider.defaultOptions();
const connection = new Connection(MB_CLUSTER_URL!, options);
const clientWallet = new Wallet(clientKeypair);
const clientProvider = new AnchorProvider(connection, clientWallet, options);
return await MangoClient.connect(
clientProvider,
'mainnet-beta',
MANGO_V4_ID['mainnet-beta'],
{
idsSource: 'get-program-accounts',
},
);
}
async function computePriceImpact(
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);
let res = await response.json();
res = res.data[0];
return {
outAmount: parseFloat(res.outAmount),
priceImpactPct: parseFloat(res.priceImpactPct),
};
}
async function main(): Promise<void> {
const client = await buildClient();
const group = await client.getGroup(new PublicKey(GROUP_PK));
await group.reloadAll(client);
console.log(
`${'COIN'.padStart(20)}, ${'Scale'.padStart(8)}, ${'Liq Fee'.padStart(
6,
)}, ${'$->coin'.padStart(6)}, ${'coin-$'.padStart(6)}`,
);
for (const bank of Array.from(group.banksMapByMint.values())) {
const usdcMint = 'EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v';
const pi1 = await computePriceImpact(
bank[0].depositWeightScaleStartQuote.toString(),
usdcMint,
bank[0].mint.toBase58(),
);
const inAmount = toNative(
Math.min(
Math.floor(
toUiDecimalsForQuote(bank[0].depositWeightScaleStartQuote) /
bank[0].uiPrice,
),
99999999999,
),
bank[0].mintDecimals,
);
const pi2 = await computePriceImpact(
inAmount.toString(),
bank[0].mint.toBase58(),
usdcMint,
);
console.log(
`${bank[0].name.padStart(20)}, ${(
'$' +
toUiDecimalsForQuote(bank[0].depositWeightScaleStartQuote).toString()
).padStart(8)}, ${(bank[0].liquidationFee.toNumber() * 100)
.toFixed(3)
.padStart(6)}%, ${(pi1.priceImpactPct * 100).toFixed(2)}%, ${(
pi2.priceImpactPct * 100
).toFixed(2)}%`,
);
}
}
main();

440
ts/client/scripts/risk.ts Normal file
View File

@ -0,0 +1,440 @@
import { AnchorProvider, BN, 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';
const { MB_CLUSTER_URL } = process.env;
const GROUP_PK = '78b8f4cGCwmZ9ysPFMWLaLTkkaYnUjwMJYStWe5RTSSX';
async function buildClient(): Promise<MangoClient> {
const clientKeypair = new Keypair();
const options = AnchorProvider.defaultOptions();
const connection = new Connection(MB_CLUSTER_URL!, options);
const clientWallet = new Wallet(clientKeypair);
const clientProvider = new AnchorProvider(connection, clientWallet, options);
return await MangoClient.connect(
clientProvider,
'mainnet-beta',
MANGO_V4_ID['mainnet-beta'],
{
idsSource: 'get-program-accounts',
},
);
}
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> {
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
}
main();

View File

@ -236,6 +236,27 @@ export class MangoAccount {
return tp ? tp.balance(bank) : ZERO_I80F48();
}
// TODO: once perp quote is merged, also add in the settle token balance if relevant
public getEffectiveTokenBalance(group: Group, bank: Bank): I80F48 {
const tp = this.getToken(bank.tokenIndex);
if (tp) {
const bal = tp.balance(bank);
for (const serum3Market of Array.from(
group.serum3MarketsMapByMarketIndex.values(),
)) {
const oo = this.serum3OosMapByMarketIndex.get(serum3Market.marketIndex);
if (serum3Market.baseTokenIndex == bank.tokenIndex && oo) {
bal.add(I80F48.fromI64(oo.baseTokenFree));
}
if (serum3Market.quoteTokenIndex == bank.tokenIndex && oo) {
bal.add(I80F48.fromI64(oo.quoteTokenFree));
}
}
return bal;
}
return ZERO_I80F48();
}
/**
*
* @param bank
@ -550,7 +571,7 @@ export class MangoAccount {
Math.pow(10, targetBank.mintDecimals - sourceBank.mintDecimals)),
),
);
const sourceBalance = this.getTokenBalance(sourceBank);
const sourceBalance = this.getEffectiveTokenBalance(group, sourceBank);
if (maxSource.gt(sourceBalance)) {
const sourceBorrow = maxSource.sub(sourceBalance);
maxSource = sourceBalance.add(
@ -686,7 +707,7 @@ export class MangoAccount {
let quoteAmount = nativeAmount.div(quoteBank.price);
// If its a bid then the reserved fund and potential loan is in base
// also keep some buffer for fees, use taker fees for worst case simulation.
const quoteBalance = this.getTokenBalance(quoteBank);
const quoteBalance = this.getEffectiveTokenBalance(group, quoteBank);
if (quoteAmount.gt(quoteBalance)) {
const quoteBorrow = quoteAmount.sub(quoteBalance);
quoteAmount = quoteBalance.add(
@ -728,7 +749,7 @@ export class MangoAccount {
let baseAmount = nativeAmount.div(baseBank.price);
// If its a ask then the reserved fund and potential loan is in base
// also keep some buffer for fees, use taker fees for worst case simulation.
const baseBalance = this.getTokenBalance(baseBank);
const baseBalance = this.getEffectiveTokenBalance(group, baseBank);
if (baseAmount.gt(baseBalance)) {
const baseBorrow = baseAmount.sub(baseBalance);
baseAmount = baseBalance.add(

View File

@ -20,6 +20,7 @@ export {
} from './clientIxParamBuilder';
export * from './constants';
export * from './numbers/I80F48';
export * from './risk';
export * from './router';
export * from './types';
export * from './utils';

416
ts/client/src/risk.ts Normal file
View File

@ -0,0 +1,416 @@
import { PublicKey } from '@solana/web3.js';
import BN from 'bn.js';
import cloneDeep from 'lodash/cloneDeep';
import { TokenIndex } from './accounts/bank';
import { Group } from './accounts/group';
import { HealthType, MangoAccount } from './accounts/mangoAccount';
import { MangoClient } from './client';
import { I80F48, ONE_I80F48, ZERO_I80F48 } from './numbers/I80F48';
import { toUiDecimals, toUiDecimalsForQuote } from './utils';
async function buildFetch(): Promise<
(
input: RequestInfo | URL,
init?: RequestInit | undefined,
) => Promise<Response>
> {
let fetch = globalThis?.fetch;
if (!fetch && process?.versions?.node) {
fetch = (await import('node-fetch')).default;
}
return fetch;
}
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;
}
export interface PerpPositionsToBeLiquidated {
Market: string;
Price: number;
'Future Price': number;
'Notional Position': number;
}
export interface AccountEquity {
Account: PublicKey;
Equity: number;
}
export interface Risk {
assetRally: { title: string; data: LiqorPriceImpact[] };
assetDrop: { title: string; data: LiqorPriceImpact[] };
perpRally: { title: string; data: PerpPositionsToBeLiquidated[] };
perpDrop: { title: string; data: PerpPositionsToBeLiquidated[] };
marketMakerEquity: { title: string; data: AccountEquity[] };
liqorEquity: { title: string; data: AccountEquity[] };
}
export 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 (await buildFetch())(url);
try {
let res = await response.json();
res = res.data[0];
return {
outAmount: parseFloat(res.outAmount),
priceImpactPct: parseFloat(res.priceImpactPct),
};
} catch (e) {
console.log(e);
throw e;
}
}
export async function getPriceImpactForLiqor(
group: Group,
mangoAccounts: MangoAccount[],
): Promise<LiqorPriceImpact[]> {
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),
),
};
});
const usdcBank = group.getFirstBankByTokenIndex(0 as TokenIndex);
const usdcMint = usdcBank.mint;
return await Promise.all(
Array.from(group.banksMapByMint.values())
.filter((banks) => banks[0].tokenIndex !== usdcBank.tokenIndex)
.map(async (banks) => {
const bank = banks[0];
// 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));
// 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());
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'],
!liabsInUsdc.eq(ZERO_I80F48())
? computePriceImpactOnJup(
liabsInUsdc.toString(),
usdcMint.toBase58(),
bank.mint.toBase58(),
)
: Promise.resolve({ priceImpactPct: 0, outAmount: 0 }),
!assets.eq(ZERO_I80F48())
? computePriceImpactOnJup(
assets.floor().toString(),
bank.mint.toBase58(),
usdcMint.toBase58(),
)
: Promise.resolve({ priceImpactPct: 0, outAmount: 0 }),
]);
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,
};
}),
);
}
export async function getPerpPositionsToBeLiquidated(
group: Group,
mangoAccounts: MangoAccount[],
): Promise<PerpPositionsToBeLiquidated[]> {
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),
),
};
});
return Array.from(group.perpMarketsMapByMarketIndex.values())
.filter((pm) => !pm.name.includes('OLD'))
.map((pm) => {
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)),
);
return {
Market: pm.name,
Price: pm['oldUiPrice'],
'Future Price': pm._uiPrice,
'Notional Position': notionalPositionUi,
};
});
}
export async function getEquityForMangoAccounts(
client: MangoClient,
group: Group,
mangoAccounts: PublicKey[],
): Promise<AccountEquity[]> {
// 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)),
);
return liqorMangoAccounts.map((a: MangoAccount) => {
return {
Account: a.publicKey,
Equity: toUiDecimalsForQuote(a.getEquity(group)),
};
});
}
export async function getRiskStats(
client: MangoClient,
group: Group,
change = 0.4, // simulates 40% price rally and price drop on tokens and markets
): Promise<Risk> {
// Get known liqors
let liqors: PublicKey[];
try {
liqors = (
await (
await (
await buildFetch()
)(
`https://api.mngo.cloud/data/v4/stats/liqors-over_period?over_period=1MONTH`,
)
).json()
).map((data) => new PublicKey(data['liqor']));
} catch (error) {
liqors = [];
}
// Get known mms
const mms = [
new PublicKey('CtHuPg2ctVVV7nqmvVEcMtcWyJAgtZw9YcNHFQidjPgF'),
new PublicKey('F1SZxEDxxCSLVjEBbMEjDYqajWRJQRCZBwPQnmcVvTLV'),
new PublicKey('BGYWnqfaauCeebFQXEfYuDCktiVG8pqpprrsD4qfqL53'),
new PublicKey('9XJt2tvSZghsMAhWto1VuPBrwXsiimPtsTR8XwGgDxK2'),
];
// Get all mango accounts
const mangoAccounts = await client.getAllMangoAccounts(group, true);
// Clone group, and simulate change % price drop for all assets
const drop = 1 - change;
const groupDrop: Group = cloneDeep(group);
Array.from(groupDrop.banksMapByTokenIndex.values())
.flat()
.forEach((b) => {
b['oldUiPrice'] = b._uiPrice;
b._uiPrice = b._uiPrice! * drop;
b._price = b._price?.mul(I80F48.fromNumber(drop));
});
Array.from(groupDrop.perpMarketsMapByMarketIndex.values()).forEach((p) => {
p['oldUiPrice'] = p._uiPrice;
p._uiPrice = p._uiPrice! * drop;
p._price = p._price?.mul(I80F48.fromNumber(drop));
});
// Clone group, and simulate change % price rally for all assets
const rally = 1 + change;
const groupRally: Group = cloneDeep(group);
Array.from(groupRally.banksMapByTokenIndex.values())
.flat()
.forEach((b) => {
b['oldUiPrice'] = b._uiPrice;
b._uiPrice = b._uiPrice! * rally;
b._price = b._price?.mul(I80F48.fromNumber(rally));
});
Array.from(groupRally.perpMarketsMapByMarketIndex.values()).forEach((p) => {
p['oldUiPrice'] = p._uiPrice;
p._uiPrice = p._uiPrice! * rally;
p._price = p._price?.mul(I80F48.fromNumber(rally));
});
const [
assetDrop,
assetRally,
perpDrop,
perpRally,
liqorEquity,
marketMakerEquity,
] = await Promise.all([
getPriceImpactForLiqor(groupDrop, mangoAccounts),
getPriceImpactForLiqor(groupDrop, mangoAccounts),
getPerpPositionsToBeLiquidated(groupDrop, mangoAccounts),
getPerpPositionsToBeLiquidated(groupRally, mangoAccounts),
getEquityForMangoAccounts(client, group, liqors),
getEquityForMangoAccounts(client, group, mms),
]);
return {
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`,
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`,
data: assetRally,
},
perpDrop: {
title: `Table 2a: Perp notional that liqor need to liquidate after a 20% drop`,
data: perpDrop,
},
perpRally: {
title: `Table 2b: Perp notional that liqor need to liquidate after a 20% rally`,
data: perpRally,
},
liqorEquity: {
title: `Table 3: Equity of known liqors from last month`,
data: liqorEquity,
},
marketMakerEquity: {
title: `Table 4: Equity of known makers from last month`,
data: marketMakerEquity,
},
};
}

113
yarn.lock
View File

@ -37,6 +37,11 @@
dependencies:
regenerator-runtime "^0.13.11"
"@colors/colors@1.5.0":
version "1.5.0"
resolved "https://registry.yarnpkg.com/@colors/colors/-/colors-1.5.0.tgz#bb504579c1cae923e6576a4f5da43d25f97bdbd9"
integrity sha512-ooWCrlZP11i8GImSjTHYHLkvFDP48nS4+204nGb1RiX/WXYHmJA2III9/e2DWVabCESdW7hBAEzHRqUn9OUVvQ==
"@coral-xyz/anchor@^0.26.0":
version "0.26.0"
resolved "https://registry.yarnpkg.com/@coral-xyz/anchor/-/anchor-0.26.0.tgz#c8e4f7177e93441afd030f22d777d54d0194d7d1"
@ -109,6 +114,31 @@
"@ethersproject/logger" "^5.6.0"
hash.js "1.1.7"
"@fast-csv/format@4.3.5":
version "4.3.5"
resolved "https://registry.yarnpkg.com/@fast-csv/format/-/format-4.3.5.tgz#90d83d1b47b6aaf67be70d6118f84f3e12ee1ff3"
integrity sha512-8iRn6QF3I8Ak78lNAa+Gdl5MJJBM5vRHivFtMRUWINdevNo00K7OXxS2PshawLKTejVwieIlPmK5YlLu6w4u8A==
dependencies:
"@types/node" "^14.0.1"
lodash.escaperegexp "^4.1.2"
lodash.isboolean "^3.0.3"
lodash.isequal "^4.5.0"
lodash.isfunction "^3.0.9"
lodash.isnil "^4.0.0"
"@fast-csv/parse@4.3.6":
version "4.3.6"
resolved "https://registry.yarnpkg.com/@fast-csv/parse/-/parse-4.3.6.tgz#ee47d0640ca0291034c7aa94039a744cfb019264"
integrity sha512-uRsLYksqpbDmWaSmzvJcuApSEe38+6NQZBUsuAyMZKqHxH0g1wcJgsKUvN3WC8tewaqFjBMMGrkHmC+T7k8LvA==
dependencies:
"@types/node" "^14.0.1"
lodash.escaperegexp "^4.1.2"
lodash.groupby "^4.6.0"
lodash.isfunction "^3.0.9"
lodash.isnil "^4.0.0"
lodash.isundefined "^3.0.1"
lodash.uniq "^4.5.0"
"@humanwhocodes/config-array@^0.5.0":
version "0.5.0"
resolved "https://registry.npmjs.org/@humanwhocodes/config-array/-/config-array-0.5.0.tgz"
@ -496,6 +526,11 @@
resolved "https://registry.yarnpkg.com/@types/node/-/node-12.20.55.tgz#c329cbd434c42164f846b909bd6f85b5537f6240"
integrity sha512-J8xLz7q2OFulZ2cyGTLE1TbbZcjpno7FaN6zdJNrgAdrJ+DZzh/uFR6YrTb4C+nXakvud8Q4+rbhoIWlYQbUFQ==
"@types/node@^14.0.1":
version "14.18.43"
resolved "https://registry.yarnpkg.com/@types/node/-/node-14.18.43.tgz#679e000d9f1d914132ea295b4a1ffdf20370ec49"
integrity sha512-n3eFEaoem0WNwLux+k272P0+aq++5o05bA9CfiwKPdYPB5ZambWKdWoeHy7/OJiizMhzg27NLaZ6uzjLTzXceQ==
"@types/node@^18.11.18":
version "18.11.18"
resolved "https://registry.yarnpkg.com/@types/node/-/node-18.11.18.tgz#8dfb97f0da23c2293e554c5a50d61ef134d7697f"
@ -740,6 +775,15 @@ axios@^1.1.3:
form-data "^4.0.0"
proxy-from-env "^1.1.0"
axios@^1.4.0:
version "1.4.0"
resolved "https://registry.yarnpkg.com/axios/-/axios-1.4.0.tgz#38a7bf1224cd308de271146038b551d725f0be1f"
integrity sha512-S4XCWMEmzvo64T9GfvQDOXgYRDJ/wsSZc7Jvdgx5u1sd0JwsuPLqb3SYmusag+edF6ziyMensPVqLTSc1PiSEA==
dependencies:
follow-redirects "^1.15.0"
form-data "^4.0.0"
proxy-from-env "^1.1.0"
balanced-match@^1.0.0:
version "1.0.2"
resolved "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz"
@ -983,6 +1027,15 @@ chokidar@3.5.3:
optionalDependencies:
fsevents "~2.3.2"
cli-table3@^0.6.3:
version "0.6.3"
resolved "https://registry.yarnpkg.com/cli-table3/-/cli-table3-0.6.3.tgz#61ab765aac156b52f222954ffc607a6f01dbeeb2"
integrity sha512-w5Jac5SykAeZJKntOxJCrm63Eg5/4dhMWIcuTbo9rpE+brgaSZo0RuNJZeOyMgsUdhDeojvgyQLmjI+K50ZGyg==
dependencies:
string-width "^4.2.0"
optionalDependencies:
"@colors/colors" "1.5.0"
cliui@^7.0.2:
version "7.0.4"
resolved "https://registry.npmjs.org/cliui/-/cliui-7.0.4.tgz"
@ -1033,6 +1086,13 @@ concat-map@0.0.1:
resolved "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz"
integrity sha1-2Klr13/Wjfd5OnMDajug1UBdR3s=
console-table-printer@^2.11.1:
version "2.11.1"
resolved "https://registry.yarnpkg.com/console-table-printer/-/console-table-printer-2.11.1.tgz#c2dfe56e6343ea5bcfa3701a4be29fe912dbd9c7"
integrity sha512-8LfFpbF/BczoxPwo2oltto5bph8bJkGOATXsg3E9ddMJOGnWJciKHldx2zDj5XIBflaKzPfVCjOTl6tMh7lErg==
dependencies:
simple-wcswidth "^1.0.1"
crc@^4.1.0:
version "4.1.1"
resolved "https://registry.yarnpkg.com/crc/-/crc-4.1.1.tgz#cb926237b56739f82c8533da1b66925ed33e011f"
@ -1350,6 +1410,14 @@ eyes@^0.1.8:
resolved "https://registry.yarnpkg.com/eyes/-/eyes-0.1.8.tgz#62cf120234c683785d902348a800ef3e0cc20bc0"
integrity sha512-GipyPsXO1anza0AOZdy69Im7hGFCNB7Y/NGjDlZGJ3GJJLtwNSb2vrzYrTYJRrRloVx7pl+bhUaTB8yiccPvFQ==
fast-csv@^4.3.6:
version "4.3.6"
resolved "https://registry.yarnpkg.com/fast-csv/-/fast-csv-4.3.6.tgz#70349bdd8fe4d66b1130d8c91820b64a21bc4a63"
integrity sha512-2RNSpuwwsJGP0frGsOmTb9oUF+VkFSM4SyLTDgwf2ciHWTarN0lQTC+F2f/t5J9QjW+c65VFIAAu85GsvMIusw==
dependencies:
"@fast-csv/format" "4.3.5"
"@fast-csv/parse" "4.3.6"
fast-deep-equal@^3.1.1, fast-deep-equal@^3.1.3:
version "3.1.3"
resolved "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz"
@ -1775,6 +1843,41 @@ locate-path@^6.0.0:
dependencies:
p-locate "^5.0.0"
lodash.escaperegexp@^4.1.2:
version "4.1.2"
resolved "https://registry.yarnpkg.com/lodash.escaperegexp/-/lodash.escaperegexp-4.1.2.tgz#64762c48618082518ac3df4ccf5d5886dae20347"
integrity sha512-TM9YBvyC84ZxE3rgfefxUWiQKLilstD6k7PTGt6wfbtXF8ixIJLOL3VYyV/z+ZiPLsVxAsKAFVwWlWeb2Y8Yyw==
lodash.groupby@^4.6.0:
version "4.6.0"
resolved "https://registry.yarnpkg.com/lodash.groupby/-/lodash.groupby-4.6.0.tgz#0b08a1dcf68397c397855c3239783832df7403d1"
integrity sha512-5dcWxm23+VAoz+awKmBaiBvzox8+RqMgFhi7UvX9DHZr2HdxHXM/Wrf8cfKpsW37RNrvtPn6hSwNqurSILbmJw==
lodash.isboolean@^3.0.3:
version "3.0.3"
resolved "https://registry.yarnpkg.com/lodash.isboolean/-/lodash.isboolean-3.0.3.tgz#6c2e171db2a257cd96802fd43b01b20d5f5870f6"
integrity sha512-Bz5mupy2SVbPHURB98VAcw+aHh4vRV5IPNhILUCsOzRmsTmSQ17jIuqopAentWoehktxGd9e/hbIXq980/1QJg==
lodash.isequal@^4.5.0:
version "4.5.0"
resolved "https://registry.yarnpkg.com/lodash.isequal/-/lodash.isequal-4.5.0.tgz#415c4478f2bcc30120c22ce10ed3226f7d3e18e0"
integrity sha512-pDo3lu8Jhfjqls6GkMgpahsF9kCyayhgykjyLMNFTKWrpVdAQtYyB4muAMWozBB4ig/dtWAmsMxLEI8wuz+DYQ==
lodash.isfunction@^3.0.9:
version "3.0.9"
resolved "https://registry.yarnpkg.com/lodash.isfunction/-/lodash.isfunction-3.0.9.tgz#06de25df4db327ac931981d1bdb067e5af68d051"
integrity sha512-AirXNj15uRIMMPihnkInB4i3NHeb4iBtNg9WRWuK2o31S+ePwwNmDPaTL3o7dTJ+VXNZim7rFs4rxN4YU1oUJw==
lodash.isnil@^4.0.0:
version "4.0.0"
resolved "https://registry.yarnpkg.com/lodash.isnil/-/lodash.isnil-4.0.0.tgz#49e28cd559013458c814c5479d3c663a21bfaa6c"
integrity sha512-up2Mzq3545mwVnMhTDMdfoG1OurpA/s5t88JmQX809eH3C8491iu2sfKhTfhQtKY78oPNhiaHJUpT/dUDAAtng==
lodash.isundefined@^3.0.1:
version "3.0.1"
resolved "https://registry.yarnpkg.com/lodash.isundefined/-/lodash.isundefined-3.0.1.tgz#23ef3d9535565203a66cefd5b830f848911afb48"
integrity sha512-MXB1is3s899/cD8jheYYE2V9qTHwKvt+npCwpD+1Sxm3Q3cECXCiYHjeHWXNwr6Q0SOBPrYUDxendrO6goVTEA==
lodash.merge@^4.6.2:
version "4.6.2"
resolved "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz"
@ -1785,6 +1888,11 @@ lodash.truncate@^4.4.2:
resolved "https://registry.npmjs.org/lodash.truncate/-/lodash.truncate-4.4.2.tgz"
integrity sha1-WjUNoLERO4N+z//VgSy+WNbq4ZM=
lodash.uniq@^4.5.0:
version "4.5.0"
resolved "https://registry.yarnpkg.com/lodash.uniq/-/lodash.uniq-4.5.0.tgz#d0225373aeb652adc1bc82e4945339a842754773"
integrity sha512-xfBaXQd9ryd9dlSDvnvI0lvxfLJlYAZzXomUYzLKtUeOQvOP5piqAWuGtrhWeqaXK9hhoM/iyJc5AV+XfsX3HQ==
lodash.zipobject@^4.1.3:
version "4.1.3"
resolved "https://registry.yarnpkg.com/lodash.zipobject/-/lodash.zipobject-4.1.3.tgz#b399f5aba8ff62a746f6979bf20b214f964dbef8"
@ -2254,6 +2362,11 @@ shiki@^0.10.0:
vscode-oniguruma "^1.6.1"
vscode-textmate "5.2.0"
simple-wcswidth@^1.0.1:
version "1.0.1"
resolved "https://registry.yarnpkg.com/simple-wcswidth/-/simple-wcswidth-1.0.1.tgz#8ab18ac0ae342f9d9b629604e54d2aa1ecb018b2"
integrity sha512-xMO/8eNREtaROt7tJvWJqHBDTMFN4eiQ5I4JRMuilwfnFcV5W9u7RUkueNkdw0jPqGMX36iCywelS5yilTuOxg==
slash@^3.0.0:
version "3.0.0"
resolved "https://registry.npmjs.org/slash/-/slash-3.0.0.tgz"