diff --git a/package.json b/package.json index 1d6f9965f..8c038be53 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/ts/client/scripts/archive/debug-user.ts b/ts/client/scripts/archive/debug-user.ts index dd3f6d490..fecadbf75 100644 --- a/ts/client/scripts/archive/debug-user.ts +++ b/ts/client/scripts/archive/debug-user.ts @@ -254,16 +254,24 @@ async function main(): Promise { 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); } } } diff --git a/ts/client/scripts/archive/devnet-user.ts b/ts/client/scripts/archive/devnet-user.ts index 51ff96869..ba2ec122b 100644 --- a/ts/client/scripts/archive/devnet-user.ts +++ b/ts/client/scripts/archive/devnet-user.ts @@ -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 { // 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 { ); 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 { // 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 { ); await mangoAccount.reload(client); newBalance = mangoAccount.getTokenBalance( + group, group.getFirstBankByMint(new PublicKey(DEVNET_MINTS.get('USDC')!)), ); expect(toUiDecimalsForQuote(oldBalance.sub(newBalance)).toString()).equals( diff --git a/ts/client/scripts/large-unhealthy-accounts.ts b/ts/client/scripts/large-unhealthy-accounts.ts deleted file mode 100644 index b3e524e96..000000000 --- a/ts/client/scripts/large-unhealthy-accounts.ts +++ /dev/null @@ -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 { - 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); -} diff --git a/ts/client/scripts/param-check.ts b/ts/client/scripts/param-check.ts deleted file mode 100644 index cafd73f04..000000000 --- a/ts/client/scripts/param-check.ts +++ /dev/null @@ -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 { - 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 { - 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(); diff --git a/ts/client/scripts/risk.ts b/ts/client/scripts/risk.ts new file mode 100644 index 000000000..cda420a07 --- /dev/null +++ b/ts/client/scripts/risk.ts @@ -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 { + 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 { + // 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 +} + +main(); diff --git a/ts/client/src/accounts/mangoAccount.ts b/ts/client/src/accounts/mangoAccount.ts index 8d076d56c..4e1c220b5 100644 --- a/ts/client/src/accounts/mangoAccount.ts +++ b/ts/client/src/accounts/mangoAccount.ts @@ -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( diff --git a/ts/client/src/index.ts b/ts/client/src/index.ts index 4edf8816e..8c562ef54 100644 --- a/ts/client/src/index.ts +++ b/ts/client/src/index.ts @@ -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'; diff --git a/ts/client/src/risk.ts b/ts/client/src/risk.ts new file mode 100644 index 000000000..a40eb5fda --- /dev/null +++ b/ts/client/src/risk.ts @@ -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 +> { + 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 { + 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 { + 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 { + // 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 { + // 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, + }, + }; +} diff --git a/yarn.lock b/yarn.lock index ae6da852a..f6eedac2e 100644 --- a/yarn.lock +++ b/yarn.lock @@ -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"