import { BN } from '@coral-xyz/anchor'; import { OpenOrders } from '@project-serum/serum'; import { PublicKey } from '@solana/web3.js'; import { I80F48, MAX_I80F48, ONE_I80F48, ZERO_I80F48 } from '../numbers/I80F48'; import { deepClone, toNativeI80F48ForQuote, toUiDecimals, toUiDecimalsForQuote, } from '../utils'; import { Bank, BankForHealth, TokenIndex } from './bank'; import { Group } from './group'; import { HealthType, MangoAccount, PerpPosition, Serum3Orders, } from './mangoAccount'; import { PerpMarket, PerpMarketIndex, PerpOrder, PerpOrderSide } from './perp'; import { MarketIndex, Serum3Market, Serum3Side } from './serum3'; // ░░░░ // // ██ // ██░░██ // ░░ ░░ ██░░░░░░██ ░░░░ // ██░░░░░░░░░░██ // ██░░░░░░░░░░██ // ██░░░░░░░░░░░░░░██ // ██░░░░░░██████░░░░░░██ // ██░░░░░░██████░░░░░░██ // ██░░░░░░░░██████░░░░░░░░██ // ██░░░░░░░░██████░░░░░░░░██ // ██░░░░░░░░░░██████░░░░░░░░░░██ // ██░░░░░░░░░░░░██████░░░░░░░░░░░░██ // ██░░░░░░░░░░░░██████░░░░░░░░░░░░██ // ██░░░░░░░░░░░░░░██████░░░░░░░░░░░░░░██ // ██░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░██ // ██░░░░░░░░░░░░░░░░██████░░░░░░░░░░░░░░░░██ // ██░░░░░░░░░░░░░░░░██████░░░░░░░░░░░░░░░░██ // ██░░░░░░░░░░░░░░░░░░██████░░░░░░░░░░░░░░░░░░██ // ░░ ██░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░██ // ██████████████████████████████████████████ // warning: this code is copy pasta from rust, keep in sync with health.rs function spotAmountTakenForHealthZero( health: I80F48, startingSpot: I80F48, assetWeightedPrice: I80F48, liabWeightedPrice: I80F48, ): I80F48 { if (health.lte(ZERO_I80F48())) { return ZERO_I80F48(); } let takenSpot = ZERO_I80F48(); if (startingSpot.gt(ZERO_I80F48())) { if (assetWeightedPrice.gt(ZERO_I80F48())) { const assetMax = health.div(assetWeightedPrice); if (assetMax.lte(startingSpot)) { return assetMax; } } takenSpot = startingSpot; health.isub(startingSpot.mul(assetWeightedPrice)); } if (health.gt(ZERO_I80F48())) { if (liabWeightedPrice.lte(ZERO_I80F48())) { throw new Error('LiabWeightedPrice must be greater than 0!'); } takenSpot.iadd(health.div(liabWeightedPrice)); } return takenSpot; } function spotAmountGivenForHealthZero( health: I80F48, startingSpot: I80F48, assetWeightedPrice: I80F48, liabWeightedPrice: I80F48, ): I80F48 { return spotAmountTakenForHealthZero( health.neg(), startingSpot.neg(), liabWeightedPrice, assetWeightedPrice, ); } export class HealthCache { constructor( public tokenInfos: TokenInfo[], public serum3Infos: Serum3Info[], public perpInfos: PerpInfo[], ) {} static fromMangoAccount( group: Group, mangoAccount: MangoAccount, ): HealthCache { // token contribution from token accounts const tokenInfos = mangoAccount.tokensActive().map((tokenPosition) => { const bank = group.getFirstBankByTokenIndex(tokenPosition.tokenIndex); return TokenInfo.fromBank(bank, tokenPosition.balance(bank)); }); // if no usdc position is found, insert it nonetheless, this is required for simulating // 1st max perp trade if ( !tokenInfos.find( (ti) => ti.tokenIndex == group.getFirstBankForPerpSettlement().tokenIndex, ) ) { tokenInfos.push( TokenInfo.fromBank( group.getFirstBankForPerpSettlement(), ZERO_I80F48(), ), ); } // Fill the TokenInfo balance with free funds in serum3 oo accounts, and fill // the serum3MaxReserved with their reserved funds. Also build Serum3Infos. const serum3Infos = mangoAccount.serum3Active().map((serum3) => { const oo = mangoAccount.getSerum3OoAccount(serum3.marketIndex); // find the TokenInfos for the market's base and quote tokens const baseInfoIndex = tokenInfos.findIndex( (tokenInfo) => tokenInfo.tokenIndex === serum3.baseTokenIndex, ); const baseInfo = tokenInfos[baseInfoIndex]; if (!baseInfo) { throw new Error( `BaseInfo not found for market with marketIndex ${serum3.marketIndex}!`, ); } const quoteInfoIndex = tokenInfos.findIndex( (tokenInfo) => tokenInfo.tokenIndex === serum3.quoteTokenIndex, ); const quoteInfo = tokenInfos[quoteInfoIndex]; if (!quoteInfo) { throw new Error( `QuoteInfo not found for market with marketIndex ${serum3.marketIndex}!`, ); } return Serum3Info.fromOoModifyingTokenInfos( serum3, baseInfoIndex, baseInfo, quoteInfoIndex, quoteInfo, serum3.marketIndex, oo, ); }); // health contribution from perp accounts const perpInfos = mangoAccount.perpActive().map((perpPosition) => { const perpMarket = group.getPerpMarketByMarketIndex( perpPosition.marketIndex, ); return PerpInfo.fromPerpPosition(perpMarket, perpPosition); }); return new HealthCache(tokenInfos, serum3Infos, perpInfos); } computeSerum3Reservations(healthType: HealthType | undefined): { tokenMaxReserved: TokenMaxReserved[]; serum3Reserved: Serum3Reserved[]; } { // For each token, compute the sum of serum-reserved amounts over all markets. const tokenMaxReserved = new Array(this.tokenInfos.length) .fill(null) .map((ignored) => new TokenMaxReserved(ZERO_I80F48())); // For each serum market, compute what happened if reserved_base was converted to quote // or reserved_quote was converted to base. const serum3Reserved: Serum3Reserved[] = []; for (const info of this.serum3Infos) { const quote = this.tokenInfos[info.quoteInfoIndex]; const base = this.tokenInfos[info.baseInfoIndex]; const reservedBase = info.reservedBase; const reservedQuote = info.reservedQuote; const quoteAsset = quote.prices.asset(healthType); const baseLiab = base.prices.liab(healthType); const reservedQuoteAsBaseOracle = reservedQuote.mul( quoteAsset.div(baseLiab), ); let allReservedAsBase; if (!info.reservedQuoteAsBaseHighestBid.eq(ZERO_I80F48())) { allReservedAsBase = reservedBase.add( reservedQuoteAsBaseOracle.min(info.reservedQuoteAsBaseHighestBid), ); } else { allReservedAsBase = reservedBase.add(reservedQuoteAsBaseOracle); } const baseAsset = base.prices.asset(healthType); const quoteLiab = quote.prices.liab(healthType); const reservedBaseAsQuoteOracle = reservedBase.mul( baseAsset.div(quoteLiab), ); let allReservedAsQuote; if (!info.reservedBaseAsQuoteLowestAsk.eq(ZERO_I80F48())) { allReservedAsQuote = reservedQuote.add( reservedBaseAsQuoteOracle.min(info.reservedBaseAsQuoteLowestAsk), ); } else { allReservedAsQuote = reservedQuote.add(reservedBaseAsQuoteOracle); } const baseMaxReserved = tokenMaxReserved[info.baseInfoIndex]; baseMaxReserved.maxSerumReserved.iadd(allReservedAsBase); const quoteMaxReserved = tokenMaxReserved[info.quoteInfoIndex]; quoteMaxReserved.maxSerumReserved.iadd(allReservedAsQuote); serum3Reserved.push( new Serum3Reserved(allReservedAsBase, allReservedAsQuote), ); } return { tokenMaxReserved: tokenMaxReserved, serum3Reserved: serum3Reserved, }; } effectiveTokenBalances(healthType: HealthType | undefined): TokenBalance[] { return this.effectiveTokenBalancesInternal(healthType, false); } effectiveTokenBalancesInternal( healthType: HealthType | undefined, ignoreNegativePerp: boolean, ): TokenBalance[] { const tokenBalances = new Array(this.tokenInfos.length) .fill(null) .map((ignored) => new TokenBalance(ZERO_I80F48())); for (const perpInfo of this.perpInfos) { const settleTokenIndex = this.findTokenInfoIndex( perpInfo.settleTokenIndex, ); const perpSettleToken = tokenBalances[settleTokenIndex]; const healthUnsettled = perpInfo.healthUnsettledPnl(healthType); if (!ignoreNegativePerp || healthUnsettled.gt(ZERO_I80F48())) { perpSettleToken.spotAndPerp.iadd(healthUnsettled); } } for (const index of this.tokenInfos.keys()) { const tokenInfo = this.tokenInfos[index]; const tokenBalance = tokenBalances[index]; tokenBalance.spotAndPerp.iadd(tokenInfo.balanceSpot); } return tokenBalances; } effectiveTokenBalancesInternalDisplay( group: Group, healthType: HealthType | undefined, ignoreNegativePerp: boolean, ): TokenBalanceDisplay[] { const tokenBalances = new Array(this.tokenInfos.length) .fill(null) .map((ignored) => new TokenBalanceDisplay(ZERO_I80F48(), 0, [])); for (const perpInfo of this.perpInfos) { const settleTokenIndex = this.findTokenInfoIndex( perpInfo.settleTokenIndex, ); const perpSettleToken = tokenBalances[settleTokenIndex]; const healthUnsettled = perpInfo.healthUnsettledPnl(healthType); perpSettleToken.perpMarketContributions.push({ market: group.getPerpMarketByMarketIndex( perpInfo.perpMarketIndex as PerpMarketIndex, ).name, contributionUi: toUiDecimals( healthUnsettled, group.getMintDecimalsByTokenIndex(perpInfo.settleTokenIndex), ), }); if (!ignoreNegativePerp || healthUnsettled.gt(ZERO_I80F48())) { perpSettleToken.spotAndPerp.iadd(healthUnsettled); } } for (const index of this.tokenInfos.keys()) { const tokenInfo = this.tokenInfos[index]; const tokenBalance = tokenBalances[index]; tokenBalance.spotAndPerp.iadd(tokenInfo.balanceSpot); tokenBalance.spotUi += toUiDecimals( tokenInfo.balanceSpot, group.getMintDecimalsByTokenIndex(tokenInfo.tokenIndex), ); } return tokenBalances; } healthSum(healthType: HealthType, tokenBalances: TokenBalance[]): I80F48 { const health = ZERO_I80F48(); for (const index of this.tokenInfos.keys()) { const tokenInfo = this.tokenInfos[index]; const tokenBalance = tokenBalances[index]; const contrib = tokenInfo.healthContribution( healthType, tokenBalance.spotAndPerp, ); // console.log(` - ti ${contrib}`); health.iadd(contrib); } const res = this.computeSerum3Reservations(healthType); for (const [index, serum3Info] of this.serum3Infos.entries()) { const contrib = serum3Info.healthContribution( healthType, this.tokenInfos, tokenBalances, res.tokenMaxReserved, res.serum3Reserved[index], ); // console.log(` - si ${contrib}`); health.iadd(contrib); } return health; } healthContributionPerAssetUi( group: Group, healthType: HealthType, ): { asset: string; contribution: number; contributionDetails: | { spotUi: number; perpMarketContributions: { market: string; contributionUi: number }[]; } | undefined; }[] { const tokenBalancesDisplay: TokenBalanceDisplay[] = this.effectiveTokenBalancesInternalDisplay(group, healthType, false); const ret = new Array<{ asset: string; contribution: number; contributionDetails: | { spotUi: number; perpMarketContributions: { market: string; contributionUi: number; }[]; } | undefined; }>(); for (const index of this.tokenInfos.keys()) { const tokenInfo = this.tokenInfos[index]; const tokenBalance = tokenBalancesDisplay[index]; const contrib = tokenInfo.healthContribution( healthType, tokenBalance.spotAndPerp, ); ret.push({ asset: group.getFirstBankByTokenIndex(tokenInfo.tokenIndex).name, contribution: toUiDecimalsForQuote(contrib), contributionDetails: { spotUi: tokenBalance.spotUi, perpMarketContributions: tokenBalance.perpMarketContributions, }, }); } const res = this.computeSerum3Reservations(healthType); for (const [index, serum3Info] of this.serum3Infos.entries()) { const contrib = serum3Info.healthContribution( healthType, this.tokenInfos, tokenBalancesDisplay, res.tokenMaxReserved, res.serum3Reserved[index], ); ret.push({ asset: group.getSerum3MarketByMarketIndex(serum3Info.marketIndex).name, contribution: toUiDecimalsForQuote(contrib), contributionDetails: undefined, }); } return ret; } public health(healthType: HealthType): I80F48 { const tokenBalances = this.effectiveTokenBalancesInternal( healthType, false, ); return this.healthSum(healthType, tokenBalances); } public perpMaxSettle(settleTokenIndex: TokenIndex): I80F48 { const healthType = HealthType.maint; const tokenBalances = this.effectiveTokenBalancesInternal(healthType, true); const perpSettleHealth = this.healthSum(healthType, tokenBalances); const tokenInfoIndex = this.findTokenInfoIndex(settleTokenIndex); const tokenInfo = this.tokenInfos[tokenInfoIndex]; return spotAmountTakenForHealthZero( perpSettleHealth, tokenBalances[tokenInfoIndex].spotAndPerp, tokenInfo.assetWeightedPrice(healthType), tokenInfo.liabWeightedPrice(healthType), ); } healthAssetsAndLiabsStableAssets(healthType: HealthType): { assets: I80F48; liabs: I80F48; } { return this.healthAssetsAndLiabs(healthType, true); } healthAssetsAndLiabsStableLiabs(healthType: HealthType): { assets: I80F48; liabs: I80F48; } { return this.healthAssetsAndLiabs(healthType, false); } public healthAssetsAndLiabs( healthType: HealthType | undefined, stableAssets: boolean, ): { assets: I80F48; liabs: I80F48 } { const totalAssets = ZERO_I80F48(); const totalLiabs = ZERO_I80F48(); for (const tokenInfo of this.tokenInfos) { const assetBalance = ZERO_I80F48(); const liabBalance = ZERO_I80F48(); if (tokenInfo.balanceSpot.isPos()) { assetBalance.iadd(tokenInfo.balanceSpot); } else { liabBalance.isub(tokenInfo.balanceSpot); } for (const perpInfo of this.perpInfos) { if (perpInfo.settleTokenIndex != tokenInfo.tokenIndex) { continue; } const healthUnsettled = perpInfo.healthUnsettledPnl(healthType); if (healthUnsettled.isPos()) { assetBalance.iadd(healthUnsettled); } else { liabBalance.isub(healthUnsettled); } } if (stableAssets) { const assetWeightedPrice = tokenInfo.assetWeightedPrice(healthType); const assets = assetBalance.mul(assetWeightedPrice); totalAssets.iadd(assets); if (assetBalance.gte(liabBalance)) { totalLiabs.iadd(liabBalance.mul(assetWeightedPrice)); } else { const liabWeightedPrice = tokenInfo.liabWeightedPrice(healthType); totalLiabs.iadd( assets.add(liabBalance.sub(assetBalance).mul(liabWeightedPrice)), ); } } else { const liabWeightedPrice = tokenInfo.liabWeightedPrice(healthType); const liabs = liabBalance.mul(liabWeightedPrice); totalLiabs.iadd(liabs); if (assetBalance.gte(liabBalance)) { const assetWeightedPrice = tokenInfo.assetWeightedPrice(healthType); totalAssets.iadd( liabs.add(assetBalance.sub(liabBalance).mul(assetWeightedPrice)), ); } else { totalAssets.iadd(assetBalance.mul(liabWeightedPrice)); } } } const tokenBalances = this.effectiveTokenBalances(healthType); const res = this.computeSerum3Reservations(healthType); for (const [index, serum3Info] of this.serum3Infos.entries()) { const contrib = serum3Info.healthContribution( healthType, this.tokenInfos, tokenBalances, res.tokenMaxReserved, res.serum3Reserved[index], ); if (contrib.isPos()) { totalAssets.iadd(contrib); } else { totalLiabs.iadd(contrib); } } return { assets: totalAssets, liabs: totalLiabs }; } public healthRatio(healthType: HealthType): I80F48 { const res = this.healthAssetsAndLiabsStableLiabs(healthType); const hundred = I80F48.fromNumber(100); // console.log(`assets ${res.assets}`); // console.log(`liabs ${res.liabs}`); if (res.liabs.gt(I80F48.fromNumber(0.001))) { return hundred.mul(res.assets.sub(res.liabs)).div(res.liabs); } return MAX_I80F48(); } findTokenInfoIndex(tokenIndex: TokenIndex): number { return this.tokenInfos.findIndex( (tokenInfo) => tokenInfo.tokenIndex === tokenIndex, ); } getOrCreateTokenInfoIndex(bank: BankForHealth): number { const index = this.findTokenInfoIndex(bank.tokenIndex); if (index == -1) { this.tokenInfos.push(TokenInfo.fromBank(bank)); } return this.findTokenInfoIndex(bank.tokenIndex); } simHealthRatioWithTokenPositionChanges( group: Group, nativeTokenChanges: { nativeTokenAmount: I80F48; mintPk: PublicKey; }[], healthType: HealthType = HealthType.init, ): I80F48 { const adjustedCache: HealthCache = deepClone(this); // HealthCache.logHealthCache('beforeChange', adjustedCache); for (const change of nativeTokenChanges) { const bank: Bank = group.getFirstBankByMint(change.mintPk); const changeIndex = adjustedCache.getOrCreateTokenInfoIndex(bank); // TODO: this will no longer work as easily because of the health weight changes adjustedCache.tokenInfos[changeIndex].balanceSpot.iadd( change.nativeTokenAmount, ); } // HealthCache.logHealthCache('afterChange', adjustedCache); return adjustedCache.healthRatio(healthType); } findSerum3InfoIndex(marketIndex: MarketIndex): number { return this.serum3Infos.findIndex( (serum3Info) => serum3Info.marketIndex === marketIndex, ); } getOrCreateSerum3InfoIndex( baseBank: BankForHealth, quoteBank: BankForHealth, serum3Market: Serum3Market, ): number { const index = this.findSerum3InfoIndex(serum3Market.marketIndex); const baseEntryIndex = this.getOrCreateTokenInfoIndex(baseBank); const quoteEntryIndex = this.getOrCreateTokenInfoIndex(quoteBank); if (index == -1) { this.serum3Infos.push( Serum3Info.emptyFromSerum3Market( serum3Market, baseEntryIndex, quoteEntryIndex, ), ); } return this.findSerum3InfoIndex(serum3Market.marketIndex); } adjustSerum3Reserved( baseBank: BankForHealth, quoteBank: BankForHealth, serum3Market: Serum3Market, reservedBaseChange: I80F48, freeBaseChange: I80F48, reservedQuoteChange: I80F48, freeQuoteChange: I80F48, ): void { const baseEntryIndex = this.getOrCreateTokenInfoIndex(baseBank); const quoteEntryIndex = this.getOrCreateTokenInfoIndex(quoteBank); const baseEntry = this.tokenInfos[baseEntryIndex]; const quoteEntry = this.tokenInfos[quoteEntryIndex]; // Apply it to the tokens baseEntry.balanceSpot.iadd(freeBaseChange); quoteEntry.balanceSpot.iadd(freeQuoteChange); // Apply it to the serum3 info const index = this.getOrCreateSerum3InfoIndex( baseBank, quoteBank, serum3Market, ); const serum3Info = this.serum3Infos[index]; serum3Info.reservedBase.iadd(reservedBaseChange); serum3Info.reservedQuote.iadd(reservedQuoteChange); } simHealthRatioWithSerum3BidChanges( baseBank: BankForHealth, quoteBank: BankForHealth, bidNativeQuoteAmount: I80F48, serum3Market: Serum3Market, healthType: HealthType = HealthType.init, ): I80F48 { const adjustedCache: HealthCache = deepClone(this); const quoteIndex = adjustedCache.getOrCreateTokenInfoIndex(quoteBank); // Move token balance to reserved funds in open orders, // essentially simulating a place order // Reduce token balance for quote adjustedCache.tokenInfos[quoteIndex].balanceSpot.isub(bidNativeQuoteAmount); // Increase reserved in Serum3Info for quote adjustedCache.adjustSerum3Reserved( baseBank, quoteBank, serum3Market, ZERO_I80F48(), ZERO_I80F48(), bidNativeQuoteAmount, ZERO_I80F48(), ); return adjustedCache.healthRatio(healthType); } simHealthRatioWithSerum3AskChanges( baseBank: BankForHealth, quoteBank: BankForHealth, askNativeBaseAmount: I80F48, serum3Market: Serum3Market, healthType: HealthType = HealthType.init, ): I80F48 { const adjustedCache: HealthCache = deepClone(this); const baseIndex = adjustedCache.getOrCreateTokenInfoIndex(baseBank); // Move token balance to reserved funds in open orders, // essentially simulating a place order // Reduce token balance for base adjustedCache.tokenInfos[baseIndex].balanceSpot.isub(askNativeBaseAmount); // Increase reserved in Serum3Info for base adjustedCache.adjustSerum3Reserved( baseBank, quoteBank, serum3Market, askNativeBaseAmount, ZERO_I80F48(), ZERO_I80F48(), ZERO_I80F48(), ); return adjustedCache.healthRatio(healthType); } findPerpInfoIndex(perpMarketIndex: number): number { return this.perpInfos.findIndex( (perpInfo) => perpInfo.perpMarketIndex === perpMarketIndex, ); } getOrCreatePerpInfoIndex(perpMarket: PerpMarket): number { const index = this.findPerpInfoIndex(perpMarket.perpMarketIndex); if (index == -1) { this.perpInfos.push(PerpInfo.emptyFromPerpMarket(perpMarket)); } return this.findPerpInfoIndex(perpMarket.perpMarketIndex); } adjustPerpInfo( perpInfoIndex: number, price: I80F48, side: PerpOrderSide, newOrderBaseLots: BN, ): void { if (side == PerpOrderSide.bid) { this.perpInfos[perpInfoIndex].baseLots.iadd(newOrderBaseLots); this.perpInfos[perpInfoIndex].quote.isub( I80F48.fromI64(newOrderBaseLots) .mul(I80F48.fromI64(this.perpInfos[perpInfoIndex].baseLotSize)) .mul(price), ); } else { this.perpInfos[perpInfoIndex].baseLots.isub(newOrderBaseLots); this.perpInfos[perpInfoIndex].quote.iadd( I80F48.fromI64(newOrderBaseLots) .mul(I80F48.fromI64(this.perpInfos[perpInfoIndex].baseLotSize)) .mul(price), ); } } simHealthRatioWithPerpOrderChanges( perpMarket: PerpMarket, existingPerpPosition: PerpPosition, side: PerpOrderSide, baseLots: BN, price: I80F48, healthType: HealthType = HealthType.init, ): I80F48 { const clonedHealthCache: HealthCache = deepClone(this); const perpInfoIndex = clonedHealthCache.getOrCreatePerpInfoIndex(perpMarket); clonedHealthCache.adjustPerpInfo(perpInfoIndex, price, side, baseLots); return clonedHealthCache.healthRatio(healthType); } private static scanRightUntilLessThan( start: I80F48, target: I80F48, fun: (amount: I80F48) => I80F48, ): I80F48 { const maxIterations = 50; let current = start; // console.log(`scanRightUntilLessThan, start ${start.toLocaleString()}`); for (const key of Array(maxIterations).fill(0).keys()) { const value = fun(current); if (value.lt(target)) { return current; } // console.log( // ` - current ${current.toLocaleString()}, value ${value.toLocaleString()}, target ${target.toLocaleString()}`, // ); current = current.max(ONE_I80F48()).mul(I80F48.fromNumber(2)); } throw new Error('Could not find amount that led to health ratio <=0'); } /// This is not a generic function. It assumes there is a almost-unique maximum between left and right, /// in the sense that `fun` might be constant on the maximum value for a while, but there won't be /// distinct maximums with non-maximal values between them. /// /// If the maximum isn't just a single point, it returns the rightmost value. private static findMaximum( left: I80F48, right: I80F48, minStep: I80F48, fun: (I80F48) => I80F48, ): I80F48[] { const half = I80F48.fromNumber(0.5); let mid = half.mul(left.add(right)); let leftValue = fun(left); let rightValue = fun(right); let midValue = fun(mid); while (right.sub(left).gt(minStep)) { if (leftValue.gt(midValue)) { // max must be between left and mid right = mid; rightValue = midValue; mid = half.mul(left.add(mid)); midValue = fun(mid); } else if (midValue.lte(rightValue)) { // max must be between mid and right left = mid; leftValue = midValue; mid = half.mul(mid.add(right)); midValue = fun(mid); } else { // mid is larger than both left and right, max could be on either side const leftmid = half.mul(left.add(mid)); const leftMidValue = fun(leftmid); if (leftMidValue.gt(midValue)) { // max between left and mid right = mid; rightValue = midValue; mid = leftmid; midValue = leftMidValue; continue; } const rightmid = half.mul(mid.add(right)); const rightMidValue = fun(rightmid); if (rightMidValue.gte(midValue)) { // max between mid and right left = mid; leftValue = midValue; mid = rightmid; midValue = rightMidValue; continue; } // max between leftmid and rightmid left = leftmid; leftValue = leftMidValue; right = rightmid; rightValue = rightMidValue; } } if (leftValue.gt(midValue)) { return [left, leftValue]; } else if (midValue.gt(rightValue)) { return [mid, midValue]; } else { return [right, rightValue]; } } static binaryApproximationSearch( left: I80F48, leftValue: I80F48, right: I80F48, targetValue: I80F48, minStep: I80F48, fun: (I80F48) => I80F48, options: { maxIterations?: number; targetError?: number } = {}, ): I80F48 { const maxIterations = options?.maxIterations || 50; const targetError = I80F48.fromNumber(options?.targetError || 0.1); const rightValue = fun(right); // console.log( // ` - binaryApproximationSearch left ${left.toLocaleString()}, leftValue ${leftValue.toLocaleString()}, right ${right.toLocaleString()}, rightValue ${rightValue.toLocaleString()}, targetValue ${targetValue.toLocaleString()}, minStep ${minStep}`, // ); if ( (leftValue.gt(targetValue) && rightValue.gt(targetValue)) || (leftValue.lt(targetValue) && rightValue.lt(targetValue)) ) { throw new Error( `Internal error: left ${leftValue.toNumber()} and right ${rightValue.toNumber()} don't contain the target value ${targetValue.toNumber()}!`, ); } let newAmount, newAmountValue; // eslint-disable-next-line @typescript-eslint/no-unused-vars for (const key of Array(maxIterations).fill(0).keys()) { if (right.sub(left).abs().lt(minStep)) { return left; } newAmount = left.add(right).mul(I80F48.fromNumber(0.5)); newAmountValue = fun(newAmount); // console.log( // ` - left ${left.toLocaleString()}, right ${right.toLocaleString()}, newAmount ${newAmount.toLocaleString()}, newAmountValue ${newAmountValue.toLocaleString()}, targetValue ${targetValue.toLocaleString()}`, // ); const error = newAmountValue.sub(targetValue); if (error.isPos() && error.lt(targetError)) { return newAmount; } if (newAmountValue.gt(targetValue) != rightValue.gt(targetValue)) { left = newAmount; } else { right = newAmount; } } console.error( `Unable to get targetValue within ${maxIterations} iterations, newAmount ${newAmount}, newAmountValue ${newAmountValue}, target ${targetValue}`, ); return newAmount; } getMaxSwapSource( sourceBank: BankForHealth, targetBank: BankForHealth, price: I80F48, ): I80F48 { const health = this.health(HealthType.init); if (health.isNeg()) { return this.getMaxSwapSourceForHealth( sourceBank, targetBank, price, toNativeI80F48ForQuote(1), // target 1 ui usd worth health ); } return this.getMaxSwapSourceForHealthRatio( sourceBank, targetBank, price, I80F48.fromNumber(2), // target 2% health ); } getMaxSwapSourceForHealthRatio( sourceBank: BankForHealth, targetBank: BankForHealth, price: I80F48, minRatio: I80F48, ): I80F48 { return this.getMaxSwapSourceForHealthFn( sourceBank, targetBank, price, minRatio, function (hc: HealthCache): I80F48 { return hc.healthRatio(HealthType.init); }, ); } getMaxSwapSourceForHealth( sourceBank: BankForHealth, targetBank: BankForHealth, price: I80F48, minHealth: I80F48, ): I80F48 { return this.getMaxSwapSourceForHealthFn( sourceBank, targetBank, price, minHealth, function (hc: HealthCache): I80F48 { return hc.health(HealthType.init); }, ); } getMaxSwapSourceForHealthFn( sourceBank: BankForHealth, targetBank: BankForHealth, price: I80F48, minFnValue: I80F48, targetFn: (cache) => I80F48, ): I80F48 { if ( sourceBank.initLiabWeight .sub(targetBank.initAssetWeight) .abs() .lte(ZERO_I80F48()) ) { return ZERO_I80F48(); } // The health and health_ratio are nonlinear based on swap amount. // For large swap amounts the slope is guaranteed to be negative, but small amounts // can have positive slope (e.g. using source deposits to pay back target borrows). // // That means: // - even if the initial value is < minRatio it can be useful to swap to *increase* health // - even if initial value is < 0, swapping can increase health (maybe above 0) // - be careful about finding the minFnValue: the function isn't convex const initialRatio = this.healthRatio(HealthType.init); // eslint-disable-next-line @typescript-eslint/no-unused-vars const healthCacheClone: HealthCache = deepClone(this); const sourceIndex = healthCacheClone.getOrCreateTokenInfoIndex(sourceBank); const targetIndex = healthCacheClone.getOrCreateTokenInfoIndex(targetBank); const source = healthCacheClone.tokenInfos[sourceIndex]; const target = healthCacheClone.tokenInfos[targetIndex]; const res = healthCacheClone.computeSerum3Reservations(HealthType.init); const sourceReserved = res.tokenMaxReserved[sourceIndex].maxSerumReserved; const targetReserved = res.tokenMaxReserved[targetIndex].maxSerumReserved; const tokenBalances = healthCacheClone.effectiveTokenBalances( HealthType.init, ); const sourceBalance = tokenBalances[sourceIndex].spotAndPerp; const targetBalance = tokenBalances[targetIndex].spotAndPerp; // If the price is sufficiently good, then health will just increase from swapping: // once we've swapped enough, swapping x reduces health by x * source_liab_weight and // increases it by x * target_asset_weight * price_factor. const finalHealthSlope = source.initLiabWeight .neg() .mul(source.prices.liab(HealthType.init)) .add( target.initAssetWeight .mul(target.prices.asset(HealthType.init)) .mul(price), ); if (finalHealthSlope.gte(ZERO_I80F48())) { return MAX_I80F48(); } // There are two key slope changes: Assume source.balance > 0 and target.balance < 0. Then // initially health ratio goes up. When one of balances flips sign, the health ratio slope // may be positive or negative for a bit, until both balances have flipped and the slope is // negative. // The maximum will be at one of these points (ignoring serum3 effects). function cacheAfterSwap(amount: I80F48): HealthCache { const adjustedCache: HealthCache = deepClone(healthCacheClone); // adjustedCache.logHealthCache('beforeSwap', adjustedCache); // TODO: make a copy of the bank, apply amount, recompute weights, // and set the new weights on the tokenInfos adjustedCache.tokenInfos[sourceIndex].balanceSpot.isub(amount); adjustedCache.tokenInfos[targetIndex].balanceSpot.iadd(amount.mul(price)); // adjustedCache.logHealthCache('afterSwap', adjustedCache); return adjustedCache; } function fnValueAfterSwap(amount: I80F48): I80F48 { return targetFn(cacheAfterSwap(amount)); } // The function we're looking at has a unique maximum. // // If we discount serum3 reservations, there are two key slope changes: // Assume source.balance > 0 and target.balance < 0. // When these values flip sign, the health slope decreases, but could still be positive. // // The first thing we do is to find this maximum. // The largest amount that the maximum could be at const rightmost = sourceBalance .abs() .add(sourceReserved) .max(targetBalance.abs().add(targetReserved).div(price)); const [amountForMaxValue, maxValue] = HealthCache.findMaximum( ZERO_I80F48(), rightmost, I80F48.fromNumber(0.1), fnValueAfterSwap, ); if (maxValue.lte(minFnValue)) { // We cannot reach min_ratio, just return the max return amountForMaxValue; } let amount: I80F48; // Now max_value is bigger than minFnValue, the target amount must be >amountForMaxValue. // Search to the right of amountForMaxValue: but how far? // Use a simple estimation for the amount that would lead to zero health: // health // - source_liab_weight * source_liab_price * a // + target_asset_weight * target_asset_price * price * a = 0. // where a is the source token native amount. // Note that this is just an estimate. Swapping can increase the amount that serum3 // reserved contributions offset, moving the actual zero point further to the right. const healthAtMaxValue = cacheAfterSwap(amountForMaxValue).health( HealthType.init, ); if (healthAtMaxValue.eq(ZERO_I80F48())) { return amountForMaxValue; } else if (healthAtMaxValue.lt(ZERO_I80F48())) { return ZERO_I80F48(); } const zeroHealthEstimate = amountForMaxValue.sub( healthAtMaxValue.div(finalHealthSlope), ); const rightBound = HealthCache.scanRightUntilLessThan( zeroHealthEstimate, minFnValue, fnValueAfterSwap, ); if (rightBound.eq(zeroHealthEstimate)) { amount = HealthCache.binaryApproximationSearch( amountForMaxValue, maxValue, rightBound, minFnValue, I80F48.fromNumber(0.1), fnValueAfterSwap, ); } else { // Must be between 0 and point0_amount amount = HealthCache.binaryApproximationSearch( zeroHealthEstimate, fnValueAfterSwap(zeroHealthEstimate), rightBound, minFnValue, I80F48.fromNumber(0.1), fnValueAfterSwap, ); } return amount; } getMaxSerum3OrderForHealthRatio( baseBank: BankForHealth, quoteBank: BankForHealth, serum3Market: Serum3Market, side: Serum3Side, minRatio: I80F48, ): I80F48 { const healthCacheClone: HealthCache = deepClone(this); const baseIndex = healthCacheClone.getOrCreateTokenInfoIndex(baseBank); const quoteIndex = healthCacheClone.getOrCreateTokenInfoIndex(quoteBank); const base = healthCacheClone.tokenInfos[baseIndex]; const quote = healthCacheClone.tokenInfos[quoteIndex]; const res = healthCacheClone.computeSerum3Reservations(HealthType.init); const baseReserved = res.tokenMaxReserved[baseIndex].maxSerumReserved; const quoteReserved = res.tokenMaxReserved[quoteIndex].maxSerumReserved; // Binary search between current health (0 sized new order) and // an amount to trade which will bring health to 0. // Current health and amount i.e. 0 const initialAmount = ZERO_I80F48(); const initialHealth = this.health(HealthType.init); const initialRatio = this.healthRatio(HealthType.init); if (initialRatio.lte(ZERO_I80F48())) { return ZERO_I80F48(); } // console.log(`getMaxSerum3OrderForHealthRatio`); // Amount which would bring health to 0 // amount = M + (init_health + M * (B_init_liab - A_init_asset) + R) / (A_init_liab - B_init_asset); // where M = max(A_deposits, B_borrows) // and R = reserved serum A amount (because they might offset A borrows) // A is what we would be essentially swapping for B // So when its an ask, then base->quote, // and when its a bid, then quote->bid let zeroAmount; if (side == Serum3Side.ask) { const quoteBorrows = quote.balanceSpot.lt(ZERO_I80F48()) ? quote.balanceSpot.abs().mul(quote.prices.liab(HealthType.init)) : ZERO_I80F48(); const max = base.balanceSpot.mul(base.prices.oracle).max(quoteBorrows); zeroAmount = max.add( initialHealth .add(max.mul(quote.initLiabWeight.sub(base.initScaledAssetWeight))) .add(baseReserved.mul(base.prices.liab(HealthType.init))) .div( base .liabWeight(HealthType.init) .sub(quote.assetWeight(HealthType.init)), ), ); // console.log(` - quoteBorrows ${quoteBorrows.toLocaleString()}`); // console.log(` - max ${max.toLocaleString()}`); } else { const baseBorrows = base.balanceSpot.lt(ZERO_I80F48()) ? base.balanceSpot.abs().mul(base.prices.liab(HealthType.init)) : ZERO_I80F48(); const max = quote.balanceSpot.mul(quote.prices.oracle).max(baseBorrows); zeroAmount = max.add( initialHealth .add(max.mul(base.initLiabWeight.sub(quote.initScaledAssetWeight))) .add(quoteReserved.mul(quote.prices.liab(HealthType.init))) .div( quote .liabWeight(HealthType.init) .sub(base.assetWeight(HealthType.init)), ), ); // console.log(` - baseBorrows ${baseBorrows.toLocaleString()}`); // console.log(` - max ${max.toLocaleString()}`); } const cache = cacheAfterPlacingOrder(zeroAmount); // eslint-disable-next-line @typescript-eslint/no-unused-vars const zeroAmountHealth = cache.health(HealthType.init); const zeroAmountRatio = cache.healthRatio(HealthType.init); // console.log(` - zeroAmount ${zeroAmount.toLocaleString()}`); // console.log(` - zeroAmountHealth ${zeroAmountHealth.toLocaleString()}`); // console.log(` - zeroAmountRatio ${zeroAmountRatio.toLocaleString()}`); function cacheAfterPlacingOrder(amount: I80F48): HealthCache { const adjustedCache: HealthCache = deepClone(healthCacheClone); // adjustedCache.logHealthCache(` before placing order ${amount}`); // TODO: there should also be some issue with oracle vs stable price here; // probably better to pass in not the quote amount but the base or quote native amount side === Serum3Side.ask ? adjustedCache.tokenInfos[baseIndex].balanceSpot.isub( amount.div(base.prices.oracle), ) : adjustedCache.tokenInfos[quoteIndex].balanceSpot.isub( amount.div(quote.prices.oracle), ); adjustedCache.adjustSerum3Reserved( baseBank, quoteBank, serum3Market, side === Serum3Side.ask ? amount.div(base.prices.oracle) : ZERO_I80F48(), ZERO_I80F48(), side === Serum3Side.bid ? amount.div(quote.prices.oracle) : ZERO_I80F48(), ZERO_I80F48(), ); // adjustedCache.logHealthCache(' after placing order'); return adjustedCache; } function healthRatioAfterPlacingOrder(amount: I80F48): I80F48 { return cacheAfterPlacingOrder(amount).healthRatio(HealthType.init); } const amount = HealthCache.binaryApproximationSearch( initialAmount, initialRatio, zeroAmount, minRatio, ONE_I80F48(), healthRatioAfterPlacingOrder, ); return amount; } getMaxPerpForHealthRatio( perpMarket: PerpMarket, price, side: PerpOrderSide, minRatio: I80F48, ): I80F48 { const healthCacheClone: HealthCache = deepClone(this); const initialRatio = this.healthRatio(HealthType.init); if (initialRatio.lt(ZERO_I80F48())) { return ZERO_I80F48(); } const direction = side == PerpOrderSide.bid ? 1 : -1; const perpInfoIndex = healthCacheClone.getOrCreatePerpInfoIndex(perpMarket); const perpInfo = healthCacheClone.perpInfos[perpInfoIndex]; const prices = perpInfo.basePrices; const baseLotSize = I80F48.fromI64(perpMarket.baseLotSize); const settleInfoIndex = this.findTokenInfoIndex(perpInfo.settleTokenIndex); const settleInfo = this.tokenInfos[settleInfoIndex]; const finalHealthSlope = direction == 1 ? perpInfo.initBaseAssetWeight .mul(prices.asset(HealthType.init)) .sub(price) : perpInfo.initBaseLiabWeight .neg() .mul(prices.liab(HealthType.init)) .add(price); if (finalHealthSlope.gte(ZERO_I80F48())) { return MAX_I80F48(); } finalHealthSlope.imul(settleInfo.liabWeightedPrice(HealthType.init)); function cacheAfterTrade(baseLots: BN): HealthCache { const adjustedCache: HealthCache = deepClone(healthCacheClone); // adjustedCache.logHealthCache(' -- before trade'); adjustedCache.adjustPerpInfo(perpInfoIndex, price, side, baseLots); // adjustedCache.logHealthCache(' -- after trade'); return adjustedCache; } function healthAfterTrade(baseLots: I80F48): I80F48 { return cacheAfterTrade(new BN(baseLots.toNumber())).health( HealthType.init, ); } function healthRatioAfterTrade(baseLots: I80F48): I80F48 { return cacheAfterTrade(new BN(baseLots.toNumber())).healthRatio( HealthType.init, ); } function healthRatioAfterTradeTrunc(baseLots: I80F48): I80F48 { return healthRatioAfterTrade(baseLots.floor()); } const initialBaseLots = I80F48.fromU64(perpInfo.baseLots); // There are two cases: // 1. We are increasing abs(baseLots) // 2. We are bringing the base position to 0, and then going to case 1. const hasCase2 = (initialBaseLots.gt(ZERO_I80F48()) && direction == -1) || (initialBaseLots.lt(ZERO_I80F48()) && direction == 1); let case1Start: I80F48, case1StartRatio: I80F48; if (hasCase2) { case1Start = initialBaseLots.abs(); case1StartRatio = healthRatioAfterTrade(case1Start); } else { case1Start = ZERO_I80F48(); case1StartRatio = initialRatio; } // If we start out below minRatio and can't go above, pick the best case let baseLots: I80F48; if (initialRatio.lte(minRatio) && case1StartRatio.lt(minRatio)) { if (case1StartRatio.gte(initialRatio)) { baseLots = case1Start; } else { baseLots = ZERO_I80F48(); } } else if (case1StartRatio.gte(minRatio)) { // Must reach minRatio to the right of case1Start // Need to figure out how many lots to trade to reach zero health (zero_health_amount). // We do this by looking at the starting health and the health slope per // traded base lot (finalHealthSlope). const startCache = cacheAfterTrade(new BN(case1Start.toNumber())); startCache.perpInfos[perpInfoIndex].initOverallAssetWeight = ONE_I80F48(); const settleInfo = startCache.tokenInfos[settleInfoIndex]; settleInfo.initAssetWeight = settleInfo.initLiabWeight; settleInfo.initScaledAssetWeight = settleInfo.initScaledLiabWeight; const startHealth = startCache.health(HealthType.init); if (startHealth.lte(ZERO_I80F48())) { return ZERO_I80F48(); } const zeroHealthAmount = case1Start .sub( startHealth.div( finalHealthSlope.mul(baseLotSize).mul(I80F48.fromNumber(0.99)), ), ) .add(ONE_I80F48()); const zeroHealthRatio = healthRatioAfterTradeTrunc(zeroHealthAmount); // console.log(`case1Start ${case1Start}`); // console.log(`case1StartRatio ${case1StartRatio}`); // console.log(`zeroHealthAmount ${zeroHealthAmount}`); // console.log(`zeroHealthRatio ${zeroHealthRatio}`); // console.log(`minRatio ${minRatio}`); baseLots = HealthCache.binaryApproximationSearch( case1Start, case1StartRatio, zeroHealthAmount, zeroHealthRatio.max(minRatio), // workaround, originally minRatio ONE_I80F48(), healthRatioAfterTradeTrunc, ); } else { // Between 0 and case1Start baseLots = HealthCache.binaryApproximationSearch( ZERO_I80F48(), initialRatio, case1Start, minRatio, ONE_I80F48(), healthRatioAfterTradeTrunc, ); } return baseLots.floor(); } public getPerpPositionLiquidationPrice( group: Group, mangoAccount: MangoAccount, perpPosition: PerpPosition, ): I80F48 | null { const hc = HealthCache.fromMangoAccount(group, mangoAccount); const hcClone = deepClone(hc); const perpMarket = group.getPerpMarketByMarketIndex( perpPosition.marketIndex, ); function healthAfterPriceChange(newPrice: I80F48): I80F48 { const pi: PerpInfo = hcClone.perpInfos[hcClone.findPerpInfoIndex(perpPosition.marketIndex)]; pi.basePrices.oracle = newPrice; return hcClone.health(HealthType.maint); } if (perpPosition.getBasePosition(perpMarket).isPos()) { const zero = ZERO_I80F48(); const healthAtPriceZero = healthAfterPriceChange(zero); if (healthAtPriceZero.gt(ZERO_I80F48())) { return null; } return HealthCache.binaryApproximationSearch( zero, healthAtPriceZero, perpMarket.price, ZERO_I80F48(), perpMarket.priceLotsToNative(new BN(1)), healthAfterPriceChange, ); } const price1000x = perpMarket.price.mul(I80F48.fromNumber(1000)); return HealthCache.binaryApproximationSearch( perpMarket.price, hcClone.health(HealthType.maint), price1000x, ZERO_I80F48(), perpMarket.priceLotsToNative(new BN(1)), healthAfterPriceChange, ); } } export class Prices { constructor(public oracle: I80F48, public stable: I80F48) {} public liab(healthType: HealthType | undefined): I80F48 { if ( healthType === HealthType.maint || healthType === HealthType.liquidationEnd || healthType === undefined ) { return this.oracle; } return this.oracle.max(this.stable); } public asset(healthType: HealthType | undefined): I80F48 { if ( healthType === HealthType.maint || healthType === HealthType.liquidationEnd || healthType === undefined ) { return this.oracle; } return this.oracle.min(this.stable); } } export class TokenInfo { constructor( public tokenIndex: TokenIndex, public maintAssetWeight: I80F48, public initAssetWeight: I80F48, public initScaledAssetWeight: I80F48, public maintLiabWeight: I80F48, public initLiabWeight: I80F48, public initScaledLiabWeight: I80F48, public prices: Prices, public balanceSpot: I80F48, ) {} static fromBank(bank: BankForHealth, nativeBalance?: I80F48): TokenInfo { const p = new Prices( bank.price, I80F48.fromNumber(bank.stablePriceModel.stablePrice), ); // Use the liab price for computing weight scaling, because it's pessimistic and // causes the most unfavorable scaling. const liabPrice = p.liab(HealthType.init); const [maintAssetWeight, maintLiabWeight] = bank.maintWeights(); return new TokenInfo( bank.tokenIndex, maintAssetWeight, bank.initAssetWeight, bank.scaledInitAssetWeight(liabPrice), maintLiabWeight, bank.initLiabWeight, bank.scaledInitLiabWeight(liabPrice), p, nativeBalance ? nativeBalance : ZERO_I80F48(), ); } assetWeight(healthType: HealthType | undefined): I80F48 { if (healthType == HealthType.init) { return this.initScaledAssetWeight; } else if (healthType == HealthType.liquidationEnd) { return this.initAssetWeight; } if (healthType == HealthType.maint) { return this.maintAssetWeight; } return I80F48.fromNumber(1); } assetWeightedPrice(healthType: HealthType | undefined): I80F48 { return this.assetWeight(healthType).mul(this.prices.asset(healthType)); } liabWeight(healthType: HealthType | undefined): I80F48 { if (healthType == HealthType.init) { return this.initScaledLiabWeight; } else if (healthType == HealthType.liquidationEnd) { return this.initLiabWeight; } if (healthType == HealthType.maint) { return this.maintLiabWeight; } return I80F48.fromNumber(1); } liabWeightedPrice(healthType: HealthType | undefined): I80F48 { return this.liabWeight(healthType).mul(this.prices.liab(healthType)); } healthContribution( healthType: HealthType | undefined, balance: I80F48, ): I80F48 { if (healthType === undefined) { return balance.mul(this.prices.oracle); } // console.log(`balance ${balance}`); return balance.isNeg() ? balance.mul(this.liabWeightedPrice(healthType)) : balance.mul(this.assetWeightedPrice(healthType)); } toString(balance: I80F48): string { return ` tokenIndex: ${this.tokenIndex}, balanceNative: ${ this.balanceSpot }, initHealth ${this.healthContribution(HealthType.init, balance)}`; } } class TokenBalance { constructor(public spotAndPerp: I80F48) {} } class TokenBalanceDisplay { constructor( public spotAndPerp: I80F48, public spotUi: number, public perpMarketContributions: { market: string; contributionUi: number; }[], ) {} } class TokenMaxReserved { constructor(public maxSerumReserved: I80F48) {} } export class Serum3Reserved { constructor( public allReservedAsBase: I80F48, public allReservedAsQuote: I80F48, ) {} } export class Serum3Info { constructor( public reservedBase: I80F48, public reservedQuote: I80F48, public reservedBaseAsQuoteLowestAsk: I80F48, public reservedQuoteAsBaseHighestBid: I80F48, public baseInfoIndex: number, public quoteInfoIndex: number, public marketIndex: MarketIndex, ) {} static emptyFromSerum3Market( serum3Market: Serum3Market, baseEntryIndex: number, quoteEntryIndex: number, ): Serum3Info { return new Serum3Info( ZERO_I80F48(), ZERO_I80F48(), ZERO_I80F48(), ZERO_I80F48(), baseEntryIndex, quoteEntryIndex, serum3Market.marketIndex, ); } static fromOoModifyingTokenInfos( serumAccount: Serum3Orders, baseInfoIndex: number, baseInfo: TokenInfo, quoteInfoIndex: number, quoteInfo: TokenInfo, marketIndex: MarketIndex, oo: OpenOrders, ): Serum3Info { // add the amounts that are freely settleable immediately to token balances const baseFree = I80F48.fromI64(oo.baseTokenFree); const quoteFree = I80F48.fromI64(oo.quoteTokenFree); baseInfo.balanceSpot.iadd(baseFree); quoteInfo.balanceSpot.iadd(quoteFree); // track the reserved amounts const reservedBase = I80F48.fromI64( oo.baseTokenTotal.sub(oo.baseTokenFree), ); const reservedQuote = I80F48.fromI64( oo.quoteTokenTotal.sub(oo.quoteTokenFree), ); const reservedBaseAsQuoteLowestAsk = reservedBase.mul( I80F48.fromNumber(serumAccount.lowestPlacedAsk), ); const reservedQuoteAsBaseHighestBid = reservedQuote.mul( I80F48.fromNumber(serumAccount.highestPlacedBidInv), ); return new Serum3Info( reservedBase, reservedQuote, reservedBaseAsQuoteLowestAsk, reservedQuoteAsBaseHighestBid, baseInfoIndex, quoteInfoIndex, marketIndex, ); } // An undefined HealthType will use an asset and liab weight of 1 healthContribution( healthType: HealthType | undefined, tokenInfos: TokenInfo[], tokenBalances: TokenBalance[], tokenMaxReserved: TokenMaxReserved[], marketReserved: Serum3Reserved, ): I80F48 { if ( marketReserved.allReservedAsBase.isZero() || marketReserved.allReservedAsQuote.isZero() ) { return ZERO_I80F48(); } const baseInfo = tokenInfos[this.baseInfoIndex]; const quoteInfo = tokenInfos[this.quoteInfoIndex]; const baseMaxReserved = tokenMaxReserved[this.baseInfoIndex]; const quoteMaxReserved = tokenMaxReserved[this.quoteInfoIndex]; // How much the health would increase if the reserved balance were applied to the passed // token info? const computeHealthEffect = function ( tokenInfo: TokenInfo, balance: TokenBalance, maxReserved: TokenMaxReserved, marketReserved: I80F48, ): I80F48 { // This balance includes all possible reserved funds from markets that relate to the // token, including this market itself: `tokenMaxReserved` is already included in `maxBalance`. const maxBalance = balance.spotAndPerp.add(maxReserved.maxSerumReserved); // Assuming `reserved` was added to `max_balance` last (because that gives the smallest // health effects): how much did health change because of it? let assetPart, liabPart; if (maxBalance.gte(marketReserved)) { assetPart = marketReserved; liabPart = ZERO_I80F48(); } else if (maxBalance.isNeg()) { assetPart = ZERO_I80F48(); liabPart = marketReserved; } else { assetPart = maxBalance; liabPart = marketReserved.sub(maxBalance); } if (healthType === undefined) { return assetPart .mul(tokenInfo.prices.oracle) .add(liabPart.mul(tokenInfo.prices.oracle)); } const assetWeight = tokenInfo.assetWeight(healthType); const liabWeight = tokenInfo.liabWeight(healthType); const assetPrice = tokenInfo.prices.asset(healthType); const liabPrice = tokenInfo.prices.liab(healthType); return assetWeight .mul(assetPart) .mul(assetPrice) .add(liabWeight.mul(liabPart).mul(liabPrice)); }; const healthBase = computeHealthEffect( baseInfo, tokenBalances[this.baseInfoIndex], tokenMaxReserved[this.baseInfoIndex], marketReserved.allReservedAsBase, ); const healthQuote = computeHealthEffect( quoteInfo, tokenBalances[this.quoteInfoIndex], tokenMaxReserved[this.quoteInfoIndex], marketReserved.allReservedAsQuote, ); // console.log(` - healthBase ${healthBase.toLocaleString()}`); // console.log(` - healthQuote ${healthQuote.toLocaleString()}`); return healthBase.min(healthQuote); } toString( tokenInfos: TokenInfo[], tokenBalances: TokenBalance[], tokenMaxReserved: TokenMaxReserved[], marketReserved: Serum3Reserved, ): string { return ` marketIndex: ${this.marketIndex}, baseInfoIndex: ${ this.baseInfoIndex }, quoteInfoIndex: ${this.quoteInfoIndex}, reservedBase: ${ this.reservedBase }, reservedQuote: ${ this.reservedQuote }, initHealth ${this.healthContribution( HealthType.init, tokenInfos, tokenBalances, tokenMaxReserved, marketReserved, )}`; } } export class PerpInfo { constructor( public perpMarketIndex: number, public settleTokenIndex: TokenIndex, public maintBaseAssetWeight: I80F48, public initBaseAssetWeight: I80F48, public maintBaseLiabWeight: I80F48, public initBaseLiabWeight: I80F48, public maintOverallAssetWeight: I80F48, public initOverallAssetWeight: I80F48, public baseLotSize: BN, public baseLots: BN, public bidsBaseLots: BN, public asksBaseLots: BN, public quote: I80F48, public basePrices: Prices, public hasOpenOrders: boolean, ) {} static fromPerpPosition( perpMarket: PerpMarket, perpPosition: PerpPosition, ): PerpInfo { const baseLots = perpPosition.basePositionLots.add( perpPosition.takerBaseLots, ); const unsettledFunding = perpPosition.getUnsettledFunding(perpMarket); const takerQuote = I80F48.fromI64( new BN(perpPosition.takerQuoteLots).mul(perpMarket.quoteLotSize), ); const quoteCurrent = perpPosition.quotePositionNative .sub(unsettledFunding) .add(takerQuote); return new PerpInfo( perpMarket.perpMarketIndex, perpMarket.settleTokenIndex, perpMarket.maintBaseAssetWeight, perpMarket.initBaseAssetWeight, perpMarket.maintBaseLiabWeight, perpMarket.initBaseLiabWeight, perpMarket.maintOverallAssetWeight, perpMarket.initOverallAssetWeight, perpMarket.baseLotSize, baseLots, perpPosition.bidsBaseLots, perpPosition.asksBaseLots, quoteCurrent, new Prices( perpMarket.price, I80F48.fromNumber(perpMarket.stablePriceModel.stablePrice), ), perpPosition.hasOpenOrders(), ); } healthContribution(healthType: HealthType, settleToken: TokenInfo): I80F48 { const contrib = this.unweightedHealthUnsettledPnl(healthType); return this.weighHealthContributionSettle( this.weighHealthContributionOverall(contrib, healthType), healthType, settleToken, ); } healthUnsettledPnl(healthType: HealthType | undefined): I80F48 { const contrib = this.unweightedHealthUnsettledPnl(healthType); return this.weighHealthContributionOverall(contrib, healthType); } weighHealthContributionSettle( unweighted: I80F48, healthType: HealthType, settleToken: TokenInfo, ): I80F48 { if (this.settleTokenIndex !== settleToken.tokenIndex) { throw new Error('Settle token index should match!'); } if (unweighted.gt(ZERO_I80F48())) { return ( healthType == HealthType.init ? settleToken.initScaledAssetWeight : healthType == HealthType.liquidationEnd ? settleToken.initAssetWeight : settleToken.maintLiabWeight ) .mul(unweighted) .mul(settleToken.prices.asset(healthType)); } return ( healthType == HealthType.init ? settleToken.initScaledLiabWeight : healthType == HealthType.liquidationEnd ? settleToken.initLiabWeight : settleToken.maintLiabWeight ) .mul(unweighted) .mul(settleToken.prices.liab(healthType)); } weighHealthContributionOverall( unweighted: I80F48, healthType: HealthType | undefined, ): I80F48 { if (unweighted.gt(ZERO_I80F48())) { return ( healthType == HealthType.init || healthType == HealthType.liquidationEnd ? this.initOverallAssetWeight : this.maintOverallAssetWeight ).mul(unweighted); } return unweighted; } unweightedHealthUnsettledPnl(healthType: HealthType | undefined): I80F48 { function orderExecutionCase( pi: PerpInfo, ordersBaseLots: BN, orderPrice: I80F48, ): I80F48 { const netBaseNative = I80F48.fromU64( pi.baseLots.add(ordersBaseLots).mul(pi.baseLotSize), ); let weight, basePrice; if ( healthType == HealthType.init || healthType == HealthType.liquidationEnd ) { if (netBaseNative.isNeg()) { weight = pi.initBaseLiabWeight; } else { weight = pi.initBaseAssetWeight; } } // healthType == HealthType.maint else { if (netBaseNative.isNeg()) { weight = pi.maintBaseLiabWeight; } else { weight = pi.maintBaseAssetWeight; } } if (netBaseNative.isNeg()) { basePrice = pi.basePrices.liab(healthType); } else { basePrice = pi.basePrices.asset(healthType); } // Total value of the order-execution adjusted base position const baseHealth = netBaseNative.mul(weight).mul(basePrice); const ordersBaseNative = I80F48.fromU64( ordersBaseLots.mul(pi.baseLotSize), ); // The quote change from executing the bids/asks const orderQuote = ordersBaseNative.neg().mul(orderPrice); return baseHealth.add(orderQuote); } // What is worse: Executing all bids at oracle_price.liab, or executing all asks at oracle_price.asset? const bidsCase = orderExecutionCase( this, this.bidsBaseLots, this.basePrices.liab(healthType), ); const asksCase = orderExecutionCase( this, this.asksBaseLots.neg(), this.basePrices.asset(healthType), ); const worstCase = bidsCase.min(asksCase); return this.quote.add(worstCase); } static emptyFromPerpMarket(perpMarket: PerpMarket): PerpInfo { return new PerpInfo( perpMarket.perpMarketIndex, perpMarket.settleTokenIndex, perpMarket.maintBaseAssetWeight, perpMarket.initBaseAssetWeight, perpMarket.maintBaseLiabWeight, perpMarket.initBaseLiabWeight, perpMarket.maintOverallAssetWeight, perpMarket.initOverallAssetWeight, perpMarket.baseLotSize, new BN(0), new BN(0), new BN(0), ZERO_I80F48(), new Prices( perpMarket.price, I80F48.fromNumber(perpMarket.stablePriceModel.stablePrice), ), false, ); } toString(): string { return ` perpMarketIndex: ${this.perpMarketIndex}, base: ${ this.baseLots }, quote: ${this.quote}, oraclePrice: ${ this.basePrices.oracle }, uncapped health contribution ${this.unweightedHealthUnsettledPnl( HealthType.init, )}`; } }