From 53d6f24f1d0403ef516567a5e60049b6870c80d6 Mon Sep 17 00:00:00 2001 From: Lukas deConantsesznak Date: Wed, 29 Oct 2025 14:19:24 -0600 Subject: [PATCH 01/13] refactor: margin calc matches on chain func --- sdk/src/marginCalculation.ts | 315 ++++++++++++++++ sdk/src/math/spotPosition.ts | 5 +- sdk/src/types.ts | 6 + sdk/src/user.ts | 677 ++++++++++++++++++++++++++++++----- 4 files changed, 919 insertions(+), 84 deletions(-) create mode 100644 sdk/src/marginCalculation.ts diff --git a/sdk/src/marginCalculation.ts b/sdk/src/marginCalculation.ts new file mode 100644 index 0000000000..43452243a9 --- /dev/null +++ b/sdk/src/marginCalculation.ts @@ -0,0 +1,315 @@ +import { BN } from '@coral-xyz/anchor'; +import { MARGIN_PRECISION } from './constants/numericConstants'; +import { MarketType } from './types'; + +export type MarginCategory = 'Initial' | 'Maintenance' | 'Fill'; + +export type MarginCalculationMode = + | { type: 'Standard' } + | { type: 'Liquidation' }; + +export class MarketIdentifier { + marketType: MarketType; + marketIndex: number; + + private constructor(marketType: MarketType, marketIndex: number) { + this.marketType = marketType; + this.marketIndex = marketIndex; + } + + static spot(marketIndex: number): MarketIdentifier { + return new MarketIdentifier(MarketType.SPOT, marketIndex); + } + + static perp(marketIndex: number): MarketIdentifier { + return new MarketIdentifier(MarketType.PERP, marketIndex); + } + + equals(other: MarketIdentifier | undefined): boolean { + return ( + !!other && + this.marketType === other.marketType && + this.marketIndex === other.marketIndex + ); + } +} + +export class MarginContext { + marginType: MarginCategory; + mode: MarginCalculationMode; + strict: boolean; + ignoreInvalidDepositOracles: boolean; + marginBuffer: BN; // scaled by MARGIN_PRECISION + marginRatioOverride?: number; + + private constructor(marginType: MarginCategory) { + this.marginType = marginType; + this.mode = { type: 'Standard' }; + this.strict = false; + this.ignoreInvalidDepositOracles = false; + this.marginBuffer = new BN(0); + } + + static standard(marginType: MarginCategory): MarginContext { + return new MarginContext(marginType); + } + + static liquidation(marginBuffer: BN): MarginContext { + const ctx = new MarginContext('Maintenance'); + ctx.mode = { type: 'Liquidation' }; + ctx.marginBuffer = marginBuffer ?? new BN(0); + return ctx; + } + + strictMode(strict: boolean): this { + this.strict = strict; + return this; + } + + ignoreInvalidDeposits(ignore: boolean): this { + this.ignoreInvalidDepositOracles = ignore; + return this; + } + + setMarginBuffer(buffer?: BN): this { + this.marginBuffer = buffer ?? new BN(0); + return this; + } + + setMarginRatioOverride(ratio: number): this { + this.marginRatioOverride = ratio; + return this; + } + + trackMarketMarginRequirement(marketIdentifier: MarketIdentifier): this { + if (this.mode.type !== 'Liquidation') { + throw new Error( + 'InvalidMarginCalculation: Cant track market outside of liquidation mode' + ); + } + return this; + } +} + +export class IsolatedMarginCalculation { + marginRequirement: BN; + totalCollateral: BN; // deposit + pnl + totalCollateralBuffer: BN; + marginRequirementPlusBuffer: BN; + + constructor() { + this.marginRequirement = new BN(0); + this.totalCollateral = new BN(0); + this.totalCollateralBuffer = new BN(0); + this.marginRequirementPlusBuffer = new BN(0); + } + + getTotalCollateralPlusBuffer(): BN { + return this.totalCollateral.add(this.totalCollateralBuffer); + } + + meetsMarginRequirement(): boolean { + return this.totalCollateral.gte(this.marginRequirement); + } + + meetsMarginRequirementWithBuffer(): boolean { + return this.getTotalCollateralPlusBuffer().gte( + this.marginRequirementPlusBuffer + ); + } + + marginShortage(): BN { + const shortage = this.marginRequirementPlusBuffer.sub( + this.getTotalCollateralPlusBuffer() + ); + return shortage.isNeg() ? new BN(0) : shortage; + } +} + +export class MarginCalculation { + context: MarginContext; + totalCollateral: BN; + totalCollateralBuffer: BN; + marginRequirement: BN; + marginRequirementPlusBuffer: BN; + isolatedMarginCalculations: Map; + numSpotLiabilities: number; + numPerpLiabilities: number; + allDepositOraclesValid: boolean; + allLiabilityOraclesValid: boolean; + withPerpIsolatedLiability: boolean; + withSpotIsolatedLiability: boolean; + totalSpotLiabilityValue: BN; + totalPerpLiabilityValue: BN; + trackedMarketMarginRequirement: BN; + fuelDeposits: number; + fuelBorrows: number; + fuelPositions: number; + + constructor(context: MarginContext) { + this.context = context; + this.totalCollateral = new BN(0); + this.totalCollateralBuffer = new BN(0); + this.marginRequirement = new BN(0); + this.marginRequirementPlusBuffer = new BN(0); + this.isolatedMarginCalculations = new Map(); + this.numSpotLiabilities = 0; + this.numPerpLiabilities = 0; + this.allDepositOraclesValid = true; + this.allLiabilityOraclesValid = true; + this.withPerpIsolatedLiability = false; + this.withSpotIsolatedLiability = false; + this.totalSpotLiabilityValue = new BN(0); + this.totalPerpLiabilityValue = new BN(0); + this.trackedMarketMarginRequirement = new BN(0); + this.fuelDeposits = 0; + this.fuelBorrows = 0; + this.fuelPositions = 0; + } + + addCrossMarginTotalCollateral(delta: BN): void { + this.totalCollateral = this.totalCollateral.add(delta); + if (this.context.marginBuffer.gt(new BN(0)) && delta.isNeg()) { + this.totalCollateralBuffer = this.totalCollateralBuffer.add( + delta.mul(this.context.marginBuffer).div(MARGIN_PRECISION) + ); + } + } + + addCrossMarginRequirement(marginRequirement: BN, liabilityValue: BN): void { + this.marginRequirement = this.marginRequirement.add(marginRequirement); + if (this.context.marginBuffer.gt(new BN(0))) { + this.marginRequirementPlusBuffer = this.marginRequirementPlusBuffer.add( + marginRequirement.add( + liabilityValue.mul(this.context.marginBuffer).div(MARGIN_PRECISION) + ) + ); + } + } + + addIsolatedMarginCalculation( + marketIndex: number, + depositValue: BN, + pnl: BN, + liabilityValue: BN, + marginRequirement: BN + ): void { + const totalCollateral = depositValue.add(pnl); + const totalCollateralBuffer = + this.context.marginBuffer.gt(new BN(0)) && pnl.isNeg() + ? pnl.mul(this.context.marginBuffer).div(MARGIN_PRECISION) + : new BN(0); + + const marginRequirementPlusBuffer = this.context.marginBuffer.gt(new BN(0)) + ? marginRequirement.add( + liabilityValue.mul(this.context.marginBuffer).div(MARGIN_PRECISION) + ) + : new BN(0); + + const iso = new IsolatedMarginCalculation(); + iso.marginRequirement = marginRequirement; + iso.totalCollateral = totalCollateral; + iso.totalCollateralBuffer = totalCollateralBuffer; + iso.marginRequirementPlusBuffer = marginRequirementPlusBuffer; + this.isolatedMarginCalculations.set(marketIndex, iso); + } + + addSpotLiability(): void { + this.numSpotLiabilities += 1; + } + + addPerpLiability(): void { + this.numPerpLiabilities += 1; + } + + addSpotLiabilityValue(spotLiabilityValue: BN): void { + this.totalSpotLiabilityValue = + this.totalSpotLiabilityValue.add(spotLiabilityValue); + } + + addPerpLiabilityValue(perpLiabilityValue: BN): void { + this.totalPerpLiabilityValue = + this.totalPerpLiabilityValue.add(perpLiabilityValue); + } + + updateAllDepositOraclesValid(valid: boolean): void { + this.allDepositOraclesValid = this.allDepositOraclesValid && valid; + } + + updateAllLiabilityOraclesValid(valid: boolean): void { + this.allLiabilityOraclesValid = this.allLiabilityOraclesValid && valid; + } + + updateWithSpotIsolatedLiability(isolated: boolean): void { + this.withSpotIsolatedLiability = this.withSpotIsolatedLiability || isolated; + } + + updateWithPerpIsolatedLiability(isolated: boolean): void { + this.withPerpIsolatedLiability = this.withPerpIsolatedLiability || isolated; + } + + validateNumSpotLiabilities(): void { + if (this.numSpotLiabilities > 0 && this.marginRequirement.eq(new BN(0))) { + throw new Error( + 'InvalidMarginRatio: num_spot_liabilities>0 but margin_requirement=0' + ); + } + } + + getNumOfLiabilities(): number { + return this.numSpotLiabilities + this.numPerpLiabilities; + } + + getCrossTotalCollateralPlusBuffer(): BN { + return this.totalCollateral.add(this.totalCollateralBuffer); + } + + meetsCrossMarginRequirement(): boolean { + return this.totalCollateral.gte(this.marginRequirement); + } + + meetsCrossMarginRequirementWithBuffer(): boolean { + return this.getCrossTotalCollateralPlusBuffer().gte( + this.marginRequirementPlusBuffer + ); + } + + meetsMarginRequirement(): boolean { + if (!this.meetsCrossMarginRequirement()) return false; + for (const [, iso] of this.isolatedMarginCalculations) { + if (!iso.meetsMarginRequirement()) return false; + } + return true; + } + + meetsMarginRequirementWithBuffer(): boolean { + if (!this.meetsCrossMarginRequirementWithBuffer()) return false; + for (const [, iso] of this.isolatedMarginCalculations) { + if (!iso.meetsMarginRequirementWithBuffer()) return false; + } + return true; + } + + getCrossFreeCollateral(): BN { + const free = this.totalCollateral.sub(this.marginRequirement); + return free.isNeg() ? new BN(0) : free; + } + + getIsolatedFreeCollateral(marketIndex: number): BN { + const iso = this.isolatedMarginCalculations.get(marketIndex); + if (!iso) + throw new Error('InvalidMarginCalculation: missing isolated calc'); + const free = iso.totalCollateral.sub(iso.marginRequirement); + return free.isNeg() ? new BN(0) : free; + } + + getIsolatedMarginCalculation( + marketIndex: number + ): IsolatedMarginCalculation | undefined { + return this.isolatedMarginCalculations.get(marketIndex); + } + + hasIsolatedMarginCalculation(marketIndex: number): boolean { + return this.isolatedMarginCalculations.has(marketIndex); + } +} \ No newline at end of file diff --git a/sdk/src/math/spotPosition.ts b/sdk/src/math/spotPosition.ts index 61eae83ec1..036f5cf83b 100644 --- a/sdk/src/math/spotPosition.ts +++ b/sdk/src/math/spotPosition.ts @@ -33,7 +33,8 @@ export function getWorstCaseTokenAmounts( spotMarketAccount: SpotMarketAccount, strictOraclePrice: StrictOraclePrice, marginCategory: MarginCategory, - customMarginRatio?: number + customMarginRatio?: number, + includeOpenOrders?: boolean ): OrderFillSimulation { const tokenAmount = getSignedTokenAmount( getTokenAmount( @@ -50,7 +51,7 @@ export function getWorstCaseTokenAmounts( strictOraclePrice ); - if (spotPosition.openBids.eq(ZERO) && spotPosition.openAsks.eq(ZERO)) { + if (spotPosition.openBids.eq(ZERO) && spotPosition.openAsks.eq(ZERO) || !includeOpenOrders) { const { weight, weightedTokenValue } = calculateWeightedTokenValue( tokenAmount, tokenValue, diff --git a/sdk/src/types.ts b/sdk/src/types.ts index bb15805693..4d97d4c608 100644 --- a/sdk/src/types.ts +++ b/sdk/src/types.ts @@ -1140,6 +1140,12 @@ export type PerpPosition = { positionFlag: number; }; +export class PositionFlag { + static readonly IsolatedPosition = 1; + static readonly BeingLiquidated = 2; + static readonly Bankruptcy = 3; +} + export type UserStatsAccount = { numberOfSubAccounts: number; numberOfSubAccountsCreated: number; diff --git a/sdk/src/user.ts b/sdk/src/user.ts index 7d5d78e256..cdf2054f7f 100644 --- a/sdk/src/user.ts +++ b/sdk/src/user.ts @@ -68,6 +68,7 @@ import { getUser30dRollingVolumeEstimate } from './math/trade'; import { MarketType, PositionDirection, + PositionFlag, SpotBalanceType, SpotMarketAccount, } from './types'; @@ -106,6 +107,12 @@ import { StrictOraclePrice } from './oracles/strictOraclePrice'; import { calculateSpotFuelBonus, calculatePerpFuelBonus } from './math/fuel'; import { grpcUserAccountSubscriber } from './accounts/grpcUserAccountSubscriber'; +import { + MarginCalculation, + MarginContext, +} from './marginCalculation'; + +export type MarginType = 'Cross' | 'Isolated'; export class User { driftClient: DriftClient; @@ -343,6 +350,22 @@ export class User { }; } + public getIsolatePerpPositionTokenAmount(perpMarketIndex: number): BN { + const perpPosition = this.getPerpPosition(perpMarketIndex); + const perpMarket = this.driftClient.getPerpMarketAccount(perpMarketIndex); + const spotMarket = this.driftClient.getSpotMarketAccount( + perpMarket.quoteSpotMarketIndex + ); + if (perpPosition === undefined) { + return ZERO; + } + return getTokenAmount( + perpPosition.isolatedPositionScaledBalance, + spotMarket, + SpotBalanceType.DEPOSIT + ); + } + public getClonedPosition(position: PerpPosition): PerpPosition { const clonedPosition = Object.assign({}, position); return clonedPosition; @@ -515,62 +538,113 @@ export class User { */ public getFreeCollateral( marginCategory: MarginCategory = 'Initial', - enterHighLeverageMode = undefined + enterHighLeverageMode = false, + perpMarketIndex?: number ): BN { - const totalCollateral = this.getTotalCollateral(marginCategory, true); - const marginRequirement = - marginCategory === 'Initial' - ? this.getInitialMarginRequirement(enterHighLeverageMode) - : this.getMaintenanceMarginRequirement(); - const freeCollateral = totalCollateral.sub(marginRequirement); - return freeCollateral.gte(ZERO) ? freeCollateral : ZERO; + const marginCalc = this.getMarginCalculation(marginCategory, { + enteringHighLeverage: enterHighLeverageMode, + }); + + if (perpMarketIndex !== undefined) { + return marginCalc.getIsolatedFreeCollateral(perpMarketIndex); + } else { + return marginCalc.getCrossFreeCollateral(); + } } /** - * @returns The margin requirement of a certain type (Initial or Maintenance) in USDC. : QUOTE_PRECISION + * @deprecated Use the overload that includes { marginType, perpMarketIndex } */ public getMarginRequirement( marginCategory: MarginCategory, liquidationBuffer?: BN, - strict = false, - includeOpenOrders = true, - enteringHighLeverage = undefined + strict?: boolean, + includeOpenOrders?: boolean, + enteringHighLeverage?: boolean + ): BN; + + /** + * Calculates the margin requirement based on the specified parameters. + * + * @param marginCategory - The category of margin to calculate ('Initial' or 'Maintenance'). + * @param liquidationBuffer - Optional buffer amount to consider during liquidation scenarios. + * @param strict - Optional flag to enforce strict margin calculations. + * @param includeOpenOrders - Optional flag to include open orders in the margin calculation. + * @param enteringHighLeverage - Optional flag indicating if the user is entering high leverage mode. + * @param perpMarketIndex - Optional index of the perpetual market. Required if marginType is 'Isolated'. + * + * @returns The calculated margin requirement as a BN (BigNumber). + */ + public getMarginRequirement( + marginCategory: MarginCategory, + liquidationBuffer?: BN, + strict?: boolean, + includeOpenOrders?: boolean, + enteringHighLeverage?: boolean, + perpMarketIndex?: number + ): BN; + + public getMarginRequirement( + marginCategory: MarginCategory, + liquidationBuffer?: BN, + strict?: boolean, + includeOpenOrders?: boolean, + enteringHighLeverage?: boolean, + perpMarketIndex?: number ): BN { - return this.getTotalPerpPositionLiability( - marginCategory, - liquidationBuffer, - includeOpenOrders, + const marginCalc = this.getMarginCalculation(marginCategory, { strict, - enteringHighLeverage - ).add( - this.getSpotMarketLiabilityValue( - undefined, - marginCategory, - liquidationBuffer, - includeOpenOrders, - strict - ) - ); + includeOpenOrders, + enteringHighLeverage, + liquidationBuffer, + }); + + // If perpMarketIndex is provided, compute only for that market index + if (perpMarketIndex !== undefined) { + const isolatedMarginCalculation = + marginCalc.isolatedMarginCalculations.get(perpMarketIndex); + const { marginRequirement } = isolatedMarginCalculation; + + return marginRequirement; + } + + // Default: Cross margin requirement + // TODO: should we be using plus buffer sometimes? + return marginCalc.marginRequirement; } /** * @returns The initial margin requirement in USDC. : QUOTE_PRECISION */ - public getInitialMarginRequirement(enterHighLeverageMode = undefined): BN { + public getInitialMarginRequirement( + enterHighLeverageMode = false, + perpMarketIndex?: number + ): BN { return this.getMarginRequirement( 'Initial', undefined, - true, + false, undefined, - enterHighLeverageMode + enterHighLeverageMode, + perpMarketIndex ); } /** * @returns The maintenance margin requirement in USDC. : QUOTE_PRECISION */ - public getMaintenanceMarginRequirement(liquidationBuffer?: BN): BN { - return this.getMarginRequirement('Maintenance', liquidationBuffer); + public getMaintenanceMarginRequirement( + liquidationBuffer?: BN, + perpMarketIndex?: number + ): BN { + return this.getMarginRequirement( + 'Maintenance', + liquidationBuffer, + true, // strict default + true, // includeOpenOrders default + false, // enteringHighLeverage default + perpMarketIndex + ); } public getActivePerpPositionsForUserAccount( @@ -580,7 +654,8 @@ export class User { (pos) => !pos.baseAssetAmount.eq(ZERO) || !pos.quoteAssetAmount.eq(ZERO) || - !(pos.openOrders == 0) + !(pos.openOrders == 0) || + pos.isolatedPositionScaledBalance.gt(ZERO) ); } @@ -641,6 +716,7 @@ export class User { const market = this.driftClient.getPerpMarketAccount( perpPosition.marketIndex ); + if(!market) return unrealizedPnl; const oraclePriceData = this.getMMOracleDataForPerpMarket( market.marketIndex ); @@ -1153,22 +1229,21 @@ export class User { marginCategory: MarginCategory = 'Initial', strict = false, includeOpenOrders = true, - liquidationBuffer?: BN + liquidationBuffer?: BN, + perpMarketIndex?: number ): BN { - return this.getSpotMarketAssetValue( - undefined, - marginCategory, + const marginCalc = this.getMarginCalculation(marginCategory, { + strict, includeOpenOrders, - strict - ).add( - this.getUnrealizedPNL( - true, - undefined, - marginCategory, - strict, - liquidationBuffer - ) - ); + liquidationBuffer, + }); + + if (perpMarketIndex !== undefined) { + return marginCalc.isolatedMarginCalculations.get(perpMarketIndex) + .totalCollateral; + } + + return marginCalc.totalCollateral; } public getLiquidationBuffer(): BN | undefined { @@ -1186,13 +1261,27 @@ export class User { * calculates User Health by comparing total collateral and maint. margin requirement * @returns : number (value from [0, 100]) */ - public getHealth(): number { - if (this.isBeingLiquidated()) { + public getHealth(perpMarketIndex?: number): number { + const marginCalc = this.getMarginCalculation('Maintenance'); + if (this.isCrossMarginBeingLiquidated(marginCalc) && !perpMarketIndex) { return 0; } - const totalCollateral = this.getTotalCollateral('Maintenance'); - const maintenanceMarginReq = this.getMaintenanceMarginRequirement(); + + let totalCollateral: BN; + let maintenanceMarginReq: BN; + + if (perpMarketIndex) { + const isolatedMarginCalc = + marginCalc.isolatedMarginCalculations.get(perpMarketIndex); + if (isolatedMarginCalc) { + totalCollateral = isolatedMarginCalc.totalCollateral; + maintenanceMarginReq = isolatedMarginCalc.marginRequirement; + } + } else { + totalCollateral = marginCalc.totalCollateral; + maintenanceMarginReq = marginCalc.marginRequirement; + } let health: number; @@ -1228,6 +1317,8 @@ export class User { perpPosition.marketIndex ); + if(!market) return ZERO; + let valuationPrice = this.getOracleDataForPerpMarket( market.marketIndex ).price; @@ -1491,9 +1582,9 @@ export class User { * calculates current user leverage which is (total liability size) / (net asset value) * @returns : Precision TEN_THOUSAND */ - public getLeverage(includeOpenOrders = true): BN { + public getLeverage(includeOpenOrders = true, perpMarketIndex?: number): BN { return this.calculateLeverageFromComponents( - this.getLeverageComponents(includeOpenOrders) + this.getLeverageComponents(includeOpenOrders, undefined, perpMarketIndex) ); } @@ -1521,13 +1612,61 @@ export class User { getLeverageComponents( includeOpenOrders = true, - marginCategory: MarginCategory = undefined + marginCategory: MarginCategory = undefined, + perpMarketIndex?: number ): { perpLiabilityValue: BN; perpPnl: BN; spotAssetValue: BN; spotLiabilityValue: BN; } { + if (perpMarketIndex) { + const perpPosition = this.getPerpPositionOrEmpty(perpMarketIndex); + const perpLiability = this.calculateWeightedPerpPositionLiability( + perpPosition, + marginCategory, + undefined, + includeOpenOrders + ); + const perpMarket = this.driftClient.getPerpMarketAccount( + perpPosition.marketIndex + ); + + const oraclePriceData = this.getOracleDataForPerpMarket( + perpPosition.marketIndex + ); + const quoteSpotMarket = this.driftClient.getSpotMarketAccount( + perpMarket.quoteSpotMarketIndex + ); + const quoteOraclePriceData = this.getOracleDataForSpotMarket( + perpMarket.quoteSpotMarketIndex + ); + const strictOracle = new StrictOraclePrice( + quoteOraclePriceData.price, + quoteOraclePriceData.twap + ); + + const positionUnrealizedPnl = calculatePositionPNL( + perpMarket, + perpPosition, + true, + oraclePriceData + ); + + const spotAssetValue = getStrictTokenValue( + perpPosition.isolatedPositionScaledBalance, + quoteSpotMarket.decimals, + strictOracle + ); + + return { + perpLiabilityValue: perpLiability, + perpPnl: positionUnrealizedPnl, + spotAssetValue, + spotLiabilityValue: ZERO, + }; + } + const perpLiability = this.getTotalPerpPositionLiability( marginCategory, undefined, @@ -1821,35 +1960,87 @@ export class User { canBeLiquidated: boolean; marginRequirement: BN; totalCollateral: BN; + liquidationStatuses: Map<'cross' | number, { canBeLiquidated: boolean; marginRequirement: BN; totalCollateral: BN }>; } { - const liquidationBuffer = this.getLiquidationBuffer(); + // Deprecated signature retained for backward compatibility in type only + // but implementation now delegates to the new Map-based API and returns cross margin status. + const map = this.getLiquidationStatuses(); + const cross = map.get('cross'); + return cross ? { ...cross, liquidationStatuses: map } : { canBeLiquidated: false, marginRequirement: ZERO, totalCollateral: ZERO, liquidationStatuses: map }; + } - const totalCollateral = this.getTotalCollateral( - 'Maintenance', - undefined, - undefined, - liquidationBuffer - ); + /** + * New API: Returns liquidation status for cross and each isolated perp position. + * Map keys: + * - 'cross' for cross margin + * - marketIndex (number) for each isolated perp position + */ + public getLiquidationStatuses(marginCalc?: MarginCalculation): Map<'cross' | number, { canBeLiquidated: boolean; marginRequirement: BN; totalCollateral: BN }> { + // If not provided, use buffer-aware calc for canBeLiquidated checks + if (!marginCalc) { + const liquidationBuffer = this.getLiquidationBuffer(); + marginCalc = this.getMarginCalculation('Maintenance', { liquidationBuffer }); + } - const marginRequirement = - this.getMaintenanceMarginRequirement(liquidationBuffer); - const canBeLiquidated = totalCollateral.lt(marginRequirement); + const result = new Map<'cross' | number, { + canBeLiquidated: boolean; + marginRequirement: BN; + totalCollateral: BN; + }>(); + + // Cross margin status + const crossTotalCollateral = marginCalc.totalCollateral; + const crossMarginRequirement = marginCalc.marginRequirement; + result.set('cross', { + canBeLiquidated: crossTotalCollateral.lt(crossMarginRequirement), + marginRequirement: crossMarginRequirement, + totalCollateral: crossTotalCollateral, + }); - return { - canBeLiquidated, - marginRequirement, - totalCollateral, - }; + // Isolated positions status + for (const [marketIndex, isoCalc] of marginCalc.isolatedMarginCalculations) { + const isoTotalCollateral = isoCalc.totalCollateral; + const isoMarginRequirement = isoCalc.marginRequirement; + result.set(marketIndex, { + canBeLiquidated: isoTotalCollateral.lt(isoMarginRequirement), + marginRequirement: isoMarginRequirement, + totalCollateral: isoTotalCollateral, + }); + } + + return result; } - public isBeingLiquidated(): boolean { - return ( + public isBeingLiquidated(marginCalc?: MarginCalculation): boolean { + // Consider on-chain flags OR computed margin status (cross or any isolated) + const hasOnChainFlag = (this.getUserAccount().status & - (UserStatus.BEING_LIQUIDATED | UserStatus.BANKRUPT)) > - 0 + (UserStatus.BEING_LIQUIDATED | UserStatus.BANKRUPT)) > 0; + const calc = marginCalc ?? this.getMarginCalculation('Maintenance'); + return ( + hasOnChainFlag || + this.isCrossMarginBeingLiquidated(calc) || + this.isIsolatedMarginBeingLiquidated(calc) ); } + /** Returns true if cross margin is currently below maintenance requirement (no buffer). */ + public isCrossMarginBeingLiquidated(marginCalc?: MarginCalculation): boolean { + const calc = marginCalc ?? this.getMarginCalculation('Maintenance'); + return calc.totalCollateral.lt(calc.marginRequirement); + } + + /** Returns true if any isolated perp position is currently below its maintenance requirement (no buffer). */ + public isIsolatedMarginBeingLiquidated(marginCalc?: MarginCalculation): boolean { + const calc = marginCalc ?? this.getMarginCalculation('Maintenance'); + for (const [, isoCalc] of calc.isolatedMarginCalculations) { + if (isoCalc.totalCollateral.lt(isoCalc.marginRequirement)) { + return true; + } + } + return false; + } + public hasStatus(status: UserStatus): boolean { return (this.getUserAccount().status & status) > 0; } @@ -2006,8 +2197,61 @@ export class User { marginCategory: MarginCategory = 'Maintenance', includeOpenOrders = false, offsetCollateral = ZERO, - enteringHighLeverage = undefined + enteringHighLeverage = false, + marginType?: MarginType ): BN { + const market = this.driftClient.getPerpMarketAccount(marketIndex); + + const oracle = + this.driftClient.getPerpMarketAccount(marketIndex).amm.oracle; + + const oraclePrice = + this.driftClient.getOracleDataForPerpMarket(marketIndex).price; + + const currentPerpPosition = this.getPerpPositionOrEmpty(marketIndex); + + if (marginType === 'Isolated') { + const marginCalculation = this.getMarginCalculation(marginCategory, { + strict: false, + includeOpenOrders, + enteringHighLeverage, + }); + const isolatedMarginCalculation = + marginCalculation.isolatedMarginCalculations.get(marketIndex); + const { totalCollateral, marginRequirement } = isolatedMarginCalculation; + + const freeCollateral = BN.max( + ZERO, + totalCollateral.sub(marginRequirement) + ).add(offsetCollateral); + + const freeCollateralDelta = this.calculateFreeCollateralDeltaForPerp( + market, + currentPerpPosition, + positionBaseSizeChange, + oraclePrice, + marginCategory, + includeOpenOrders, + enteringHighLeverage + ); + + if (freeCollateralDelta.eq(ZERO)) { + return new BN(-1); + } + + const liqPriceDelta = freeCollateral + .mul(QUOTE_PRECISION) + .div(freeCollateralDelta); + + const liqPrice = oraclePrice.sub(liqPriceDelta); + + if (liqPrice.lt(ZERO)) { + return new BN(-1); + } + + return liqPrice; + } + const totalCollateral = this.getTotalCollateral( marginCategory, false, @@ -2025,15 +2269,6 @@ export class User { totalCollateral.sub(marginRequirement) ).add(offsetCollateral); - const oracle = - this.driftClient.getPerpMarketAccount(marketIndex).amm.oracle; - - const oraclePrice = - this.driftClient.getOracleDataForPerpMarket(marketIndex).price; - - const market = this.driftClient.getPerpMarketAccount(marketIndex); - const currentPerpPosition = this.getPerpPositionOrEmpty(marketIndex); - positionBaseSizeChange = standardizeBaseAssetAmount( positionBaseSizeChange, market.amm.orderStepSize @@ -3915,4 +4150,282 @@ export class User { activeSpotPositions: activeSpotMarkets, }; } + + /** + * Compute a consolidated margin snapshot once, without caching. + * Consumers can use this to avoid duplicating work across separate calls. + */ + + public getMarginCalculation( + marginCategory: MarginCategory = 'Initial', + opts?: { + strict?: boolean; // mirror StrictOraclePrice application + includeOpenOrders?: boolean; + enteringHighLeverage?: boolean; + liquidationBuffer?: BN; // margin_buffer analog for buffer mode + marginRatioOverride?: number; // mirrors context.margin_ratio_override + } + ): MarginCalculation { + const strict = opts?.strict ?? false; + const enteringHighLeverage = opts?.enteringHighLeverage ?? false; + const includeOpenOrders = opts?.includeOpenOrders ?? true; // TODO: remove this ?? + const marginBuffer = opts?.liquidationBuffer; // treat as MARGIN_BUFFER ratio if provided + const marginRatioOverride = opts?.marginRatioOverride; + + // Equivalent to on-chain user_custom_margin_ratio + let userCustomMarginRatio = + marginCategory === 'Initial' ? this.getUserAccount().maxMarginRatio : 0; + if (marginRatioOverride !== undefined) { + userCustomMarginRatio = Math.max( + userCustomMarginRatio, + marginRatioOverride + ); + } + + // Initialize calc via JS mirror of Rust MarginCalculation + const ctx = MarginContext.standard(marginCategory) + .strictMode(strict) + .setMarginBuffer(marginBuffer) + .setMarginRatioOverride(userCustomMarginRatio); + const calc = new MarginCalculation(ctx); + + // SPOT POSITIONS + // TODO: include open orders in the worst-case simulation in the same way on both spot and perp positions + for (const spotPosition of this.getUserAccount().spotPositions) { + if (isSpotPositionAvailable(spotPosition)) continue; + + const spotMarket = this.driftClient.getSpotMarketAccount( + spotPosition.marketIndex + ); + const oraclePriceData = this.getOracleDataForSpotMarket( + spotPosition.marketIndex + ); + const twap5 = strict + ? calculateLiveOracleTwap( + spotMarket.historicalOracleData, + oraclePriceData, + new BN(Math.floor(Date.now() / 1000)), + FIVE_MINUTE + ) + : undefined; + const strictOracle = new StrictOraclePrice(oraclePriceData.price, twap5); + + if (spotPosition.marketIndex === QUOTE_SPOT_MARKET_INDEX) { + const tokenAmount = getSignedTokenAmount( + getTokenAmount( + spotPosition.scaledBalance, + spotMarket, + spotPosition.balanceType + ), + spotPosition.balanceType + ); + if (isVariant(spotPosition.balanceType, 'deposit')) { + // add deposit value to total collateral + const tokenValue = getStrictTokenValue( + tokenAmount, + spotMarket.decimals, + strictOracle + ); + calc.addCrossMarginTotalCollateral(tokenValue); + } else { + // borrow on quote contributes to margin requirement + const tokenValueAbs = getStrictTokenValue( + tokenAmount, + spotMarket.decimals, + strictOracle + ).abs(); + calc.addCrossMarginRequirement(tokenValueAbs, tokenValueAbs); + calc.addSpotLiability(); + } + continue; + } + + // Non-quote spot: worst-case simulation + const { + tokenAmount: worstCaseTokenAmount, + ordersValue: worstCaseOrdersValue, + tokenValue: worstCaseTokenValue, + weightedTokenValue: worstCaseWeightedTokenValue, + } = getWorstCaseTokenAmounts( + spotPosition, + spotMarket, + strictOracle, + marginCategory, + userCustomMarginRatio, + includeOpenOrders + ); + + // open order IM + calc.addCrossMarginRequirement( + new BN(spotPosition.openOrders).mul(OPEN_ORDER_MARGIN_REQUIREMENT), + ZERO + ); + + if (worstCaseTokenAmount.gt(ZERO)) { + // asset side increases total collateral (weighted) + calc.addCrossMarginTotalCollateral(worstCaseWeightedTokenValue); + } else if (worstCaseTokenAmount.lt(ZERO)) { + // liability side increases margin requirement (weighted >= abs(token_value)) + const liabilityWeighted = worstCaseWeightedTokenValue.abs(); + calc.addCrossMarginRequirement( + liabilityWeighted, + worstCaseTokenValue.abs() + ); + calc.addSpotLiability(); + calc.addSpotLiabilityValue(worstCaseTokenValue.abs()); + } else if (spotPosition.openOrders !== 0) { + calc.addSpotLiability(); + calc.addSpotLiabilityValue(worstCaseTokenValue.abs()); + } + + // orders value contributes to collateral or requirement + if (worstCaseOrdersValue.gt(ZERO)) { + calc.addCrossMarginTotalCollateral(worstCaseOrdersValue); + } else if (worstCaseOrdersValue.lt(ZERO)) { + const absVal = worstCaseOrdersValue.abs(); + calc.addCrossMarginRequirement(absVal, absVal); + } + } + + // PERP POSITIONS + for (const marketPosition of this.getActivePerpPositions()) { + const market = this.driftClient.getPerpMarketAccount( + marketPosition.marketIndex + ); + const quoteSpotMarket = this.driftClient.getSpotMarketAccount( + market.quoteSpotMarketIndex + ); + const quoteOraclePriceData = this.getOracleDataForSpotMarket( + market.quoteSpotMarketIndex + ); + const oraclePriceData = this.getOracleDataForPerpMarket( + market.marketIndex + ); + + // Worst-case perp liability and weighted pnl + const { worstCaseBaseAssetAmount, worstCaseLiabilityValue } = + calculateWorstCasePerpLiabilityValue( + marketPosition, + market, + oraclePriceData.price, + ); + + // margin ratio for this perp + const customMarginRatio = Math.max(this.getUserAccount().maxMarginRatio, marketPosition.maxMarginRatio); + let marginRatio = new BN( + calculateMarketMarginRatio( + market, + worstCaseBaseAssetAmount.abs(), + marginCategory, + customMarginRatio, + this.isHighLeverageMode(marginCategory) || enteringHighLeverage + ) + ); + if (isVariant(market.status, 'settlement')) { + marginRatio = ZERO; + } + + // convert liability to quote value and apply margin ratio + const quotePrice = strict + ? BN.max( + quoteOraclePriceData.price, + quoteSpotMarket.historicalOracleData.lastOraclePriceTwap5Min + ) + : quoteOraclePriceData.price; + let perpMarginRequirement = worstCaseLiabilityValue + .mul(quotePrice) + .div(PRICE_PRECISION) + .mul(marginRatio) + .div(MARGIN_PRECISION); + // add open orders IM + perpMarginRequirement = perpMarginRequirement.add( + new BN(marketPosition.openOrders).mul(OPEN_ORDER_MARGIN_REQUIREMENT) + ); + + // weighted unrealized pnl + let positionUnrealizedPnl = calculatePositionPNL( + market, + marketPosition, + true, + oraclePriceData + ); + let pnlQuotePrice: BN; + if (strict && positionUnrealizedPnl.gt(ZERO)) { + pnlQuotePrice = BN.min( + quoteOraclePriceData.price, + quoteSpotMarket.historicalOracleData.lastOraclePriceTwap5Min + ); + } else if (strict && positionUnrealizedPnl.lt(ZERO)) { + pnlQuotePrice = BN.max( + quoteOraclePriceData.price, + quoteSpotMarket.historicalOracleData.lastOraclePriceTwap5Min + ); + } else { + pnlQuotePrice = quoteOraclePriceData.price; + } + positionUnrealizedPnl = positionUnrealizedPnl + .mul(pnlQuotePrice) + .div(PRICE_PRECISION); + + // Add perp contribution: isolated vs cross + const isIsolated = this.isPerpPositionIsolated(marketPosition); + if (isIsolated) { + // derive isolated quote deposit value, mirroring on-chain logic + let depositValue = ZERO; + if (marketPosition.isolatedPositionScaledBalance.gt(ZERO)) { + const quoteSpotMarket = this.driftClient.getSpotMarketAccount( + market.quoteSpotMarketIndex + ); + const quoteOraclePriceData = this.getOracleDataForSpotMarket( + market.quoteSpotMarketIndex + ); + const strictQuote = new StrictOraclePrice( + quoteOraclePriceData.price, + strict + ? quoteSpotMarket.historicalOracleData.lastOraclePriceTwap5Min + : undefined + ); + const quoteTokenAmount = getTokenAmount( + marketPosition.isolatedPositionScaledBalance, + quoteSpotMarket, + SpotBalanceType.DEPOSIT + ); + depositValue = getStrictTokenValue( + quoteTokenAmount, + quoteSpotMarket.decimals, + strictQuote + ); + } + calc.addIsolatedMarginCalculation( + market.marketIndex, + depositValue, + positionUnrealizedPnl, + worstCaseLiabilityValue, + perpMarginRequirement + ); + calc.addPerpLiability(); + calc.addPerpLiabilityValue(worstCaseLiabilityValue); + } else { + // cross: add to global requirement and collateral + calc.addCrossMarginRequirement( + perpMarginRequirement, + worstCaseLiabilityValue + ); + calc.addCrossMarginTotalCollateral(positionUnrealizedPnl); + const hasPerpLiability = + !marketPosition.baseAssetAmount.eq(ZERO) || + marketPosition.quoteAssetAmount.lt(ZERO) || + marketPosition.openOrders !== 0; + if (hasPerpLiability) { + calc.addPerpLiability(); + } + } + } + + return calc; + } + + private isPerpPositionIsolated(perpPosition: PerpPosition): boolean { + return (perpPosition.positionFlag & PositionFlag.IsolatedPosition) !== 0; + } } From a1a358e3eeab25d1f6e02fd6f9c92b9f74eef156 Mon Sep 17 00:00:00 2001 From: Lukas deConantsesznak Date: Wed, 29 Oct 2025 15:57:11 -0600 Subject: [PATCH 02/13] fix: hadnle iso balance undefined --- sdk/src/user.ts | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/sdk/src/user.ts b/sdk/src/user.ts index cdf2054f7f..4111dda35e 100644 --- a/sdk/src/user.ts +++ b/sdk/src/user.ts @@ -360,7 +360,7 @@ export class User { return ZERO; } return getTokenAmount( - perpPosition.isolatedPositionScaledBalance, + perpPosition.isolatedPositionScaledBalance ?? ZERO, //TODO remove ? later spotMarket, SpotBalanceType.DEPOSIT ); @@ -655,7 +655,7 @@ export class User { !pos.baseAssetAmount.eq(ZERO) || !pos.quoteAssetAmount.eq(ZERO) || !(pos.openOrders == 0) || - pos.isolatedPositionScaledBalance.gt(ZERO) + pos.isolatedPositionScaledBalance?.gt(ZERO) ); } @@ -1654,7 +1654,7 @@ export class User { ); const spotAssetValue = getStrictTokenValue( - perpPosition.isolatedPositionScaledBalance, + perpPosition.isolatedPositionScaledBalance ?? ZERO, //TODO remove ? later quoteSpotMarket.decimals, strictOracle ); @@ -4372,7 +4372,8 @@ export class User { if (isIsolated) { // derive isolated quote deposit value, mirroring on-chain logic let depositValue = ZERO; - if (marketPosition.isolatedPositionScaledBalance.gt(ZERO)) { + // TODO this field(isolatedPositionScaledBalance) should not be undefined in the future, remove ? later + if (marketPosition.isolatedPositionScaledBalance?.gt(ZERO)) { const quoteSpotMarket = this.driftClient.getSpotMarketAccount( market.quoteSpotMarketIndex ); @@ -4386,7 +4387,7 @@ export class User { : undefined ); const quoteTokenAmount = getTokenAmount( - marketPosition.isolatedPositionScaledBalance, + marketPosition.isolatedPositionScaledBalance ?? ZERO, //TODO remove ? later quoteSpotMarket, SpotBalanceType.DEPOSIT ); From 0a373ea16c20371f1c005410d3bd567df60cc7b4 Mon Sep 17 00:00:00 2001 From: Lukas deConantsesznak Date: Wed, 29 Oct 2025 22:50:28 -0600 Subject: [PATCH 03/13] feat: margin calc tests --- sdk/tests/dlob/helpers.ts | 19 ++ sdk/tests/user/getMarginCalculation.ts | 390 +++++++++++++++++++++++++ 2 files changed, 409 insertions(+) create mode 100644 sdk/tests/user/getMarginCalculation.ts diff --git a/sdk/tests/dlob/helpers.ts b/sdk/tests/dlob/helpers.ts index 287c77d725..43af8e4860 100644 --- a/sdk/tests/dlob/helpers.ts +++ b/sdk/tests/dlob/helpers.ts @@ -45,6 +45,8 @@ export const mockPerpPosition: PerpPosition = { lastQuoteAssetAmountPerLp: new BN(0), perLpBase: 0, maxMarginRatio: 1, + isolatedPositionScaledBalance: new BN(0), + positionFlag: 0, }; export const mockAMM: AMM = { @@ -152,6 +154,8 @@ export const mockAMM: AMM = { mmOraclePrice: new BN(0), mmOracleSlot: new BN(0), lastFundingOracleTwap: new BN(0), + oracleSlotDelayOverride: 0, + referencePriceOffsetDeadbandPct: 0, }; export const mockPerpMarkets: Array = [ @@ -201,6 +205,11 @@ export const mockPerpMarkets: Array = [ fuelBoostTaker: 0, protectedMakerLimitPriceDivisor: 0, protectedMakerDynamicDivisor: 0, + lpPoolId: 0, + lpFeeTransferScalar: 0, + lpExchangeFeeExcluscionScalar: 0, + lpStatus: 0, + lpPausedOperations: 0, }, { status: MarketStatus.INITIALIZED, @@ -248,6 +257,11 @@ export const mockPerpMarkets: Array = [ fuelBoostTaker: 0, protectedMakerLimitPriceDivisor: 0, protectedMakerDynamicDivisor: 0, + lpPoolId: 0, + lpFeeTransferScalar: 0, + lpExchangeFeeExcluscionScalar: 0, + lpStatus: 0, + lpPausedOperations: 0, }, { status: MarketStatus.INITIALIZED, @@ -295,6 +309,11 @@ export const mockPerpMarkets: Array = [ fuelBoostTaker: 0, protectedMakerLimitPriceDivisor: 0, protectedMakerDynamicDivisor: 0, + lpPoolId: 0, + lpFeeTransferScalar: 0, + lpExchangeFeeExcluscionScalar: 0, + lpStatus: 0, + lpPausedOperations: 0, }, ]; diff --git a/sdk/tests/user/getMarginCalculation.ts b/sdk/tests/user/getMarginCalculation.ts new file mode 100644 index 0000000000..6074ea16f6 --- /dev/null +++ b/sdk/tests/user/getMarginCalculation.ts @@ -0,0 +1,390 @@ +import { + BN, + ZERO, + User, + UserAccount, + PublicKey, + PerpMarketAccount, + SpotMarketAccount, + PRICE_PRECISION, + OraclePriceData, + BASE_PRECISION, + QUOTE_PRECISION, + SPOT_MARKET_BALANCE_PRECISION, + SpotBalanceType, + MARGIN_PRECISION, + OPEN_ORDER_MARGIN_REQUIREMENT, + SPOT_MARKET_WEIGHT_PRECISION, + PositionFlag, +} from '../../src'; +import { MockUserMap, mockPerpMarkets, mockSpotMarkets } from '../dlob/helpers'; +import { assert } from '../../src/assert/assert'; +import { mockUserAccount as baseMockUserAccount } from './helpers'; +import * as _ from 'lodash'; + +async function makeMockUser( + myMockPerpMarkets: Array, + myMockSpotMarkets: Array, + myMockUserAccount: UserAccount, + perpOraclePriceList: number[], + spotOraclePriceList: number[] +): Promise { + const umap = new MockUserMap(); + const mockUser: User = await umap.mustGet('1'); + mockUser._isSubscribed = true; + mockUser.driftClient._isSubscribed = true; + mockUser.driftClient.accountSubscriber.isSubscribed = true; + + const oraclePriceMap: Record = {}; + for (let i = 0; i < myMockPerpMarkets.length; i++) { + oraclePriceMap[myMockPerpMarkets[i].amm.oracle.toString()] = + perpOraclePriceList[i] ?? 1; + } + for (let i = 0; i < myMockSpotMarkets.length; i++) { + oraclePriceMap[myMockSpotMarkets[i].oracle.toString()] = + spotOraclePriceList[i] ?? 1; + } + + function getMockUserAccount(): UserAccount { + return myMockUserAccount; + } + function getMockPerpMarket(marketIndex: number): PerpMarketAccount { + return myMockPerpMarkets[marketIndex]; + } + function getMockSpotMarket(marketIndex: number): SpotMarketAccount { + return myMockSpotMarkets[marketIndex]; + } + function getMockOracle(oracleKey: PublicKey) { + const data: OraclePriceData = { + price: new BN( + (oraclePriceMap[oracleKey.toString()] ?? 1) * + PRICE_PRECISION.toNumber() + ), + slot: new BN(0), + confidence: new BN(1), + hasSufficientNumberOfDataPoints: true, + }; + return { data, slot: 0 }; + } + function getOracleDataForPerpMarket(marketIndex: number) { + const oracle = getMockPerpMarket(marketIndex).amm.oracle; + return getMockOracle(oracle).data; + } + function getOracleDataForSpotMarket(marketIndex: number) { + const oracle = getMockSpotMarket(marketIndex).oracle; + return getMockOracle(oracle).data; + } + + mockUser.getUserAccount = getMockUserAccount; + mockUser.driftClient.getPerpMarketAccount = getMockPerpMarket as any; + mockUser.driftClient.getSpotMarketAccount = getMockSpotMarket as any; + mockUser.driftClient.getOraclePriceDataAndSlot = getMockOracle as any; + mockUser.driftClient.getOracleDataForPerpMarket = getOracleDataForPerpMarket as any; + mockUser.driftClient.getOracleDataForSpotMarket = getOracleDataForSpotMarket as any; + return mockUser; +} + +describe('getMarginCalculation snapshot', () => { + it('empty account returns zeroed snapshot', async () => { + const myMockPerpMarkets = _.cloneDeep(mockPerpMarkets); + const myMockSpotMarkets = _.cloneDeep(mockSpotMarkets); + const myMockUserAccount = _.cloneDeep(baseMockUserAccount); + + const user: User = await makeMockUser( + myMockPerpMarkets, + myMockSpotMarkets, + myMockUserAccount, + [1, 1, 1, 1, 1, 1, 1, 1], + [1, 1, 1, 1, 1, 1, 1, 1] + ); + + const calc = user.getMarginCalculation('Initial'); + assert(calc.totalCollateral.eq(ZERO)); + assert(calc.marginRequirement.eq(ZERO)); + assert(calc.numSpotLiabilities === 0); + assert(calc.numPerpLiabilities === 0); + }); + + it('quote deposit increases totalCollateral, no requirement', async () => { + const myMockPerpMarkets = _.cloneDeep(mockPerpMarkets); + const myMockSpotMarkets = _.cloneDeep(mockSpotMarkets); + const myMockUserAccount = _.cloneDeep(baseMockUserAccount); + + myMockUserAccount.spotPositions[0].balanceType = SpotBalanceType.DEPOSIT; + myMockUserAccount.spotPositions[0].scaledBalance = new BN( + 10000 * SPOT_MARKET_BALANCE_PRECISION.toNumber() + ); + + const user: User = await makeMockUser( + myMockPerpMarkets, + myMockSpotMarkets, + myMockUserAccount, + [1, 1, 1, 1, 1, 1, 1, 1], + [1, 1, 1, 1, 1, 1, 1, 1] + ); + + const calc = user.getMarginCalculation('Initial'); + const expected = new BN('10000000000'); // $10k + assert(calc.totalCollateral.eq(expected)); + assert(calc.marginRequirement.eq(ZERO)); + }); + + it('quote borrow increases requirement and buffer applies', async () => { + const myMockPerpMarkets = _.cloneDeep(mockPerpMarkets); + const myMockSpotMarkets = _.cloneDeep(mockSpotMarkets); + const myMockUserAccount = _.cloneDeep(baseMockUserAccount); + + // Borrow 100 quote + myMockUserAccount.spotPositions[0].balanceType = SpotBalanceType.BORROW; + myMockUserAccount.spotPositions[0].scaledBalance = new BN( + 100 * SPOT_MARKET_BALANCE_PRECISION.toNumber() + ); + + const user: User = await makeMockUser( + myMockPerpMarkets, + myMockSpotMarkets, + myMockUserAccount, + [1, 1, 1, 1, 1, 1, 1, 1], + [1, 1, 1, 1, 1, 1, 1, 1] + ); + + const tenPercent = new BN(1000); + const calc = user.getMarginCalculation('Initial', { + liquidationBuffer: tenPercent, + }); + const liability = new BN(100).mul(QUOTE_PRECISION); // $100 + assert(calc.totalCollateral.eq(ZERO)); + assert(calc.marginRequirement.eq(liability)); + assert( + calc.marginRequirementPlusBuffer.eq( + liability.div(new BN(10)).add(calc.marginRequirement) // 10% of liability + margin requirement + ) + ); + assert(calc.numSpotLiabilities === 1); + }); + + it('non-quote spot open orders add IM', async () => { + const myMockPerpMarkets = _.cloneDeep(mockPerpMarkets); + const myMockSpotMarkets = _.cloneDeep(mockSpotMarkets); + const myMockUserAccount = _.cloneDeep(baseMockUserAccount); + + // Market 1 (e.g., SOL) with 2 open orders + myMockUserAccount.spotPositions[1].marketIndex = 1; + myMockUserAccount.spotPositions[1].openOrders = 2; + + const user: User = await makeMockUser( + myMockPerpMarkets, + myMockSpotMarkets, + myMockUserAccount, + [1, 1, 1, 1, 1, 1, 1, 1], + [1, 1, 1, 1, 1, 1, 1, 1] + ); + + const calc = user.getMarginCalculation('Initial'); + const expectedIM = new BN(2).mul(OPEN_ORDER_MARGIN_REQUIREMENT); + assert(calc.marginRequirement.eq(expectedIM)); + }); + + it('perp long liability reflects maintenance requirement', async () => { + const myMockPerpMarkets = _.cloneDeep(mockPerpMarkets); + const myMockSpotMarkets = _.cloneDeep(mockSpotMarkets); + const myMockUserAccount = _.cloneDeep(baseMockUserAccount); + + // 20 base long, -$10 quote (liability) + myMockUserAccount.perpPositions[0].baseAssetAmount = new BN(20).mul( + BASE_PRECISION + ); + myMockUserAccount.perpPositions[0].quoteAssetAmount = new BN(-10).mul( + QUOTE_PRECISION + ); + + const user: User = await makeMockUser( + myMockPerpMarkets, + myMockSpotMarkets, + myMockUserAccount, + [1, 1, 1, 1, 1, 1, 1, 1], + [1, 1, 1, 1, 1, 1, 1, 1] + ); + + const calc = user.getMarginCalculation('Maintenance'); + // From existing liquidation test expectations: 2_000_000 + assert(calc.marginRequirement.eq(new BN('2000000'))); + }); + + it.only('maker position reducing: collateral equals maintenance requirement', async () => { + const myMockPerpMarkets = _.cloneDeep(mockPerpMarkets); + const myMockSpotMarkets = _.cloneDeep(mockSpotMarkets); + const myMockUserAccount = _.cloneDeep(baseMockUserAccount); + + myMockUserAccount.perpPositions[0].baseAssetAmount = new BN(200000000).mul( + BASE_PRECISION + ); + myMockUserAccount.perpPositions[0].quoteAssetAmount = new BN(-180000000).mul( + QUOTE_PRECISION + ); + + const user: User = await makeMockUser( + myMockPerpMarkets, + myMockSpotMarkets, + myMockUserAccount, + [1, 1, 1, 1, 1, 1, 1, 1], + [1, 1, 1, 1, 1, 1, 1, 1] + ); + + const calc = user.getMarginCalculation('Maintenance'); + assert(calc.marginRequirement.eq(calc.totalCollateral)); + }); + + it('maker reducing after simulated fill: collateral equals maintenance requirement', async () => { + const myMockPerpMarkets = _.cloneDeep(mockPerpMarkets); + const myMockSpotMarkets = _.cloneDeep(mockSpotMarkets); + + // Build maker and taker accounts + const makerAccount = _.cloneDeep(baseMockUserAccount); + const takerAccount = _.cloneDeep(baseMockUserAccount); + + // Oracle price = 1 for perp and spot + const perpOracles = [1, 1, 1, 1, 1, 1, 1, 1]; + const spotOracles = [1, 1, 1, 1, 1, 1, 1, 1]; + + // Pre-fill: maker has 21 base long at entry 1 ($21 notional), taker flat + makerAccount.perpPositions[0].baseAssetAmount = new BN(21).mul(BASE_PRECISION); + makerAccount.perpPositions[0].quoteEntryAmount = new BN(-21).mul(QUOTE_PRECISION); + makerAccount.perpPositions[0].quoteBreakEvenAmount = new BN(-21).mul(QUOTE_PRECISION); + // Provide exactly $2 in quote collateral to equal 10% maintenance of 20 notional post-fill + makerAccount.spotPositions[0].balanceType = SpotBalanceType.DEPOSIT; + makerAccount.spotPositions[0].scaledBalance = new BN(2).mul( + SPOT_MARKET_BALANCE_PRECISION + ); + + // Simulate fill: maker sells 1 base to taker at price = oracle = 1 + // Post-fill maker position: 20 base long with zero unrealized PnL + const maker: User = await makeMockUser( + myMockPerpMarkets, + myMockSpotMarkets, + makerAccount, + perpOracles, + spotOracles + ); + const taker: User = await makeMockUser( + myMockPerpMarkets, + myMockSpotMarkets, + takerAccount, + perpOracles, + spotOracles + ); + + // Apply synthetic trade deltas to both user accounts + // Maker: base 21 -> 20; taker: base 0 -> 1. Use quote deltas consistent with price 1, fee 0 + maker.getUserAccount().perpPositions[0].baseAssetAmount = new BN(20).mul( + BASE_PRECISION + ); + maker.getUserAccount().perpPositions[0].quoteEntryAmount = new BN(-20).mul( + QUOTE_PRECISION + ); + maker.getUserAccount().perpPositions[0].quoteBreakEvenAmount = new BN(-20).mul( + QUOTE_PRECISION + ); + // Align quoteAssetAmount with base value so unrealized PnL = 0 at price 1 + maker.getUserAccount().perpPositions[0].quoteAssetAmount = new BN(-20).mul( + QUOTE_PRECISION + ); + + taker.getUserAccount().perpPositions[0].baseAssetAmount = new BN(1).mul( + BASE_PRECISION + ); + taker.getUserAccount().perpPositions[0].quoteEntryAmount = new BN(-1).mul( + QUOTE_PRECISION + ); + taker.getUserAccount().perpPositions[0].quoteBreakEvenAmount = new BN(-1).mul( + QUOTE_PRECISION + ); + // Also set taker's quoteAssetAmount consistently + taker.getUserAccount().perpPositions[0].quoteAssetAmount = new BN(-1).mul( + QUOTE_PRECISION + ); + + const makerCalc = maker.getMarginCalculation('Maintenance'); + assert(makerCalc.marginRequirement.eq(makerCalc.totalCollateral)); + assert(makerCalc.marginRequirement.gt(ZERO)); + }); + + it('isolated position margin requirement (SDK parity)', async () => { + const myMockPerpMarkets = _.cloneDeep(mockPerpMarkets); + const myMockSpotMarkets = _.cloneDeep(mockSpotMarkets); + myMockSpotMarkets[0].oracle = new PublicKey(2); + myMockSpotMarkets[1].oracle = new PublicKey(5); + myMockPerpMarkets[0].amm.oracle = new PublicKey(5); + + // Configure perp market 0 ratios to match on-chain test + myMockPerpMarkets[0].marginRatioInitial = 1000; // 10% + myMockPerpMarkets[0].marginRatioMaintenance = 500; // 5% + + // Configure spot market 1 (e.g., SOL) weights to match on-chain test + myMockSpotMarkets[1].initialAssetWeight = + (SPOT_MARKET_WEIGHT_PRECISION.toNumber() * 8) / 10; // 0.8 + myMockSpotMarkets[1].maintenanceAssetWeight = + (SPOT_MARKET_WEIGHT_PRECISION.toNumber() * 9) / 10; // 0.9 + myMockSpotMarkets[1].initialLiabilityWeight = + (SPOT_MARKET_WEIGHT_PRECISION.toNumber() * 12) / 10; // 1.2 + myMockSpotMarkets[1].maintenanceLiabilityWeight = + (SPOT_MARKET_WEIGHT_PRECISION.toNumber() * 11) / 10; // 1.1 + + // ---------- Cross margin only (spot positions) ---------- + const crossAccount = _.cloneDeep(baseMockUserAccount); + // USDC deposit: $20,000 + crossAccount.spotPositions[0].marketIndex = 0; + crossAccount.spotPositions[0].balanceType = SpotBalanceType.DEPOSIT; + crossAccount.spotPositions[0].scaledBalance = new BN(20000).mul( + SPOT_MARKET_BALANCE_PRECISION + ); + // SOL borrow: 100 units + crossAccount.spotPositions[1].marketIndex = 1; + crossAccount.spotPositions[1].balanceType = SpotBalanceType.BORROW; + crossAccount.spotPositions[1].scaledBalance = new BN(100).mul( + SPOT_MARKET_BALANCE_PRECISION + ); + // No perp exposure in cross calc + crossAccount.perpPositions[0].baseAssetAmount = new BN(100 * BASE_PRECISION.toNumber()); + crossAccount.perpPositions[0].quoteAssetAmount = new BN(-11000 * QUOTE_PRECISION.toNumber()); + crossAccount.perpPositions[0].positionFlag = PositionFlag.IsolatedPosition; + crossAccount.perpPositions[0].isolatedPositionScaledBalance = new BN(100).mul( + SPOT_MARKET_BALANCE_PRECISION + ); + + const userCross: User = await makeMockUser( + myMockPerpMarkets, + myMockSpotMarkets, + crossAccount, + [100, 1, 1, 1, 1, 1, 1, 1], // perp oracle for market 0 = 100 + [1, 100, 1, 1, 1, 1, 1, 1] // spot oracle: usdc=1, sol=100 + ); + + const crossCalc = userCross.getMarginCalculation('Initial'); + const isolatedMarginCalc = crossCalc.isolatedMarginCalculations.get(0); + // Expect: cross MR from SOL borrow: 100 * $100 = $10,000 * 1.2 = $12,000 + assert(crossCalc.marginRequirement.eq(new BN('12000000000'))); + // Expect: cross total collateral from USDC deposit only = $20,000 + assert(crossCalc.totalCollateral.eq(new BN('20000000000'))); + // Meets cross margin requirement + assert(crossCalc.marginRequirement.lte(crossCalc.totalCollateral)); + + assert(isolatedMarginCalc?.marginRequirement.eq(new BN('1000000000'))); + assert(isolatedMarginCalc?.totalCollateral.eq(new BN('-900000000'))); + // With 10% buffer + const tenPct = new BN(1000); + const crossCalcBuf = userCross.getMarginCalculation('Initial', { + liquidationBuffer: tenPct, + }); + assert(crossCalcBuf.marginRequirementPlusBuffer.eq(new BN('13000000000'))); // replicate 10% buffer + const crossTotalPlusBuffer = crossCalcBuf.totalCollateral.add( + crossCalcBuf.totalCollateralBuffer + ); + assert(crossTotalPlusBuffer.eq(new BN('20000000000'))); + + const isoPosition = crossCalcBuf.isolatedMarginCalculations.get(0); + assert(isoPosition?.marginRequirementPlusBuffer.eq(new BN('2000000000'))); + assert(isoPosition?.totalCollateralBuffer.add(isoPosition?.totalCollateral).eq(new BN('-1000000000'))); + }); +}); + From c6b850e11faff34a4b4d85d562d13d3922f115ed Mon Sep 17 00:00:00 2001 From: Lukas deConantsesznak Date: Fri, 31 Oct 2025 14:19:14 -0600 Subject: [PATCH 04/13] fix: lint and formatting --- sdk/src/marginCalculation.ts | 11 +---- sdk/src/math/spotPosition.ts | 7 ++- sdk/src/user.ts | 67 ++++++++++++++++++-------- sdk/tests/user/getMarginCalculation.ts | 1 - 4 files changed, 52 insertions(+), 34 deletions(-) diff --git a/sdk/src/marginCalculation.ts b/sdk/src/marginCalculation.ts index 43452243a9..c26333db00 100644 --- a/sdk/src/marginCalculation.ts +++ b/sdk/src/marginCalculation.ts @@ -80,15 +80,6 @@ export class MarginContext { this.marginRatioOverride = ratio; return this; } - - trackMarketMarginRequirement(marketIdentifier: MarketIdentifier): this { - if (this.mode.type !== 'Liquidation') { - throw new Error( - 'InvalidMarginCalculation: Cant track market outside of liquidation mode' - ); - } - return this; - } } export class IsolatedMarginCalculation { @@ -312,4 +303,4 @@ export class MarginCalculation { hasIsolatedMarginCalculation(marketIndex: number): boolean { return this.isolatedMarginCalculations.has(marketIndex); } -} \ No newline at end of file +} diff --git a/sdk/src/math/spotPosition.ts b/sdk/src/math/spotPosition.ts index 036f5cf83b..b0612eaca0 100644 --- a/sdk/src/math/spotPosition.ts +++ b/sdk/src/math/spotPosition.ts @@ -33,7 +33,7 @@ export function getWorstCaseTokenAmounts( spotMarketAccount: SpotMarketAccount, strictOraclePrice: StrictOraclePrice, marginCategory: MarginCategory, - customMarginRatio?: number, + customMarginRatio?: number, includeOpenOrders?: boolean ): OrderFillSimulation { const tokenAmount = getSignedTokenAmount( @@ -51,7 +51,10 @@ export function getWorstCaseTokenAmounts( strictOraclePrice ); - if (spotPosition.openBids.eq(ZERO) && spotPosition.openAsks.eq(ZERO) || !includeOpenOrders) { + if ( + (spotPosition.openBids.eq(ZERO) && spotPosition.openAsks.eq(ZERO)) || + !includeOpenOrders + ) { const { weight, weightedTokenValue } = calculateWeightedTokenValue( tokenAmount, tokenValue, diff --git a/sdk/src/user.ts b/sdk/src/user.ts index 4111dda35e..c1f950c473 100644 --- a/sdk/src/user.ts +++ b/sdk/src/user.ts @@ -107,10 +107,7 @@ import { StrictOraclePrice } from './oracles/strictOraclePrice'; import { calculateSpotFuelBonus, calculatePerpFuelBonus } from './math/fuel'; import { grpcUserAccountSubscriber } from './accounts/grpcUserAccountSubscriber'; -import { - MarginCalculation, - MarginContext, -} from './marginCalculation'; +import { MarginCalculation, MarginContext } from './marginCalculation'; export type MarginType = 'Cross' | 'Isolated'; @@ -716,7 +713,7 @@ export class User { const market = this.driftClient.getPerpMarketAccount( perpPosition.marketIndex ); - if(!market) return unrealizedPnl; + if (!market) return unrealizedPnl; const oraclePriceData = this.getMMOracleDataForPerpMarket( market.marketIndex ); @@ -1267,7 +1264,6 @@ export class User { return 0; } - let totalCollateral: BN; let maintenanceMarginReq: BN; @@ -1317,7 +1313,7 @@ export class User { perpPosition.marketIndex ); - if(!market) return ZERO; + if (!market) return ZERO; let valuationPrice = this.getOracleDataForPerpMarket( market.marketIndex @@ -1960,13 +1956,23 @@ export class User { canBeLiquidated: boolean; marginRequirement: BN; totalCollateral: BN; - liquidationStatuses: Map<'cross' | number, { canBeLiquidated: boolean; marginRequirement: BN; totalCollateral: BN }>; + liquidationStatuses: Map< + 'cross' | number, + { canBeLiquidated: boolean; marginRequirement: BN; totalCollateral: BN } + >; } { // Deprecated signature retained for backward compatibility in type only // but implementation now delegates to the new Map-based API and returns cross margin status. const map = this.getLiquidationStatuses(); const cross = map.get('cross'); - return cross ? { ...cross, liquidationStatuses: map } : { canBeLiquidated: false, marginRequirement: ZERO, totalCollateral: ZERO, liquidationStatuses: map }; + return cross + ? { ...cross, liquidationStatuses: map } + : { + canBeLiquidated: false, + marginRequirement: ZERO, + totalCollateral: ZERO, + liquidationStatuses: map, + }; } /** @@ -1975,18 +1981,28 @@ export class User { * - 'cross' for cross margin * - marketIndex (number) for each isolated perp position */ - public getLiquidationStatuses(marginCalc?: MarginCalculation): Map<'cross' | number, { canBeLiquidated: boolean; marginRequirement: BN; totalCollateral: BN }> { + public getLiquidationStatuses( + marginCalc?: MarginCalculation + ): Map< + 'cross' | number, + { canBeLiquidated: boolean; marginRequirement: BN; totalCollateral: BN } + > { // If not provided, use buffer-aware calc for canBeLiquidated checks if (!marginCalc) { const liquidationBuffer = this.getLiquidationBuffer(); - marginCalc = this.getMarginCalculation('Maintenance', { liquidationBuffer }); + marginCalc = this.getMarginCalculation('Maintenance', { + liquidationBuffer, + }); } - const result = new Map<'cross' | number, { - canBeLiquidated: boolean; - marginRequirement: BN; - totalCollateral: BN; - }>(); + const result = new Map< + 'cross' | number, + { + canBeLiquidated: boolean; + marginRequirement: BN; + totalCollateral: BN; + } + >(); // Cross margin status const crossTotalCollateral = marginCalc.totalCollateral; @@ -1998,7 +2014,10 @@ export class User { }); // Isolated positions status - for (const [marketIndex, isoCalc] of marginCalc.isolatedMarginCalculations) { + for (const [ + marketIndex, + isoCalc, + ] of marginCalc.isolatedMarginCalculations) { const isoTotalCollateral = isoCalc.totalCollateral; const isoMarginRequirement = isoCalc.marginRequirement; result.set(marketIndex, { @@ -2015,7 +2034,8 @@ export class User { // Consider on-chain flags OR computed margin status (cross or any isolated) const hasOnChainFlag = (this.getUserAccount().status & - (UserStatus.BEING_LIQUIDATED | UserStatus.BANKRUPT)) > 0; + (UserStatus.BEING_LIQUIDATED | UserStatus.BANKRUPT)) > + 0; const calc = marginCalc ?? this.getMarginCalculation('Maintenance'); return ( hasOnChainFlag || @@ -2031,7 +2051,9 @@ export class User { } /** Returns true if any isolated perp position is currently below its maintenance requirement (no buffer). */ - public isIsolatedMarginBeingLiquidated(marginCalc?: MarginCalculation): boolean { + public isIsolatedMarginBeingLiquidated( + marginCalc?: MarginCalculation + ): boolean { const calc = marginCalc ?? this.getMarginCalculation('Maintenance'); for (const [, isoCalc] of calc.isolatedMarginCalculations) { if (isoCalc.totalCollateral.lt(isoCalc.marginRequirement)) { @@ -4307,11 +4329,14 @@ export class User { calculateWorstCasePerpLiabilityValue( marketPosition, market, - oraclePriceData.price, + oraclePriceData.price ); // margin ratio for this perp - const customMarginRatio = Math.max(this.getUserAccount().maxMarginRatio, marketPosition.maxMarginRatio); + const customMarginRatio = Math.max( + this.getUserAccount().maxMarginRatio, + marketPosition.maxMarginRatio + ); let marginRatio = new BN( calculateMarketMarginRatio( market, diff --git a/sdk/tests/user/getMarginCalculation.ts b/sdk/tests/user/getMarginCalculation.ts index 6074ea16f6..49f954528b 100644 --- a/sdk/tests/user/getMarginCalculation.ts +++ b/sdk/tests/user/getMarginCalculation.ts @@ -12,7 +12,6 @@ import { QUOTE_PRECISION, SPOT_MARKET_BALANCE_PRECISION, SpotBalanceType, - MARGIN_PRECISION, OPEN_ORDER_MARGIN_REQUIREMENT, SPOT_MARKET_WEIGHT_PRECISION, PositionFlag, From d414b5769b0168b24574afa1008b037cdc491347 Mon Sep 17 00:00:00 2001 From: Lukas deConantsesznak Date: Tue, 4 Nov 2025 08:57:26 -0700 Subject: [PATCH 05/13] fix: logic to return with liq buffer --- sdk/src/user.ts | 23 +++++++++++++++++------ 1 file changed, 17 insertions(+), 6 deletions(-) diff --git a/sdk/src/user.ts b/sdk/src/user.ts index c1f950c473..34b539bc0f 100644 --- a/sdk/src/user.ts +++ b/sdk/src/user.ts @@ -600,13 +600,18 @@ export class User { if (perpMarketIndex !== undefined) { const isolatedMarginCalculation = marginCalc.isolatedMarginCalculations.get(perpMarketIndex); - const { marginRequirement } = isolatedMarginCalculation; + const { marginRequirement, marginRequirementPlusBuffer } = isolatedMarginCalculation; + if(liquidationBuffer?.gt(ZERO)){ + return marginRequirementPlusBuffer; + } return marginRequirement; } // Default: Cross margin requirement - // TODO: should we be using plus buffer sometimes? + if(liquidationBuffer?.gt(ZERO)){ + return marginCalc.marginRequirementPlusBuffer; + } return marginCalc.marginRequirement; } @@ -620,7 +625,7 @@ export class User { return this.getMarginRequirement( 'Initial', undefined, - false, + true, undefined, enterHighLeverageMode, perpMarketIndex @@ -637,7 +642,7 @@ export class User { return this.getMarginRequirement( 'Maintenance', liquidationBuffer, - true, // strict default + false, // strict default true, // includeOpenOrders default false, // enteringHighLeverage default perpMarketIndex @@ -1236,10 +1241,16 @@ export class User { }); if (perpMarketIndex !== undefined) { - return marginCalc.isolatedMarginCalculations.get(perpMarketIndex) - .totalCollateral; + const { totalCollateral, totalCollateralBuffer } = marginCalc.isolatedMarginCalculations.get(perpMarketIndex) + if(liquidationBuffer?.gt(ZERO)){ + return totalCollateralBuffer; + } + return totalCollateral; } + if(liquidationBuffer?.gt(ZERO)){ + return marginCalc.totalCollateralBuffer; + } return marginCalc.totalCollateral; } From 02a46b02188908bcd26c961a8d3b43a87d4288e1 Mon Sep 17 00:00:00 2001 From: Lukas deConantsesznak Date: Tue, 4 Nov 2025 10:47:06 -0700 Subject: [PATCH 06/13] feat: add liq buffer in margin calc + cleaner canBeLiquidated return --- sdk/src/types.ts | 6 ++++++ sdk/src/user.ts | 50 +++++++++++++++++++++--------------------------- 2 files changed, 28 insertions(+), 28 deletions(-) diff --git a/sdk/src/types.ts b/sdk/src/types.ts index 4d97d4c608..83cd78a0e1 100644 --- a/sdk/src/types.ts +++ b/sdk/src/types.ts @@ -1902,3 +1902,9 @@ export type CacheInfo = { export type AmmCache = { cache: CacheInfo[]; }; + +export type AccountLiquidatableStatus = { + canBeLiquidated: boolean; + marginRequirement: BN; + totalCollateral: BN; +}; diff --git a/sdk/src/user.ts b/sdk/src/user.ts index 34b539bc0f..67f80c0db7 100644 --- a/sdk/src/user.ts +++ b/sdk/src/user.ts @@ -14,6 +14,7 @@ import { UserAccount, UserStatus, UserStatsAccount, + AccountLiquidatableStatus, } from './types'; import { calculateEntryPrice, @@ -600,16 +601,17 @@ export class User { if (perpMarketIndex !== undefined) { const isolatedMarginCalculation = marginCalc.isolatedMarginCalculations.get(perpMarketIndex); - const { marginRequirement, marginRequirementPlusBuffer } = isolatedMarginCalculation; + const { marginRequirement, marginRequirementPlusBuffer } = + isolatedMarginCalculation; - if(liquidationBuffer?.gt(ZERO)){ + if (liquidationBuffer?.gt(ZERO)) { return marginRequirementPlusBuffer; } return marginRequirement; } // Default: Cross margin requirement - if(liquidationBuffer?.gt(ZERO)){ + if (liquidationBuffer?.gt(ZERO)) { return marginCalc.marginRequirementPlusBuffer; } return marginCalc.marginRequirement; @@ -1241,14 +1243,15 @@ export class User { }); if (perpMarketIndex !== undefined) { - const { totalCollateral, totalCollateralBuffer } = marginCalc.isolatedMarginCalculations.get(perpMarketIndex) - if(liquidationBuffer?.gt(ZERO)){ + const { totalCollateral, totalCollateralBuffer } = + marginCalc.isolatedMarginCalculations.get(perpMarketIndex); + if (liquidationBuffer?.gt(ZERO)) { return totalCollateralBuffer; } return totalCollateral; } - if(liquidationBuffer?.gt(ZERO)){ + if (liquidationBuffer?.gt(ZERO)) { return marginCalc.totalCollateralBuffer; } return marginCalc.totalCollateral; @@ -1963,26 +1966,27 @@ export class User { return netAssetValue.mul(TEN_THOUSAND).div(totalLiabilityValue); } - public canBeLiquidated(): { - canBeLiquidated: boolean; - marginRequirement: BN; - totalCollateral: BN; - liquidationStatuses: Map< - 'cross' | number, - { canBeLiquidated: boolean; marginRequirement: BN; totalCollateral: BN } - >; + public canBeLiquidated(): AccountLiquidatableStatus & { + isolatedPositions: Map; } { // Deprecated signature retained for backward compatibility in type only // but implementation now delegates to the new Map-based API and returns cross margin status. const map = this.getLiquidationStatuses(); const cross = map.get('cross'); + const isolatedPositions: Map = new Map( + Array.from(map.entries()) + .filter( + (e): e is [number, AccountLiquidatableStatus] => e[0] !== 'cross' + ) + .map(([key, value]) => [key, value]) + ); return cross - ? { ...cross, liquidationStatuses: map } + ? { ...cross, isolatedPositions } : { canBeLiquidated: false, marginRequirement: ZERO, totalCollateral: ZERO, - liquidationStatuses: map, + isolatedPositions, }; } @@ -1994,10 +1998,7 @@ export class User { */ public getLiquidationStatuses( marginCalc?: MarginCalculation - ): Map< - 'cross' | number, - { canBeLiquidated: boolean; marginRequirement: BN; totalCollateral: BN } - > { + ): Map<'cross' | number, AccountLiquidatableStatus> { // If not provided, use buffer-aware calc for canBeLiquidated checks if (!marginCalc) { const liquidationBuffer = this.getLiquidationBuffer(); @@ -2006,14 +2007,7 @@ export class User { }); } - const result = new Map< - 'cross' | number, - { - canBeLiquidated: boolean; - marginRequirement: BN; - totalCollateral: BN; - } - >(); + const result = new Map<'cross' | number, AccountLiquidatableStatus>(); // Cross margin status const crossTotalCollateral = marginCalc.totalCollateral; From 782dc441ef08e3cc983e860f3294b4faab239f53 Mon Sep 17 00:00:00 2001 From: Lukas deConantsesznak Date: Thu, 6 Nov 2025 14:04:52 -0700 Subject: [PATCH 07/13] fix: many fixes and testing script to bring cross collat margin calc parity --- sdk/scripts/compare-user-parity.ts | 351 +++ sdk/src/math/margin.ts | 17 +- sdk/src/math/spotPosition.ts | 2 +- sdk/src/user.ts | 152 +- sdk/src/user_oldMarginCalculation.ts | 4131 ++++++++++++++++++++++++++ 5 files changed, 4610 insertions(+), 43 deletions(-) create mode 100644 sdk/scripts/compare-user-parity.ts create mode 100644 sdk/src/user_oldMarginCalculation.ts diff --git a/sdk/scripts/compare-user-parity.ts b/sdk/scripts/compare-user-parity.ts new file mode 100644 index 0000000000..b388552f82 --- /dev/null +++ b/sdk/scripts/compare-user-parity.ts @@ -0,0 +1,351 @@ +import { Connection, Commitment, PublicKey } from '@solana/web3.js'; +import { AnchorProvider, BN } from '@coral-xyz/anchor'; +import { Keypair } from '@solana/web3.js'; + +import { DriftClient } from '../src/driftClient'; +import { BulkAccountLoader } from '../src/accounts/bulkAccountLoader'; +import { DRIFT_PROGRAM_ID, Wallet } from '../src'; +import { User as CurrentUser } from '../src/user'; +import { User as OldUser } from '../src/user_oldMarginCalculation'; +import { UserMap } from '../src/userMap/userMap'; +import { UserMapConfig } from '../src/userMap/userMapConfig'; + +type MarginCategory = 'Initial' | 'Maintenance'; + +function getEnv(name: string, fallback?: string): string { + const v = process.env[name]; + if (v === undefined || v === '') { + if (fallback !== undefined) return fallback; + throw new Error(`${name} env var must be set.`); + } + return v; +} + +function asCommitment( + maybe: string | undefined, + fallback: Commitment +): Commitment { + const val = (maybe as Commitment) || fallback; + return val; +} + +function bnEq(a: BN, b: BN): boolean { + return a.eq(b); +} + +function buildOldUserFromSnapshot( + driftClient: DriftClient, + currentUser: CurrentUser +): OldUser { + const userAccountPubkey = currentUser.getUserAccountPublicKey(); + + const oldUser = new OldUser({ + driftClient, + userAccountPublicKey: userAccountPubkey, + accountSubscription: { + type: 'custom', + userAccountSubscriber: currentUser.accountSubscriber, + }, + }); + + return oldUser; +} + +function logMismatch( + userPubkey: PublicKey, + fn: string, + args: Record, + vNew: BN, + vOld: BN +) { + // Ensure BN values are logged as strings and arrays are printable + const serialize = (val: unknown): unknown => { + if (val instanceof BN) return val.toString(); + if (Array.isArray(val)) + return val.map((x) => (x instanceof BN ? x.toString() : x)); + return val; + }; + + const argsSerialized: Record = {}; + for (const k of Object.keys(args)) { + argsSerialized[k] = serialize(args[k]); + } + + const argsLines = Object.keys(argsSerialized) + .map( + (k) => + `\t- ${k}: ${ + Array.isArray(argsSerialized[k]) + ? (argsSerialized[k] as unknown[]).join(', ') + : String(argsSerialized[k]) + }` + ) + .join('|'); + + console.error( + // `❌ Parity mismatch\n` + + `- ❌ user: ${userPubkey.toBase58()} | function: ${fn}\n` + + `- args:\n${argsLines || '\t- none'}\n` + + `- new: ${vNew.toString()} | old: ${vOld.toString()}\n` + ); +} + +async function main(): Promise { + const RPC_ENDPOINT = getEnv('RPC_ENDPOINT'); + const COMMITMENT = asCommitment(process.env.COMMITMENT, 'processed'); + const POLL_FREQUENCY_MS = Number(process.env.POLL_FREQUENCY_MS || '40000'); + + const connection = new Connection(RPC_ENDPOINT, COMMITMENT); + const wallet = new Wallet(new Keypair()); + + // AnchorProvider is not strictly required for polling, but some downstream utils expect a provider on the program + // eslint-disable-next-line @typescript-eslint/no-unused-vars + const _provider = new AnchorProvider( + connection, + wallet as unknown as AnchorProvider['wallet'], + { + commitment: COMMITMENT, + preflightCommitment: COMMITMENT, + } + ); + + const bulkAccountLoader = new BulkAccountLoader( + connection, + COMMITMENT, + POLL_FREQUENCY_MS + ); + + const driftClient = new DriftClient({ + connection, + wallet, + programID: new PublicKey(DRIFT_PROGRAM_ID), + accountSubscription: { + type: 'polling', + accountLoader: bulkAccountLoader, + }, + }); + + await driftClient.subscribe(); + + const userMapConfig: UserMapConfig = { + driftClient, + subscriptionConfig: { + type: 'polling', + frequency: POLL_FREQUENCY_MS, + commitment: COMMITMENT, + }, + includeIdle: false, + fastDecode: true, + throwOnFailedSync: false, + }; + + const userMap = new UserMap(userMapConfig); + await userMap.subscribe(); + await userMap.sync(); + + let mismatches = 0; + let usersChecked = 0; + const mismatchesByFunction: Record = {}; + const usersWithDiscrepancies = new Set(); + + const isolatedKeysEnv = process.env.ISOLATED_USER_PUBKEY; + const isolatedKeys = + isolatedKeysEnv && isolatedKeysEnv.length > 0 + ? isolatedKeysEnv + .split(',') + .map((k) => k.trim()) + .filter((k) => k.length > 0) + : []; + + const usersFilterd = + isolatedKeys.length > 0 + ? Array.from(userMap.entries()).filter(([userKey]) => + isolatedKeys.includes(userKey) + ) + : Array.from(userMap.entries()); + + for (const [userKey, currUser] of usersFilterd) { + usersChecked += 1; + const userPubkey = new PublicKey(userKey); + + function noteMismatch(functionName: string): void { + mismatchesByFunction[functionName] = + (mismatchesByFunction[functionName] ?? 0) + 1; + usersWithDiscrepancies.add(userPubkey.toBase58()); + mismatches += 1; + } + + // clean curr User position flags to be all 0 + + currUser.getActivePerpPositions().forEach((position) => { + position.positionFlag = 0; + }); + + const oldUser = buildOldUserFromSnapshot(driftClient, currUser, COMMITMENT); + + try { + // Cross-account level comparisons + // const categories: MarginCategory[] = ['Initial', 'Maintenance']; + const categories: MarginCategory[] = ['Initial']; + // const categories: MarginCategory[] = ['Maintenance']; + // const categories: MarginCategory[] = []; + + for (const cat of categories) { + // getFreeCollateral + const vNew_fc = currUser.getFreeCollateral(cat); + const vOld_fc = oldUser.getFreeCollateral(cat); + if (!bnEq(vNew_fc, vOld_fc)) { + logMismatch( + userPubkey, + 'getFreeCollateral', + { marginCategory: cat }, + vNew_fc, + vOld_fc + ); + noteMismatch('getFreeCollateral'); + } + + // only do free collateral for now + // continue; + + // getTotalCollateral + const vNew_tc = currUser.getTotalCollateral(cat); + const vOld_tc = oldUser.getTotalCollateral(cat); + if (!bnEq(vNew_tc, vOld_tc)) { + logMismatch( + userPubkey, + 'getTotalCollateral', + { marginCategory: cat }, + vNew_tc, + vOld_tc + ); + noteMismatch('getTotalCollateral'); + } + + // getMarginRequirement (strict=true, includeOpenOrders=true) + const vNew_mr = currUser.getMarginRequirement( + cat, + undefined, + true, + true + ); + const vOld_mr = oldUser.getMarginRequirement( + cat, + undefined, + true, + true + ); + if (!bnEq(vNew_mr, vOld_mr)) { + logMismatch( + userPubkey, + 'getMarginRequirement', + { marginCategory: cat, strict: true, includeOpenOrders: true }, + vNew_mr, + vOld_mr + ); + noteMismatch('getMarginRequirement'); + } + } + // continue; + + // Per-perp-market comparisons + const activePerpPositions = currUser.getActivePerpPositions(); + for (const pos of activePerpPositions) { + const marketIndex = pos.marketIndex; + + // getPerpBuyingPower + const vNew_pbp = currUser.getPerpBuyingPower(marketIndex); + const vOld_pbp = oldUser.getPerpBuyingPower(marketIndex); + if (!bnEq(vNew_pbp, vOld_pbp)) { + logMismatch( + userPubkey, + 'getPerpBuyingPower', + { marketIndex }, + vNew_pbp, + vOld_pbp + ); + noteMismatch('getPerpBuyingPower'); + } + + // liquidationPrice (defaults) + const vNew_lp = currUser.liquidationPrice(marketIndex); + const vOld_lp = oldUser.liquidationPrice(marketIndex); + if (!bnEq(vNew_lp, vOld_lp)) { + logMismatch( + userPubkey, + 'liquidationPrice', + { marketIndex }, + vNew_lp, + vOld_lp + ); + noteMismatch('liquidationPrice'); + } + + // liquidationPriceAfterClose with 10% of current quote as close amount (skip if zero/absent) + const quoteAbs = pos.quoteAssetAmount + ? pos.quoteAssetAmount.abs() + : new BN(0); + const closeQuoteAmount = quoteAbs.div(new BN(10)); + if (closeQuoteAmount.gt(new BN(0))) { + const vNew_lpac = currUser.liquidationPriceAfterClose( + marketIndex, + closeQuoteAmount + ); + const vOld_lpac = oldUser.liquidationPriceAfterClose( + marketIndex, + closeQuoteAmount + ); + if (!bnEq(vNew_lpac, vOld_lpac)) { + logMismatch( + userPubkey, + 'liquidationPriceAfterClose', + { marketIndex, closeQuoteAmount: closeQuoteAmount.toString() }, + vNew_lpac, + vOld_lpac + ); + noteMismatch('liquidationPriceAfterClose'); + } + } + } + } catch (e) { + console.error( + `💥 Parity exception\n` + + `- user: ${userPubkey.toBase58()}\n` + + `- error: ${(e as Error).message}` + ); + usersWithDiscrepancies.add(userPubkey.toBase58()); + mismatches += 1; + } finally { + await oldUser.unsubscribe(); + } + } + + const byFunctionLines = Object.entries(mismatchesByFunction) + .sort((a, b) => b[1] - a[1]) + .map(([fn, count]) => `\t- ${fn}: ${count}`) + .join('\n'); + + console.log( + `\n📊 User parity summary\n` + + `- users checked: ${usersChecked}\n` + + `- users with discrepancy: ${usersWithDiscrepancies.size}\n` + + `- percentage of users with discrepancy: ${ + (usersWithDiscrepancies.size / usersChecked) * 100 + }%\n` + + `- total mismatches: ${mismatches}\n` + + // `- percentage of mismatches: ${(mismatches / usersChecked) * 100}%\n` + + `- mismatches by function:\n${byFunctionLines || '\t- none'}\n` + ); + + await userMap.unsubscribe(); + await driftClient.unsubscribe(); + + if (mismatches > 0) { + process.exit(1); + } else { + process.exit(0); + } +} + +// eslint-disable-next-line @typescript-eslint/no-floating-promises +main(); diff --git a/sdk/src/math/margin.ts b/sdk/src/math/margin.ts index 2dde85d11b..c65912eab7 100644 --- a/sdk/src/math/margin.ts +++ b/sdk/src/math/margin.ts @@ -165,12 +165,25 @@ export function calculateWorstCaseBaseAssetAmount( export function calculateWorstCasePerpLiabilityValue( perpPosition: PerpPosition, perpMarket: PerpMarketAccount, - oraclePrice: BN + oraclePrice: BN, + includeOpenOrders = true ): { worstCaseBaseAssetAmount: BN; worstCaseLiabilityValue: BN } { + const isPredictionMarket = isVariant(perpMarket.contractType, 'prediction'); + + if (!includeOpenOrders) { + return { + worstCaseBaseAssetAmount: perpPosition.baseAssetAmount, + worstCaseLiabilityValue: calculatePerpLiabilityValue( + perpPosition.baseAssetAmount, + oraclePrice, + isPredictionMarket + ), + }; + } + const allBids = perpPosition.baseAssetAmount.add(perpPosition.openBids); const allAsks = perpPosition.baseAssetAmount.add(perpPosition.openAsks); - const isPredictionMarket = isVariant(perpMarket.contractType, 'prediction'); const allBidsLiabilityValue = calculatePerpLiabilityValue( allBids, oraclePrice, diff --git a/sdk/src/math/spotPosition.ts b/sdk/src/math/spotPosition.ts index b0612eaca0..d05a7382d4 100644 --- a/sdk/src/math/spotPosition.ts +++ b/sdk/src/math/spotPosition.ts @@ -34,7 +34,7 @@ export function getWorstCaseTokenAmounts( strictOraclePrice: StrictOraclePrice, marginCategory: MarginCategory, customMarginRatio?: number, - includeOpenOrders?: boolean + includeOpenOrders: boolean = true ): OrderFillSimulation { const tokenAmount = getSignedTokenAmount( getTokenAmount( diff --git a/sdk/src/user.ts b/sdk/src/user.ts index 67f80c0db7..fad96cd122 100644 --- a/sdk/src/user.ts +++ b/sdk/src/user.ts @@ -532,6 +532,7 @@ export class User { /** * calculates Free Collateral = Total collateral - margin requirement + * TODO: can we not call getMarginCalculation twice? seems annoying... * @returns : Precision QUOTE_PRECISION */ public getFreeCollateral( @@ -539,14 +540,21 @@ export class User { enterHighLeverageMode = false, perpMarketIndex?: number ): BN { + const { totalCollateral } = this.getMarginCalculation(marginCategory, { + enteringHighLeverage: enterHighLeverageMode, + strict: true, + }); + const marginCalc = this.getMarginCalculation(marginCategory, { enteringHighLeverage: enterHighLeverageMode, + strict: marginCategory === 'Initial', }); if (perpMarketIndex !== undefined) { return marginCalc.getIsolatedFreeCollateral(perpMarketIndex); } else { - return marginCalc.getCrossFreeCollateral(); + const freeCollateral = totalCollateral.sub(marginCalc.marginRequirement); + return freeCollateral.gte(ZERO) ? freeCollateral : ZERO; } } @@ -2035,17 +2043,11 @@ export class User { return result; } - public isBeingLiquidated(marginCalc?: MarginCalculation): boolean { - // Consider on-chain flags OR computed margin status (cross or any isolated) - const hasOnChainFlag = + public isBeingLiquidated(): boolean { + return ( (this.getUserAccount().status & (UserStatus.BEING_LIQUIDATED | UserStatus.BANKRUPT)) > - 0; - const calc = marginCalc ?? this.getMarginCalculation('Maintenance'); - return ( - hasOnChainFlag || - this.isCrossMarginBeingLiquidated(calc) || - this.isIsolatedMarginBeingLiquidated(calc) + 0 ); } @@ -2284,6 +2286,11 @@ export class User { false, includeOpenOrders ); + + // console.log( + // 'new user liq price totalCollateral', + // totalCollateral.toString() + // ); const marginRequirement = this.getMarginRequirement( marginCategory, undefined, @@ -2291,6 +2298,11 @@ export class User { includeOpenOrders, enteringHighLeverage ); + + // console.log( + // 'new user liq price marginRequirement', + // marginRequirement.toString() + // ); let freeCollateral = BN.max( ZERO, totalCollateral.sub(marginRequirement) @@ -2502,6 +2514,7 @@ export class User { this.getUserAccount().maxMarginRatio ); + // TODO: does this work in an isolated position context, cc perp const marginRatio = calculateMarketMarginRatio( market, proposedBaseAssetAmount.abs(), @@ -4199,6 +4212,8 @@ export class User { const marginBuffer = opts?.liquidationBuffer; // treat as MARGIN_BUFFER ratio if provided const marginRatioOverride = opts?.marginRatioOverride; + let totalUnrealizedPnl = ZERO; + // Equivalent to on-chain user_custom_margin_ratio let userCustomMarginRatio = marginCategory === 'Initial' ? this.getUserAccount().maxMarginRatio : 0; @@ -4218,9 +4233,13 @@ export class User { // SPOT POSITIONS // TODO: include open orders in the worst-case simulation in the same way on both spot and perp positions + + let netQuoteValue = ZERO; for (const spotPosition of this.getUserAccount().spotPositions) { if (isSpotPositionAvailable(spotPosition)) continue; + const isQuote = spotPosition.marketIndex === QUOTE_SPOT_MARKET_INDEX; + const spotMarket = this.driftClient.getSpotMarketAccount( spotPosition.marketIndex ); @@ -4237,7 +4256,7 @@ export class User { : undefined; const strictOracle = new StrictOraclePrice(oraclePriceData.price, twap5); - if (spotPosition.marketIndex === QUOTE_SPOT_MARKET_INDEX) { + if (isQuote) { const tokenAmount = getSignedTokenAmount( getTokenAmount( spotPosition.scaledBalance, @@ -4248,23 +4267,25 @@ export class User { ); if (isVariant(spotPosition.balanceType, 'deposit')) { // add deposit value to total collateral - const tokenValue = getStrictTokenValue( + const weightedTokenValue = this.getSpotAssetValue( tokenAmount, - spotMarket.decimals, - strictOracle + strictOracle, + spotMarket, + marginCategory ); - calc.addCrossMarginTotalCollateral(tokenValue); + netQuoteValue = netQuoteValue.add(weightedTokenValue); } else { // borrow on quote contributes to margin requirement - const tokenValueAbs = getStrictTokenValue( + const tokenValueAbs = this.getSpotLiabilityValue( tokenAmount, - spotMarket.decimals, - strictOracle + strictOracle, + spotMarket, + marginCategory, + marginBuffer ).abs(); - calc.addCrossMarginRequirement(tokenValueAbs, tokenValueAbs); calc.addSpotLiability(); + netQuoteValue = netQuoteValue.sub(tokenValueAbs); } - continue; } // Non-quote spot: worst-case simulation @@ -4280,40 +4301,63 @@ export class User { marginCategory, userCustomMarginRatio, includeOpenOrders + // false ); - // open order IM - calc.addCrossMarginRequirement( - new BN(spotPosition.openOrders).mul(OPEN_ORDER_MARGIN_REQUIREMENT), - ZERO - ); + if (includeOpenOrders) { + // open order IM + calc.addCrossMarginRequirement( + new BN(spotPosition.openOrders).mul(OPEN_ORDER_MARGIN_REQUIREMENT), + ZERO + ); + } - if (worstCaseTokenAmount.gt(ZERO)) { + if (worstCaseTokenAmount.gt(ZERO) && !isQuote) { + const baseAssetValue = this.getSpotAssetValue( + worstCaseTokenAmount, + strictOracle, + spotMarket, + marginCategory + ); // asset side increases total collateral (weighted) - calc.addCrossMarginTotalCollateral(worstCaseWeightedTokenValue); - } else if (worstCaseTokenAmount.lt(ZERO)) { + calc.addCrossMarginTotalCollateral(baseAssetValue); + } else if (worstCaseTokenAmount.lt(ZERO) && !isQuote) { // liability side increases margin requirement (weighted >= abs(token_value)) - const liabilityWeighted = worstCaseWeightedTokenValue.abs(); + // const liabilityWeighted = worstCaseWeightedTokenValue.abs(); + const getSpotLiabilityValue = this.getSpotLiabilityValue( + worstCaseTokenAmount, + strictOracle, + spotMarket, + marginCategory, + marginBuffer + ); + + // TODO need to sync with perp on passing liability weighted or getSpotLiabilityValue.abs() for each param here calc.addCrossMarginRequirement( - liabilityWeighted, - worstCaseTokenValue.abs() + getSpotLiabilityValue.abs(), + getSpotLiabilityValue.abs() ); calc.addSpotLiability(); calc.addSpotLiabilityValue(worstCaseTokenValue.abs()); - } else if (spotPosition.openOrders !== 0) { + } else if (spotPosition.openOrders !== 0 && !isQuote) { calc.addSpotLiability(); calc.addSpotLiabilityValue(worstCaseTokenValue.abs()); } // orders value contributes to collateral or requirement if (worstCaseOrdersValue.gt(ZERO)) { - calc.addCrossMarginTotalCollateral(worstCaseOrdersValue); + netQuoteValue = netQuoteValue.add(worstCaseOrdersValue); } else if (worstCaseOrdersValue.lt(ZERO)) { const absVal = worstCaseOrdersValue.abs(); - calc.addCrossMarginRequirement(absVal, absVal); + netQuoteValue = netQuoteValue.sub(absVal); } } + if (netQuoteValue.gt(ZERO)) { + calc.addCrossMarginTotalCollateral(netQuoteValue); + } else if (netQuoteValue.lt(ZERO)) { + calc.addCrossMarginRequirement(netQuoteValue.abs(), netQuoteValue.abs()); + } // PERP POSITIONS for (const marketPosition of this.getActivePerpPositions()) { const market = this.driftClient.getPerpMarketAccount( @@ -4325,7 +4369,11 @@ export class User { const quoteOraclePriceData = this.getOracleDataForSpotMarket( market.quoteSpotMarketIndex ); - const oraclePriceData = this.getOracleDataForPerpMarket( + const oraclePriceData = this.getMMOracleDataForPerpMarket( + market.marketIndex + ); + + const nonMmmOraclePriceData = this.getOracleDataForPerpMarket( market.marketIndex ); @@ -4334,7 +4382,8 @@ export class User { calculateWorstCasePerpLiabilityValue( marketPosition, market, - oraclePriceData.price + nonMmmOraclePriceData.price, + includeOpenOrders ); // margin ratio for this perp @@ -4368,9 +4417,11 @@ export class User { .mul(marginRatio) .div(MARGIN_PRECISION); // add open orders IM - perpMarginRequirement = perpMarginRequirement.add( - new BN(marketPosition.openOrders).mul(OPEN_ORDER_MARGIN_REQUIREMENT) - ); + if (includeOpenOrders) { + perpMarginRequirement = perpMarginRequirement.add( + new BN(marketPosition.openOrders).mul(OPEN_ORDER_MARGIN_REQUIREMENT) + ); + } // weighted unrealized pnl let positionUnrealizedPnl = calculatePositionPNL( @@ -4397,6 +4448,28 @@ export class User { .mul(pnlQuotePrice) .div(PRICE_PRECISION); + if (marginCategory !== undefined) { + if (positionUnrealizedPnl.gt(ZERO)) { + positionUnrealizedPnl = positionUnrealizedPnl + .mul( + calculateUnrealizedAssetWeight( + market, + quoteSpotMarket, + positionUnrealizedPnl, + marginCategory, + oraclePriceData + ) + ) + .div(new BN(SPOT_MARKET_WEIGHT_PRECISION)); + } + + if (marginBuffer && positionUnrealizedPnl.lt(ZERO)) { + positionUnrealizedPnl = positionUnrealizedPnl.add( + positionUnrealizedPnl.mul(marginBuffer).div(MARGIN_PRECISION) + ); + } + } + // Add perp contribution: isolated vs cross const isIsolated = this.isPerpPositionIsolated(marketPosition); if (isIsolated) { @@ -4452,7 +4525,6 @@ export class User { } } } - return calc; } diff --git a/sdk/src/user_oldMarginCalculation.ts b/sdk/src/user_oldMarginCalculation.ts new file mode 100644 index 0000000000..6a13c75277 --- /dev/null +++ b/sdk/src/user_oldMarginCalculation.ts @@ -0,0 +1,4131 @@ +import { PublicKey } from '@solana/web3.js'; +import { EventEmitter } from 'events'; +import StrictEventEmitter from 'strict-event-emitter-types'; +import { DriftClient } from './driftClient'; +import { + HealthComponent, + HealthComponents, + isVariant, + MarginCategory, + Order, + PerpMarketAccount, + PerpPosition, + SpotPosition, + UserAccount, + UserStatus, + UserStatsAccount, +} from './types'; +import { + calculateEntryPrice, + calculateUnsettledFundingPnl, + positionIsAvailable, +} from './math/position'; +import { + AMM_RESERVE_PRECISION, + AMM_TO_QUOTE_PRECISION_RATIO, + BASE_PRECISION, + BN_MAX, + DUST_POSITION_SIZE, + FIVE_MINUTE, + MARGIN_PRECISION, + OPEN_ORDER_MARGIN_REQUIREMENT, + PRICE_PRECISION, + QUOTE_PRECISION, + QUOTE_PRECISION_EXP, + QUOTE_SPOT_MARKET_INDEX, + SPOT_MARKET_WEIGHT_PRECISION, + TEN_THOUSAND, + TWO, + ZERO, + FUEL_START_TS, +} from './constants/numericConstants'; +import { + DataAndSlot, + UserAccountEvents, + UserAccountSubscriber, +} from './accounts/types'; +import { BigNum } from './factory/bigNum'; +import { BN } from '@coral-xyz/anchor'; +import { calculateBaseAssetValue, calculatePositionPNL } from './math/position'; +import { + calculateMarketMarginRatio, + calculateReservePrice, + calculateUnrealizedAssetWeight, +} from './math/market'; +import { + calculatePerpLiabilityValue, + calculateWorstCasePerpLiabilityValue, +} from './math/margin'; +import { calculateSpotMarketMarginRatio } from './math/spotMarket'; +import { divCeil, sigNum } from './math/utils'; +import { + getBalance, + getSignedTokenAmount, + getStrictTokenValue, + getTokenValue, +} from './math/spotBalance'; +import { getUser30dRollingVolumeEstimate } from './math/trade'; +import { + MarketType, + PositionDirection, + SpotBalanceType, + SpotMarketAccount, +} from './types'; +import { standardizeBaseAssetAmount } from './math/orders'; +import { UserStats } from './userStats'; +import { WebSocketProgramUserAccountSubscriber } from './accounts/websocketProgramUserAccountSubscriber'; +import { + calculateAssetWeight, + calculateLiabilityWeight, + calculateWithdrawLimit, + getSpotAssetValue, + getSpotLiabilityValue, + getTokenAmount, +} from './math/spotBalance'; +import { + calculateBaseAssetValueWithOracle, + calculateCollateralDepositRequiredForTrade, + calculateMarginUSDCRequiredForTrade, + calculateWorstCaseBaseAssetAmount, +} from './math/margin'; +import { MMOraclePriceData, OraclePriceData } from './oracles/types'; +import { UserConfig } from './userConfig'; +import { PollingUserAccountSubscriber } from './accounts/pollingUserAccountSubscriber'; +import { WebSocketUserAccountSubscriber } from './accounts/webSocketUserAccountSubscriber'; +import { + calculateWeightedTokenValue, + getWorstCaseTokenAmounts, + isSpotPositionAvailable, +} from './math/spotPosition'; +import { + calculateLiveOracleTwap, + getMultipleBetweenOracleSources, +} from './math/oracles'; +import { getPerpMarketTierNumber, getSpotMarketTierNumber } from './math/tiers'; +import { StrictOraclePrice } from './oracles/strictOraclePrice'; + +import { calculateSpotFuelBonus, calculatePerpFuelBonus } from './math/fuel'; +import { grpcUserAccountSubscriber } from './accounts/grpcUserAccountSubscriber'; + +export class User { + driftClient: DriftClient; + userAccountPublicKey: PublicKey; + accountSubscriber: UserAccountSubscriber; + _isSubscribed = false; + eventEmitter: StrictEventEmitter; + + public get isSubscribed() { + return this._isSubscribed && this.accountSubscriber.isSubscribed; + } + + public set isSubscribed(val: boolean) { + this._isSubscribed = val; + } + + public constructor(config: UserConfig) { + this.driftClient = config.driftClient; + this.userAccountPublicKey = config.userAccountPublicKey; + if (config.accountSubscription?.type === 'polling') { + this.accountSubscriber = new PollingUserAccountSubscriber( + config.driftClient.connection, + config.userAccountPublicKey, + config.accountSubscription.accountLoader, + this.driftClient.program.account.user.coder.accounts.decodeUnchecked.bind( + this.driftClient.program.account.user.coder.accounts + ) + ); + } else if (config.accountSubscription?.type === 'custom') { + this.accountSubscriber = config.accountSubscription.userAccountSubscriber; + } else if (config.accountSubscription?.type === 'grpc') { + if (config.accountSubscription.grpcMultiUserAccountSubscriber) { + this.accountSubscriber = + config.accountSubscription.grpcMultiUserAccountSubscriber.forUser( + config.userAccountPublicKey + ); + } else { + this.accountSubscriber = new grpcUserAccountSubscriber( + config.accountSubscription.grpcConfigs, + config.driftClient.program, + config.userAccountPublicKey, + { + resubTimeoutMs: config.accountSubscription?.resubTimeoutMs, + logResubMessages: config.accountSubscription?.logResubMessages, + } + ); + } + } else { + if ( + config.accountSubscription?.type === 'websocket' && + config.accountSubscription?.programUserAccountSubscriber + ) { + this.accountSubscriber = new WebSocketProgramUserAccountSubscriber( + config.driftClient.program, + config.userAccountPublicKey, + config.accountSubscription.programUserAccountSubscriber + ); + } else { + this.accountSubscriber = new WebSocketUserAccountSubscriber( + config.driftClient.program, + config.userAccountPublicKey, + { + resubTimeoutMs: config.accountSubscription?.resubTimeoutMs, + logResubMessages: config.accountSubscription?.logResubMessages, + }, + config.accountSubscription?.commitment + ); + } + } + this.eventEmitter = this.accountSubscriber.eventEmitter; + } + + /** + * Subscribe to User state accounts + * @returns SusbcriptionSuccess result + */ + public async subscribe(userAccount?: UserAccount): Promise { + this.isSubscribed = await this.accountSubscriber.subscribe(userAccount); + return this.isSubscribed; + } + + /** + * Forces the accountSubscriber to fetch account updates from rpc + */ + public async fetchAccounts(): Promise { + await this.accountSubscriber.fetch(); + } + + public async unsubscribe(): Promise { + await this.accountSubscriber.unsubscribe(); + this.isSubscribed = false; + } + + public getUserAccount(): UserAccount { + return this.accountSubscriber.getUserAccountAndSlot().data; + } + + public async forceGetUserAccount(): Promise { + await this.fetchAccounts(); + return this.accountSubscriber.getUserAccountAndSlot().data; + } + + public getUserAccountAndSlot(): DataAndSlot | undefined { + return this.accountSubscriber.getUserAccountAndSlot(); + } + + public getPerpPositionForUserAccount( + userAccount: UserAccount, + marketIndex: number + ): PerpPosition | undefined { + return this.getActivePerpPositionsForUserAccount(userAccount).find( + (position) => position.marketIndex === marketIndex + ); + } + + /** + * Gets the user's current position for a given perp market. If the user has no position returns undefined + * @param marketIndex + * @returns userPerpPosition + */ + public getPerpPosition(marketIndex: number): PerpPosition | undefined { + const userAccount = this.getUserAccount(); + return this.getPerpPositionForUserAccount(userAccount, marketIndex); + } + + public getPerpPositionOrEmpty(marketIndex: number): PerpPosition { + const userAccount = this.getUserAccount(); + return ( + this.getPerpPositionForUserAccount(userAccount, marketIndex) ?? + this.getEmptyPosition(marketIndex) + ); + } + + public getPerpPositionAndSlot( + marketIndex: number + ): DataAndSlot { + const userAccount = this.getUserAccountAndSlot(); + const perpPosition = this.getPerpPositionForUserAccount( + userAccount.data, + marketIndex + ); + return { + data: perpPosition, + slot: userAccount.slot, + }; + } + + public getSpotPositionForUserAccount( + userAccount: UserAccount, + marketIndex: number + ): SpotPosition | undefined { + return userAccount.spotPositions.find( + (position) => position.marketIndex === marketIndex + ); + } + + /** + * Gets the user's current position for a given spot market. If the user has no position returns undefined + * @param marketIndex + * @returns userSpotPosition + */ + public getSpotPosition(marketIndex: number): SpotPosition | undefined { + const userAccount = this.getUserAccount(); + return this.getSpotPositionForUserAccount(userAccount, marketIndex); + } + + public getSpotPositionAndSlot( + marketIndex: number + ): DataAndSlot { + const userAccount = this.getUserAccountAndSlot(); + const spotPosition = this.getSpotPositionForUserAccount( + userAccount.data, + marketIndex + ); + return { + data: spotPosition, + slot: userAccount.slot, + }; + } + + getEmptySpotPosition(marketIndex: number): SpotPosition { + return { + marketIndex, + scaledBalance: ZERO, + balanceType: SpotBalanceType.DEPOSIT, + cumulativeDeposits: ZERO, + openAsks: ZERO, + openBids: ZERO, + openOrders: 0, + }; + } + + /** + * Returns the token amount for a given market. The spot market precision is based on the token mint decimals. + * Positive if it is a deposit, negative if it is a borrow. + * + * @param marketIndex + */ + public getTokenAmount(marketIndex: number): BN { + const spotPosition = this.getSpotPosition(marketIndex); + if (spotPosition === undefined) { + return ZERO; + } + const spotMarket = this.driftClient.getSpotMarketAccount(marketIndex); + return getSignedTokenAmount( + getTokenAmount( + spotPosition.scaledBalance, + spotMarket, + spotPosition.balanceType + ), + spotPosition.balanceType + ); + } + + public getEmptyPosition(marketIndex: number): PerpPosition { + return { + baseAssetAmount: ZERO, + remainderBaseAssetAmount: 0, + lastCumulativeFundingRate: ZERO, + marketIndex, + quoteAssetAmount: ZERO, + quoteEntryAmount: ZERO, + quoteBreakEvenAmount: ZERO, + openOrders: 0, + openBids: ZERO, + openAsks: ZERO, + settledPnl: ZERO, + lpShares: ZERO, + lastBaseAssetAmountPerLp: ZERO, + lastQuoteAssetAmountPerLp: ZERO, + perLpBase: 0, + maxMarginRatio: 0, + isolatedPositionScaledBalance: ZERO, + positionFlag: 0, + }; + } + + public getClonedPosition(position: PerpPosition): PerpPosition { + const clonedPosition = Object.assign({}, position); + return clonedPosition; + } + + public getOrderForUserAccount( + userAccount: UserAccount, + orderId: number + ): Order | undefined { + return userAccount.orders.find((order) => order.orderId === orderId); + } + + /** + * @param orderId + * @returns Order + */ + public getOrder(orderId: number): Order | undefined { + const userAccount = this.getUserAccount(); + return this.getOrderForUserAccount(userAccount, orderId); + } + + public getOrderAndSlot(orderId: number): DataAndSlot { + const userAccount = this.getUserAccountAndSlot(); + const order = this.getOrderForUserAccount(userAccount.data, orderId); + return { + data: order, + slot: userAccount.slot, + }; + } + + public getOrderByUserIdForUserAccount( + userAccount: UserAccount, + userOrderId: number + ): Order | undefined { + return userAccount.orders.find( + (order) => order.userOrderId === userOrderId + ); + } + + /** + * @param userOrderId + * @returns Order + */ + public getOrderByUserOrderId(userOrderId: number): Order | undefined { + const userAccount = this.getUserAccount(); + return this.getOrderByUserIdForUserAccount(userAccount, userOrderId); + } + + public getOrderByUserOrderIdAndSlot( + userOrderId: number + ): DataAndSlot { + const userAccount = this.getUserAccountAndSlot(); + const order = this.getOrderByUserIdForUserAccount( + userAccount.data, + userOrderId + ); + return { + data: order, + slot: userAccount.slot, + }; + } + + public getOpenOrdersForUserAccount(userAccount?: UserAccount): Order[] { + return userAccount?.orders.filter((order) => + isVariant(order.status, 'open') + ); + } + + public getOpenOrders(): Order[] { + const userAccount = this.getUserAccount(); + return this.getOpenOrdersForUserAccount(userAccount); + } + + public getOpenOrdersAndSlot(): DataAndSlot { + const userAccount = this.getUserAccountAndSlot(); + const openOrders = this.getOpenOrdersForUserAccount(userAccount.data); + return { + data: openOrders, + slot: userAccount.slot, + }; + } + + public getUserAccountPublicKey(): PublicKey { + return this.userAccountPublicKey; + } + + public async exists(): Promise { + const userAccountRPCResponse = + await this.driftClient.connection.getParsedAccountInfo( + this.userAccountPublicKey + ); + return userAccountRPCResponse.value !== null; + } + + /** + * calculates the total open bids/asks in a perp market (including lps) + * @returns : open bids + * @returns : open asks + */ + public getPerpBidAsks(marketIndex: number): [BN, BN] { + const position = this.getPerpPosition(marketIndex); + + const totalOpenBids = position.openBids; + const totalOpenAsks = position.openAsks; + + return [totalOpenBids, totalOpenAsks]; + } + + /** + * calculates Buying Power = free collateral / initial margin ratio + * @returns : Precision QUOTE_PRECISION + */ + public getPerpBuyingPower( + marketIndex: number, + collateralBuffer = ZERO, + enterHighLeverageMode = undefined, + maxMarginRatio = undefined + ): BN { + const perpPosition = this.getPerpPositionOrEmpty(marketIndex); + + const perpMarket = this.driftClient.getPerpMarketAccount(marketIndex); + const oraclePriceData = this.getOracleDataForPerpMarket(marketIndex); + const worstCaseBaseAssetAmount = perpPosition + ? calculateWorstCaseBaseAssetAmount( + perpPosition, + perpMarket, + oraclePriceData.price + ) + : ZERO; + + const freeCollateral = this.getFreeCollateral( + 'Initial', + enterHighLeverageMode + ).sub(collateralBuffer); + + return this.getPerpBuyingPowerFromFreeCollateralAndBaseAssetAmount( + marketIndex, + freeCollateral, + worstCaseBaseAssetAmount, + enterHighLeverageMode, + maxMarginRatio || perpPosition.maxMarginRatio + ); + } + + getPerpBuyingPowerFromFreeCollateralAndBaseAssetAmount( + marketIndex: number, + freeCollateral: BN, + baseAssetAmount: BN, + enterHighLeverageMode = undefined, + perpMarketMaxMarginRatio = undefined + ): BN { + const maxMarginRatio = Math.max( + perpMarketMaxMarginRatio, + this.getUserAccount().maxMarginRatio + ); + const marginRatio = calculateMarketMarginRatio( + this.driftClient.getPerpMarketAccount(marketIndex), + baseAssetAmount, + 'Initial', + maxMarginRatio, + enterHighLeverageMode || this.isHighLeverageMode('Initial') + ); + + return freeCollateral.mul(MARGIN_PRECISION).div(new BN(marginRatio)); + } + + /** + * calculates Free Collateral = Total collateral - margin requirement + * @returns : Precision QUOTE_PRECISION + */ + public getFreeCollateral( + marginCategory: MarginCategory = 'Initial', + enterHighLeverageMode = undefined + ): BN { + const totalCollateral = this.getTotalCollateral(marginCategory, true); + const marginRequirement = + marginCategory === 'Initial' + ? this.getInitialMarginRequirement(enterHighLeverageMode) + : this.getMaintenanceMarginRequirement(); + const freeCollateral = totalCollateral.sub(marginRequirement); + return freeCollateral.gte(ZERO) ? freeCollateral : ZERO; + } + + /** + * @returns The margin requirement of a certain type (Initial or Maintenance) in USDC. : QUOTE_PRECISION + */ + public getMarginRequirement( + marginCategory: MarginCategory, + liquidationBuffer?: BN, + strict = false, + includeOpenOrders = true, + enteringHighLeverage = undefined + ): BN { + const perpPositionLiabilityValue = this.getTotalPerpPositionLiability( + marginCategory, + liquidationBuffer, + includeOpenOrders, + strict, + enteringHighLeverage + ); + const spotMarketLiabilityValue = this.getSpotMarketLiabilityValue( + undefined, + marginCategory, + liquidationBuffer, + includeOpenOrders, + strict + ); + const totalMarginRequirement = perpPositionLiabilityValue.add( + spotMarketLiabilityValue + ); + return totalMarginRequirement; + } + + /** + * @returns The initial margin requirement in USDC. : QUOTE_PRECISION + */ + public getInitialMarginRequirement(enterHighLeverageMode = undefined): BN { + return this.getMarginRequirement( + 'Initial', + undefined, + true, + undefined, + enterHighLeverageMode + ); + } + + /** + * @returns The maintenance margin requirement in USDC. : QUOTE_PRECISION + */ + public getMaintenanceMarginRequirement(liquidationBuffer?: BN): BN { + return this.getMarginRequirement('Maintenance', liquidationBuffer); + } + + public getActivePerpPositionsForUserAccount( + userAccount: UserAccount + ): PerpPosition[] { + return userAccount.perpPositions.filter( + (pos) => + !pos.baseAssetAmount.eq(ZERO) || + !pos.quoteAssetAmount.eq(ZERO) || + !(pos.openOrders == 0) + ); + } + + public getActivePerpPositions(): PerpPosition[] { + const userAccount = this.getUserAccount(); + return this.getActivePerpPositionsForUserAccount(userAccount); + } + public getActivePerpPositionsAndSlot(): DataAndSlot { + const userAccount = this.getUserAccountAndSlot(); + const positions = this.getActivePerpPositionsForUserAccount( + userAccount.data + ); + return { + data: positions, + slot: userAccount.slot, + }; + } + + public getActiveSpotPositionsForUserAccount( + userAccount: UserAccount + ): SpotPosition[] { + return userAccount.spotPositions.filter( + (pos) => !isSpotPositionAvailable(pos) + ); + } + + public getActiveSpotPositions(): SpotPosition[] { + const userAccount = this.getUserAccount(); + return this.getActiveSpotPositionsForUserAccount(userAccount); + } + public getActiveSpotPositionsAndSlot(): DataAndSlot { + const userAccount = this.getUserAccountAndSlot(); + const positions = this.getActiveSpotPositionsForUserAccount( + userAccount.data + ); + return { + data: positions, + slot: userAccount.slot, + }; + } + + /** + * calculates unrealized position price pnl + * @returns : Precision QUOTE_PRECISION + */ + public getUnrealizedPNL( + withFunding?: boolean, + marketIndex?: number, + withWeightMarginCategory?: MarginCategory, + strict = false, + liquidationBuffer?: BN + ): BN { + return this.getActivePerpPositions() + .filter((pos) => + marketIndex !== undefined ? pos.marketIndex === marketIndex : true + ) + .reduce((unrealizedPnl, perpPosition) => { + const market = this.driftClient.getPerpMarketAccount( + perpPosition.marketIndex + ); + const oraclePriceData = this.getMMOracleDataForPerpMarket( + market.marketIndex + ); + + const quoteSpotMarket = this.driftClient.getSpotMarketAccount( + market.quoteSpotMarketIndex + ); + const quoteOraclePriceData = this.getOracleDataForSpotMarket( + market.quoteSpotMarketIndex + ); + + // console.log( + // 'old user oralcePriceData for unrealized pnl', + // oraclePriceData.price.toString() + // ); + + let positionUnrealizedPnl = calculatePositionPNL( + market, + perpPosition, + withFunding, + oraclePriceData + ); + + // console.log( + // 'old user positionUnrealizedPnl before pnlQuotePrice', + // positionUnrealizedPnl.toString() + // ); + // console.log( + // 'old user quoteOraclePriceData.price', + // quoteOraclePriceData.price.toString() + // ); + // console.log( + // 'old user quoteSpotMarket.historicalOracleData.lastOraclePriceTwap5Min', + // quoteSpotMarket.historicalOracleData.lastOraclePriceTwap5Min.toString() + // ); + + let quotePrice; + if (strict && positionUnrealizedPnl.gt(ZERO)) { + quotePrice = BN.min( + quoteOraclePriceData.price, + quoteSpotMarket.historicalOracleData.lastOraclePriceTwap5Min + ); + } else if (strict && positionUnrealizedPnl.lt(ZERO)) { + quotePrice = BN.max( + quoteOraclePriceData.price, + quoteSpotMarket.historicalOracleData.lastOraclePriceTwap5Min + ); + } else { + quotePrice = quoteOraclePriceData.price; + } + + positionUnrealizedPnl = positionUnrealizedPnl + .mul(quotePrice) + .div(PRICE_PRECISION); + + // console.log( + // 'old user positionUnrealizedPnl after pnlQuotePrice', + // positionUnrealizedPnl.toString() + // ); + + if (withWeightMarginCategory !== undefined) { + if (positionUnrealizedPnl.gt(ZERO)) { + positionUnrealizedPnl = positionUnrealizedPnl + .mul( + calculateUnrealizedAssetWeight( + market, + quoteSpotMarket, + positionUnrealizedPnl, + withWeightMarginCategory, + oraclePriceData + ) + ) + .div(new BN(SPOT_MARKET_WEIGHT_PRECISION)); + } + + if (liquidationBuffer && positionUnrealizedPnl.lt(ZERO)) { + positionUnrealizedPnl = positionUnrealizedPnl.add( + positionUnrealizedPnl.mul(liquidationBuffer).div(MARGIN_PRECISION) + ); + // console.log( + // 'old user positionUnrealizedPnl post liquidationBuffer', + // positionUnrealizedPnl.toString() + // ); + } + } + + // console.log( + // `old user positionUnrealizedPnl [${perpPosition.marketIndex}]`, + // positionUnrealizedPnl.toString() + // ); + + return unrealizedPnl.add(positionUnrealizedPnl); + }, ZERO); + } + + /** + * calculates unrealized funding payment pnl + * @returns : Precision QUOTE_PRECISION + */ + public getUnrealizedFundingPNL(marketIndex?: number): BN { + return this.getUserAccount() + .perpPositions.filter((pos) => + marketIndex !== undefined ? pos.marketIndex === marketIndex : true + ) + .reduce((pnl, perpPosition) => { + const market = this.driftClient.getPerpMarketAccount( + perpPosition.marketIndex + ); + return pnl.add(calculateUnsettledFundingPnl(market, perpPosition)); + }, ZERO); + } + + public getFuelBonus( + now: BN, + includeSettled = true, + includeUnsettled = true, + givenUserStats?: UserStats + ): { + depositFuel: BN; + borrowFuel: BN; + positionFuel: BN; + takerFuel: BN; + makerFuel: BN; + insuranceFuel: BN; + } { + const userAccount: UserAccount = this.getUserAccount(); + + const result = { + insuranceFuel: ZERO, + takerFuel: ZERO, + makerFuel: ZERO, + depositFuel: ZERO, + borrowFuel: ZERO, + positionFuel: ZERO, + }; + + const userStats = givenUserStats ?? this.driftClient.getUserStats(); + const userStatsAccount: UserStatsAccount = userStats.getAccount(); + + if (includeSettled) { + result.takerFuel = result.takerFuel.add( + new BN(userStatsAccount.fuelTaker) + ); + result.makerFuel = result.makerFuel.add( + new BN(userStatsAccount.fuelMaker) + ); + result.depositFuel = result.depositFuel.add( + new BN(userStatsAccount.fuelDeposits) + ); + result.borrowFuel = result.borrowFuel.add( + new BN(userStatsAccount.fuelBorrows) + ); + result.positionFuel = result.positionFuel.add( + new BN(userStatsAccount.fuelPositions) + ); + } + + if (includeUnsettled) { + const fuelBonusNumerator = BN.max( + now.sub( + BN.max(new BN(userAccount.lastFuelBonusUpdateTs), FUEL_START_TS) + ), + ZERO + ); + + if (fuelBonusNumerator.gt(ZERO)) { + for (const spotPosition of this.getActiveSpotPositions()) { + const spotMarketAccount: SpotMarketAccount = + this.driftClient.getSpotMarketAccount(spotPosition.marketIndex); + + const tokenAmount = this.getTokenAmount(spotPosition.marketIndex); + const oraclePriceData = this.getOracleDataForSpotMarket( + spotPosition.marketIndex + ); + + const twap5min = calculateLiveOracleTwap( + spotMarketAccount.historicalOracleData, + oraclePriceData, + now, + FIVE_MINUTE // 5MIN + ); + const strictOraclePrice = new StrictOraclePrice( + oraclePriceData.price, + twap5min + ); + + const signedTokenValue = getStrictTokenValue( + tokenAmount, + spotMarketAccount.decimals, + strictOraclePrice + ); + + if (signedTokenValue.gt(ZERO)) { + result.depositFuel = result.depositFuel.add( + calculateSpotFuelBonus( + spotMarketAccount, + signedTokenValue, + fuelBonusNumerator + ) + ); + } else { + result.borrowFuel = result.borrowFuel.add( + calculateSpotFuelBonus( + spotMarketAccount, + signedTokenValue, + fuelBonusNumerator + ) + ); + } + } + + for (const perpPosition of this.getActivePerpPositions()) { + const oraclePriceData = this.getMMOracleDataForPerpMarket( + perpPosition.marketIndex + ); + + const perpMarketAccount = this.driftClient.getPerpMarketAccount( + perpPosition.marketIndex + ); + + const baseAssetValue = this.getPerpPositionValue( + perpPosition.marketIndex, + oraclePriceData, + false + ); + + result.positionFuel = result.positionFuel.add( + calculatePerpFuelBonus( + perpMarketAccount, + baseAssetValue, + fuelBonusNumerator + ) + ); + } + } + } + + result.insuranceFuel = userStats.getInsuranceFuelBonus( + now, + includeSettled, + includeUnsettled + ); + + return result; + } + + public getSpotMarketAssetAndLiabilityValue( + marketIndex?: number, + marginCategory?: MarginCategory, + liquidationBuffer?: BN, + includeOpenOrders?: boolean, + strict = false, + now?: BN + ): { totalAssetValue: BN; totalLiabilityValue: BN } { + // console.log( + // 'line 923 old user getSpotMarketAssetAndLiabilityValue strict?', + // strict + // ); + now = now || new BN(new Date().getTime() / 1000); + let netQuoteValue = ZERO; + let totalAssetValue = ZERO; + let totalLiabilityValue = ZERO; + for (const spotPosition of this.getUserAccount().spotPositions) { + const countForBase = + marketIndex === undefined || spotPosition.marketIndex === marketIndex; + + const countForQuote = + marketIndex === undefined || + marketIndex === QUOTE_SPOT_MARKET_INDEX || + (includeOpenOrders && spotPosition.openOrders !== 0); + + // console.log(' line936 countForBase', countForBase); + // console.log(' line937 countForQuote', countForQuote); + if ( + isSpotPositionAvailable(spotPosition) || + (!countForBase && !countForQuote) + ) { + // console.log( + // ` line945 old user SKIPPING spotPosition marketIndex: ${spotPosition.marketIndex}`, + // spotPosition.marketIndex + // ); + continue; + } + + const spotMarketAccount: SpotMarketAccount = + this.driftClient.getSpotMarketAccount(spotPosition.marketIndex); + + const oraclePriceData = this.getOracleDataForSpotMarket( + spotPosition.marketIndex + ); + + let twap5min; + if (strict) { + twap5min = calculateLiveOracleTwap( + spotMarketAccount.historicalOracleData, + oraclePriceData, + now, + FIVE_MINUTE // 5MIN + ); + } + // console.log('line 960 old user twap5min', twap5min?.toString()); + const strictOraclePrice = new StrictOraclePrice( + oraclePriceData.price, + twap5min + ); + + // console.log( + // `line 966 old user strict oracle, marketIndex: ${spotPosition.marketIndex}`, + // strictOraclePrice.current.toString() + // ); + // console.log( + // `line 978 old user strict oracle JSON, marketIndex: ${spotPosition.marketIndex}`, + // JSON.stringify(strictOraclePrice) + // ); + + if ( + spotPosition.marketIndex === QUOTE_SPOT_MARKET_INDEX && + countForQuote + ) { + const tokenAmount = getSignedTokenAmount( + getTokenAmount( + spotPosition.scaledBalance, + spotMarketAccount, + spotPosition.balanceType + ), + spotPosition.balanceType + ); + + // console.log( + // ' line984 old user spotPosition.balanceType', + // JSON.stringify(spotPosition.balanceType) + // ); + // console.log(' line988 old user tokenAmount', tokenAmount.toString()); + + if (isVariant(spotPosition.balanceType, 'borrow')) { + const weightedTokenValue = this.getSpotLiabilityValue( + tokenAmount, + strictOraclePrice, + spotMarketAccount, + marginCategory, + liquidationBuffer + ).abs(); + + // console.log( + // ` line 1003 old user weightedTokenValue on borrow marketIndex: ${spotPosition.marketIndex}`, + // weightedTokenValue.toString() + // ); + + netQuoteValue = netQuoteValue.sub(weightedTokenValue); + } else { + const weightedTokenValue = this.getSpotAssetValue( + tokenAmount, + strictOraclePrice, + spotMarketAccount, + marginCategory + ); + + // console.log( + // ' line1007 old user weightedTokenValue', + // weightedTokenValue.toString() + // ); + + netQuoteValue = netQuoteValue.add(weightedTokenValue); + } + + continue; + } + + if (!includeOpenOrders && countForBase) { + if (isVariant(spotPosition.balanceType, 'borrow')) { + const tokenAmount = getSignedTokenAmount( + getTokenAmount( + spotPosition.scaledBalance, + spotMarketAccount, + spotPosition.balanceType + ), + SpotBalanceType.BORROW + ); + const liabilityValue = this.getSpotLiabilityValue( + tokenAmount, + strictOraclePrice, + spotMarketAccount, + marginCategory, + liquidationBuffer + ).abs(); + + // console.log( + // `line 1033 old user adding to totalLiabilityValue, marketIndex: ${spotPosition.marketIndex}`, + // liabilityValue.toString() + // ); + totalLiabilityValue = totalLiabilityValue.add(liabilityValue); + + continue; + } else { + const tokenAmount = getTokenAmount( + spotPosition.scaledBalance, + spotMarketAccount, + spotPosition.balanceType + ); + const assetValue = this.getSpotAssetValue( + tokenAmount, + strictOraclePrice, + spotMarketAccount, + marginCategory + ); + // console.log( + // ` line1038 old user assetValue, marketIndex: ${spotPosition.marketIndex}`, + // assetValue.toString() + // ); + totalAssetValue = totalAssetValue.add(assetValue); + + continue; + } + } + + const { + tokenAmount: worstCaseTokenAmount, + ordersValue: worstCaseQuoteTokenAmount, + } = getWorstCaseTokenAmounts( + spotPosition, + spotMarketAccount, + strictOraclePrice, + marginCategory, + this.getUserAccount().maxMarginRatio + ); + + // console.log( + // ` line1059 old user worstCaseTokenAmount, marketIndex: ${spotPosition.marketIndex}`, + // worstCaseTokenAmount.toString() + // ); + // console.log( + // ` line1060 old user worstCaseQuoteTokenAmount, marketIndex: ${spotPosition.marketIndex}`, + // worstCaseQuoteTokenAmount.toString() + // ); + + if (worstCaseTokenAmount.gt(ZERO) && countForBase) { + // console.log( + // `line 1087 old user spotMarketAccount decimals, marketIndex: ${spotPosition.marketIndex}`, + // spotMarketAccount.decimals.toString() + // ); + // console.log( + // `line 1088 old user spotMarketAccount imfFactor, marketIndex: ${spotPosition.marketIndex}`, + // spotMarketAccount.imfFactor.toString() + // ); + + const baseAssetValue = this.getSpotAssetValue( + worstCaseTokenAmount, + strictOraclePrice, + spotMarketAccount, + marginCategory + ); + // console.log( + // ` line1085 old user asset value, marketIndex: ${spotPosition.marketIndex}`, + // baseAssetValue.toString() + // ); + totalAssetValue = totalAssetValue.add(baseAssetValue); + // console.log( + // 'old user totalAssetValue post add base', + // totalAssetValue.toString() + // ); + } + + if (worstCaseTokenAmount.lt(ZERO) && countForBase) { + // if (spotPosition.marketIndex === 1) { + // console.log( + // `line 1133 old user worstCaseTokenAmount, marketIndex: ${spotPosition.marketIndex}`, + // worstCaseTokenAmount.toString() + // ); + // console.log( + // `line 1134 old user strictOracle, marketIndex: ${spotPosition.marketIndex}`, + // strictOraclePrice.current.toString() + // ); + // console.log( + // `line 1135 old user spotMarket, marketIndex: ${spotPosition.marketIndex}`, + // spotMarketAccount.toString() + // ); + // console.log( + // `line 1136 old user marginCategory, marketIndex: ${spotPosition.marketIndex}`, + // marginCategory + // ); + // console.log( + // `line 1137 old user liquidationBuffer, marketIndex: ${spotPosition.marketIndex}`, + // liquidationBuffer?.toString() + // ); + // } + + const baseLiabilityValue = this.getSpotLiabilityValue( + worstCaseTokenAmount, + strictOraclePrice, + spotMarketAccount, + marginCategory, + liquidationBuffer + ).abs(); + + // console.log( + // `line 1109 old user adding to totalLiabilityValue, marketIndex: ${spotPosition.marketIndex}`, + // baseLiabilityValue.toString() + // ); + + totalLiabilityValue = totalLiabilityValue.add(baseLiabilityValue); + } + + if (worstCaseQuoteTokenAmount.gt(ZERO) && countForQuote) { + // console.log( + // ' line1108 old user worstCaseQuoteTokenAmount', + // worstCaseQuoteTokenAmount.toString() + // ); + netQuoteValue = netQuoteValue.add(worstCaseQuoteTokenAmount); + } + + if (worstCaseQuoteTokenAmount.lt(ZERO) && countForQuote) { + let weight = SPOT_MARKET_WEIGHT_PRECISION; + if (marginCategory === 'Initial') { + weight = BN.max(weight, new BN(this.getUserAccount().maxMarginRatio)); + } + + const weightedTokenValue = worstCaseQuoteTokenAmount + .abs() + .mul(weight) + .div(SPOT_MARKET_WEIGHT_PRECISION); + + // console.log( + // `line1126 old user weightedTokenValue ${spotPosition.marketIndex}`, + // weightedTokenValue.toString() + // ); + netQuoteValue = netQuoteValue.sub(weightedTokenValue); + } + + // console.log( + // ` line1165 old user open orders marketIndex: ${spotPosition.marketIndex}`, + // new BN(spotPosition.openOrders) + // .mul(OPEN_ORDER_MARGIN_REQUIREMENT) + // .toString() + // ); + + totalLiabilityValue = totalLiabilityValue.add( + new BN(spotPosition.openOrders).mul(OPEN_ORDER_MARGIN_REQUIREMENT) + ); + } + + // console.log( + // ' line1116 old user totalAssetValue', + // totalAssetValue.toString() + // ); + + // console.log(' line1121 old user netQuoteValue', netQuoteValue.toString()); + + if (marketIndex === undefined || marketIndex === QUOTE_SPOT_MARKET_INDEX) { + if (netQuoteValue.gt(ZERO)) { + // console.log( + // 'old user netQuoteValue post add', + // netQuoteValue.toString() + // ); + totalAssetValue = totalAssetValue.add(netQuoteValue); + // console.log( + // 'old user totalAssetValue post add netQuoteValue', + // totalAssetValue.toString() + // ); + } else { + // console.log( + // 'old user adding to totalLiabilityValue', + // netQuoteValue.abs().toString() + // ); + totalLiabilityValue = totalLiabilityValue.add(netQuoteValue.abs()); + } + } + + // console.log( + // ' line1138 old user totalAssetValue', + // totalAssetValue.toString() + // ); + // console.log( + // ' line1170 old user totalLiabilityValue after spot positions looping', + // totalLiabilityValue.toString() + // ); + // console.log( + // 'line 1183 old user getSpotMarketAssetAndLiabilityValue strict?', + // strict + // ); + // console.log( + // 'line 1184 old user getSpotMarketAssetAndLiabilityValue includeOpenOrders?', + // includeOpenOrders + // ); + + return { totalAssetValue, totalLiabilityValue }; + } + + public getSpotMarketLiabilityValue( + marketIndex?: number, + marginCategory?: MarginCategory, + liquidationBuffer?: BN, + includeOpenOrders?: boolean, + strict = false, + now?: BN + ): BN { + // console.log( + // 'line 1220 old user getSpotMarketLiabilityValue strict?', + // strict + // ); + const { totalLiabilityValue } = this.getSpotMarketAssetAndLiabilityValue( + marketIndex, + marginCategory, + liquidationBuffer, + includeOpenOrders, + strict, + now + ); + return totalLiabilityValue; + } + + getSpotLiabilityValue( + tokenAmount: BN, + strictOraclePrice: StrictOraclePrice, + spotMarketAccount: SpotMarketAccount, + marginCategory?: MarginCategory, + liquidationBuffer?: BN + ): BN { + return getSpotLiabilityValue( + tokenAmount, + strictOraclePrice, + spotMarketAccount, + this.getUserAccount().maxMarginRatio, + marginCategory, + liquidationBuffer + ); + } + + public getSpotMarketAssetValue( + marketIndex?: number, + marginCategory?: MarginCategory, + includeOpenOrders?: boolean, + strict = false, + now?: BN + ): BN { + const { totalAssetValue } = this.getSpotMarketAssetAndLiabilityValue( + marketIndex, + marginCategory, + undefined, + includeOpenOrders, + strict, + now + ); + return totalAssetValue; + } + + getSpotAssetValue( + tokenAmount: BN, + strictOraclePrice: StrictOraclePrice, + spotMarketAccount: SpotMarketAccount, + marginCategory?: MarginCategory + ): BN { + return getSpotAssetValue( + tokenAmount, + strictOraclePrice, + spotMarketAccount, + this.getUserAccount().maxMarginRatio, + marginCategory + ); + } + + public getSpotPositionValue( + marketIndex: number, + marginCategory?: MarginCategory, + includeOpenOrders?: boolean, + strict = false, + now?: BN + ): BN { + const { totalAssetValue, totalLiabilityValue } = + this.getSpotMarketAssetAndLiabilityValue( + marketIndex, + marginCategory, + undefined, + includeOpenOrders, + strict, + now + ); + + return totalAssetValue.sub(totalLiabilityValue); + } + + public getNetSpotMarketValue(withWeightMarginCategory?: MarginCategory): BN { + const { totalAssetValue, totalLiabilityValue } = + this.getSpotMarketAssetAndLiabilityValue( + undefined, + withWeightMarginCategory + ); + + return totalAssetValue.sub(totalLiabilityValue); + } + + /** + * calculates TotalCollateral: collateral + unrealized pnl + * @returns : Precision QUOTE_PRECISION + */ + public getTotalCollateral( + marginCategory: MarginCategory = 'Initial', + strict = false, + includeOpenOrders = true, + liquidationBuffer?: BN + ): BN { + const unrealizedPNL = this.getUnrealizedPNL( + true, + undefined, + marginCategory, + strict, + liquidationBuffer + ); + const spotMarketAssetValue = this.getSpotMarketAssetValue( + undefined, + marginCategory, + includeOpenOrders, + strict + ); + return spotMarketAssetValue.add(unrealizedPNL); + } + + public getLiquidationBuffer(): BN | undefined { + // if user being liq'd, can continue to be liq'd until total collateral above the margin requirement plus buffer + let liquidationBuffer = undefined; + if (this.isBeingLiquidated()) { + liquidationBuffer = new BN( + this.driftClient.getStateAccount().liquidationMarginBufferRatio + ); + } + return liquidationBuffer; + } + + /** + * calculates User Health by comparing total collateral and maint. margin requirement + * @returns : number (value from [0, 100]) + */ + public getHealth(): number { + if (this.isBeingLiquidated()) { + return 0; + } + + const totalCollateral = this.getTotalCollateral('Maintenance'); + const maintenanceMarginReq = this.getMaintenanceMarginRequirement(); + + let health: number; + + if (maintenanceMarginReq.eq(ZERO) && totalCollateral.gte(ZERO)) { + health = 100; + } else if (totalCollateral.lte(ZERO)) { + health = 0; + } else { + health = Math.round( + Math.min( + 100, + Math.max( + 0, + (1 - maintenanceMarginReq.toNumber() / totalCollateral.toNumber()) * + 100 + ) + ) + ); + } + + return health; + } + + calculateWeightedPerpPositionLiability( + perpPosition: PerpPosition, + marginCategory?: MarginCategory, + liquidationBuffer?: BN, + includeOpenOrders?: boolean, + strict = false, + enteringHighLeverage = undefined + ): BN { + const market = this.driftClient.getPerpMarketAccount( + perpPosition.marketIndex + ); + + let valuationPrice = this.getOracleDataForPerpMarket( + market.marketIndex + ).price; + + if (isVariant(market.status, 'settlement')) { + valuationPrice = market.expiryPrice; + } + + let baseAssetAmount: BN; + let liabilityValue; + if (includeOpenOrders) { + const { worstCaseBaseAssetAmount, worstCaseLiabilityValue } = + calculateWorstCasePerpLiabilityValue( + perpPosition, + market, + valuationPrice + ); + baseAssetAmount = worstCaseBaseAssetAmount; + liabilityValue = worstCaseLiabilityValue; + } else { + baseAssetAmount = perpPosition.baseAssetAmount; + liabilityValue = calculatePerpLiabilityValue( + baseAssetAmount, + valuationPrice, + isVariant(market.contractType, 'prediction') + ); + } + + if (marginCategory) { + const userCustomMargin = Math.max( + perpPosition.maxMarginRatio, + this.getUserAccount().maxMarginRatio + ); + let marginRatio = new BN( + calculateMarketMarginRatio( + market, + baseAssetAmount.abs(), + marginCategory, + enteringHighLeverage === false + ? Math.max(market.marginRatioInitial, userCustomMargin) + : userCustomMargin, + this.isHighLeverageMode(marginCategory) || + enteringHighLeverage === true + ) + ); + + if (liquidationBuffer !== undefined) { + marginRatio = marginRatio.add(liquidationBuffer); + } + + if (isVariant(market.status, 'settlement')) { + marginRatio = ZERO; + } + + const quoteSpotMarket = this.driftClient.getSpotMarketAccount( + market.quoteSpotMarketIndex + ); + const quoteOraclePriceData = this.driftClient.getOracleDataForSpotMarket( + QUOTE_SPOT_MARKET_INDEX + ); + + let quotePrice; + if (strict) { + quotePrice = BN.max( + quoteOraclePriceData.price, + quoteSpotMarket.historicalOracleData.lastOraclePriceTwap5Min + ); + } else { + quotePrice = quoteOraclePriceData.price; + } + + liabilityValue = liabilityValue + .mul(quotePrice) + .div(PRICE_PRECISION) + .mul(marginRatio) + .div(MARGIN_PRECISION); + + if (includeOpenOrders) { + liabilityValue = liabilityValue.add( + new BN(perpPosition.openOrders).mul(OPEN_ORDER_MARGIN_REQUIREMENT) + ); + } + } + + return liabilityValue; + } + + /** + * calculates position value of a single perp market in margin system + * @returns : Precision QUOTE_PRECISION + */ + public getPerpMarketLiabilityValue( + marketIndex: number, + marginCategory?: MarginCategory, + liquidationBuffer?: BN, + includeOpenOrders?: boolean, + strict = false + ): BN { + const perpPosition = this.getPerpPosition(marketIndex); + return this.calculateWeightedPerpPositionLiability( + perpPosition, + marginCategory, + liquidationBuffer, + includeOpenOrders, + strict + ); + } + + /** + * calculates sum of position value across all positions in margin system + * @returns : Precision QUOTE_PRECISION + */ + getTotalPerpPositionLiability( + marginCategory?: MarginCategory, + liquidationBuffer?: BN, + includeOpenOrders?: boolean, + strict = false, + enteringHighLeverage = undefined + ): BN { + return this.getActivePerpPositions().reduce( + (totalPerpValue, perpPosition) => { + const baseAssetValue = this.calculateWeightedPerpPositionLiability( + perpPosition, + marginCategory, + liquidationBuffer, + includeOpenOrders, + strict, + enteringHighLeverage + ); + return totalPerpValue.add(baseAssetValue); + }, + ZERO + ); + } + + /** + * calculates position value based on oracle + * @returns : Precision QUOTE_PRECISION + */ + public getPerpPositionValue( + marketIndex: number, + oraclePriceData: Pick, + includeOpenOrders = false + ): BN { + const userPosition = this.getPerpPositionOrEmpty(marketIndex); + const market = this.driftClient.getPerpMarketAccount( + userPosition.marketIndex + ); + return calculateBaseAssetValueWithOracle( + market, + userPosition, + oraclePriceData, + includeOpenOrders + ); + } + + /** + * calculates position liabiltiy value in margin system + * @returns : Precision QUOTE_PRECISION + */ + public getPerpLiabilityValue( + marketIndex: number, + oraclePriceData: OraclePriceData, + includeOpenOrders = false + ): BN { + const userPosition = this.getPerpPositionOrEmpty(marketIndex); + const market = this.driftClient.getPerpMarketAccount( + userPosition.marketIndex + ); + + if (includeOpenOrders) { + return calculateWorstCasePerpLiabilityValue( + userPosition, + market, + oraclePriceData.price + ).worstCaseLiabilityValue; + } else { + return calculatePerpLiabilityValue( + userPosition.baseAssetAmount, + oraclePriceData.price, + isVariant(market.contractType, 'prediction') + ); + } + } + + public getPositionSide( + currentPosition: Pick + ): PositionDirection | undefined { + if (currentPosition.baseAssetAmount.gt(ZERO)) { + return PositionDirection.LONG; + } else if (currentPosition.baseAssetAmount.lt(ZERO)) { + return PositionDirection.SHORT; + } else { + return undefined; + } + } + + /** + * calculates average exit price (optionally for closing up to 100% of position) + * @returns : Precision PRICE_PRECISION + */ + public getPositionEstimatedExitPriceAndPnl( + position: PerpPosition, + amountToClose?: BN, + useAMMClose = false + ): [BN, BN] { + const market = this.driftClient.getPerpMarketAccount(position.marketIndex); + + const entryPrice = calculateEntryPrice(position); + + const oraclePriceData = this.getMMOracleDataForPerpMarket( + position.marketIndex + ); + + if (amountToClose) { + if (amountToClose.eq(ZERO)) { + return [calculateReservePrice(market, oraclePriceData), ZERO]; + } + position = { + baseAssetAmount: amountToClose, + lastCumulativeFundingRate: position.lastCumulativeFundingRate, + marketIndex: position.marketIndex, + quoteAssetAmount: position.quoteAssetAmount, + } as PerpPosition; + } + + let baseAssetValue: BN; + + if (useAMMClose) { + baseAssetValue = calculateBaseAssetValue( + market, + position, + oraclePriceData + ); + } else { + baseAssetValue = calculateBaseAssetValueWithOracle( + market, + position, + oraclePriceData + ); + } + if (position.baseAssetAmount.eq(ZERO)) { + return [ZERO, ZERO]; + } + + const exitPrice = baseAssetValue + .mul(AMM_TO_QUOTE_PRECISION_RATIO) + .mul(PRICE_PRECISION) + .div(position.baseAssetAmount.abs()); + + const pnlPerBase = exitPrice.sub(entryPrice); + const pnl = pnlPerBase + .mul(position.baseAssetAmount) + .div(PRICE_PRECISION) + .div(AMM_TO_QUOTE_PRECISION_RATIO); + + return [exitPrice, pnl]; + } + + /** + * calculates current user leverage which is (total liability size) / (net asset value) + * @returns : Precision TEN_THOUSAND + */ + public getLeverage(includeOpenOrders = true): BN { + return this.calculateLeverageFromComponents( + this.getLeverageComponents(includeOpenOrders) + ); + } + + calculateLeverageFromComponents({ + perpLiabilityValue, + perpPnl, + spotAssetValue, + spotLiabilityValue, + }: { + perpLiabilityValue: BN; + perpPnl: BN; + spotAssetValue: BN; + spotLiabilityValue: BN; + }): BN { + const totalLiabilityValue = perpLiabilityValue.add(spotLiabilityValue); + const totalAssetValue = spotAssetValue.add(perpPnl); + const netAssetValue = totalAssetValue.sub(spotLiabilityValue); + + if (netAssetValue.eq(ZERO)) { + return ZERO; + } + + return totalLiabilityValue.mul(TEN_THOUSAND).div(netAssetValue); + } + + getLeverageComponents( + includeOpenOrders = true, + marginCategory: MarginCategory = undefined + ): { + perpLiabilityValue: BN; + perpPnl: BN; + spotAssetValue: BN; + spotLiabilityValue: BN; + } { + const perpLiability = this.getTotalPerpPositionLiability( + marginCategory, + undefined, + includeOpenOrders + ); + const perpPnl = this.getUnrealizedPNL(true, undefined, marginCategory); + + const { + totalAssetValue: spotAssetValue, + totalLiabilityValue: spotLiabilityValue, + } = this.getSpotMarketAssetAndLiabilityValue( + undefined, + marginCategory, + undefined, + includeOpenOrders + ); + + return { + perpLiabilityValue: perpLiability, + perpPnl, + spotAssetValue, + spotLiabilityValue, + }; + } + + isDustDepositPosition(spotMarketAccount: SpotMarketAccount): boolean { + const marketIndex = spotMarketAccount.marketIndex; + + const spotPosition = this.getSpotPosition(spotMarketAccount.marketIndex); + + if (isSpotPositionAvailable(spotPosition)) { + return false; + } + + const depositAmount = this.getTokenAmount(spotMarketAccount.marketIndex); + + if (depositAmount.lte(ZERO)) { + return false; + } + + const oraclePriceData = this.getOracleDataForSpotMarket(marketIndex); + + const strictOraclePrice = new StrictOraclePrice( + oraclePriceData.price, + oraclePriceData.twap + ); + + const balanceValue = this.getSpotAssetValue( + depositAmount, + strictOraclePrice, + spotMarketAccount + ); + + if (balanceValue.lt(DUST_POSITION_SIZE)) { + return true; + } + + return false; + } + + getSpotMarketAccountsWithDustPosition() { + const spotMarketAccounts = this.driftClient.getSpotMarketAccounts(); + + const dustPositionAccounts: SpotMarketAccount[] = []; + + for (const spotMarketAccount of spotMarketAccounts) { + const isDust = this.isDustDepositPosition(spotMarketAccount); + if (isDust) { + dustPositionAccounts.push(spotMarketAccount); + } + } + + return dustPositionAccounts; + } + + getTotalLiabilityValue(marginCategory?: MarginCategory): BN { + return this.getTotalPerpPositionLiability( + marginCategory, + undefined, + true + ).add( + this.getSpotMarketLiabilityValue( + undefined, + marginCategory, + undefined, + true + ) + ); + } + + getTotalAssetValue(marginCategory?: MarginCategory): BN { + return this.getSpotMarketAssetValue(undefined, marginCategory, true).add( + this.getUnrealizedPNL(true, undefined, marginCategory) + ); + } + + getNetUsdValue(): BN { + const netSpotValue = this.getNetSpotMarketValue(); + const unrealizedPnl = this.getUnrealizedPNL(true, undefined, undefined); + return netSpotValue.add(unrealizedPnl); + } + + /** + * Calculates the all time P&L of the user. + * + * Net withdraws + Net spot market value + Net unrealized P&L - + */ + getTotalAllTimePnl(): BN { + const netUsdValue = this.getNetUsdValue(); + const totalDeposits = this.getUserAccount().totalDeposits; + const totalWithdraws = this.getUserAccount().totalWithdraws; + + const totalPnl = netUsdValue.add(totalWithdraws).sub(totalDeposits); + + return totalPnl; + } + + /** + * calculates max allowable leverage exceeding hitting requirement category + * for large sizes where imf factor activates, result is a lower bound + * @param marginCategory {Initial, Maintenance} + * @param isLp if calculating max leveraging for adding lp, need to add buffer + * @param enterHighLeverageMode can pass this as true to calculate max leverage if the user was to enter high leverage mode + * @returns : Precision TEN_THOUSAND + */ + public getMaxLeverageForPerp( + perpMarketIndex: number, + _marginCategory: MarginCategory = 'Initial', + isLp = false, + enterHighLeverageMode = undefined + ): BN { + const market = this.driftClient.getPerpMarketAccount(perpMarketIndex); + const marketPrice = + this.driftClient.getOracleDataForPerpMarket(perpMarketIndex).price; + + const { perpLiabilityValue, perpPnl, spotAssetValue, spotLiabilityValue } = + this.getLeverageComponents(); + + const totalAssetValue = spotAssetValue.add(perpPnl); + + const netAssetValue = totalAssetValue.sub(spotLiabilityValue); + + if (netAssetValue.eq(ZERO)) { + return ZERO; + } + + const totalLiabilityValue = perpLiabilityValue.add(spotLiabilityValue); + + const lpBuffer = isLp + ? marketPrice.mul(market.amm.orderStepSize).div(AMM_RESERVE_PRECISION) + : ZERO; + + // absolute max fesible size (upper bound) + const maxSizeQuote = BN.max( + BN.min( + this.getMaxTradeSizeUSDCForPerp( + perpMarketIndex, + PositionDirection.LONG, + false, + enterHighLeverageMode || this.isHighLeverageMode('Initial') + ).tradeSize, + this.getMaxTradeSizeUSDCForPerp( + perpMarketIndex, + PositionDirection.SHORT, + false, + enterHighLeverageMode || this.isHighLeverageMode('Initial') + ).tradeSize + ).sub(lpBuffer), + ZERO + ); + + return totalLiabilityValue + .add(maxSizeQuote) + .mul(TEN_THOUSAND) + .div(netAssetValue); + } + + /** + * calculates max allowable leverage exceeding hitting requirement category + * @param spotMarketIndex + * @param direction + * @returns : Precision TEN_THOUSAND + */ + public getMaxLeverageForSpot( + spotMarketIndex: number, + direction: PositionDirection + ): BN { + const { perpLiabilityValue, perpPnl, spotAssetValue, spotLiabilityValue } = + this.getLeverageComponents(); + + const totalLiabilityValue = perpLiabilityValue.add(spotLiabilityValue); + const totalAssetValue = spotAssetValue.add(perpPnl); + + const netAssetValue = totalAssetValue.sub(spotLiabilityValue); + + if (netAssetValue.eq(ZERO)) { + return ZERO; + } + + const currentQuoteAssetValue = this.getSpotMarketAssetValue( + QUOTE_SPOT_MARKET_INDEX + ); + const currentQuoteLiabilityValue = this.getSpotMarketLiabilityValue( + QUOTE_SPOT_MARKET_INDEX + ); + const currentQuoteValue = currentQuoteAssetValue.sub( + currentQuoteLiabilityValue + ); + + const currentSpotMarketAssetValue = + this.getSpotMarketAssetValue(spotMarketIndex); + const currentSpotMarketLiabilityValue = + this.getSpotMarketLiabilityValue(spotMarketIndex); + const currentSpotMarketNetValue = currentSpotMarketAssetValue.sub( + currentSpotMarketLiabilityValue + ); + + const tradeQuoteAmount = this.getMaxTradeSizeUSDCForSpot( + spotMarketIndex, + direction, + currentQuoteAssetValue, + currentSpotMarketNetValue + ); + + let assetValueToAdd = ZERO; + let liabilityValueToAdd = ZERO; + + const newQuoteNetValue = isVariant(direction, 'short') + ? currentQuoteValue.add(tradeQuoteAmount) + : currentQuoteValue.sub(tradeQuoteAmount); + const newQuoteAssetValue = BN.max(newQuoteNetValue, ZERO); + const newQuoteLiabilityValue = BN.min(newQuoteNetValue, ZERO).abs(); + + assetValueToAdd = assetValueToAdd.add( + newQuoteAssetValue.sub(currentQuoteAssetValue) + ); + liabilityValueToAdd = liabilityValueToAdd.add( + newQuoteLiabilityValue.sub(currentQuoteLiabilityValue) + ); + + const newSpotMarketNetValue = isVariant(direction, 'long') + ? currentSpotMarketNetValue.add(tradeQuoteAmount) + : currentSpotMarketNetValue.sub(tradeQuoteAmount); + const newSpotMarketAssetValue = BN.max(newSpotMarketNetValue, ZERO); + const newSpotMarketLiabilityValue = BN.min( + newSpotMarketNetValue, + ZERO + ).abs(); + + assetValueToAdd = assetValueToAdd.add( + newSpotMarketAssetValue.sub(currentSpotMarketAssetValue) + ); + liabilityValueToAdd = liabilityValueToAdd.add( + newSpotMarketLiabilityValue.sub(currentSpotMarketLiabilityValue) + ); + + const finalTotalAssetValue = totalAssetValue.add(assetValueToAdd); + const finalTotalSpotLiability = spotLiabilityValue.add(liabilityValueToAdd); + + const finalTotalLiabilityValue = + totalLiabilityValue.add(liabilityValueToAdd); + + const finalNetAssetValue = finalTotalAssetValue.sub( + finalTotalSpotLiability + ); + + return finalTotalLiabilityValue.mul(TEN_THOUSAND).div(finalNetAssetValue); + } + + /** + * calculates margin ratio: 1 / leverage + * @returns : Precision TEN_THOUSAND + */ + public getMarginRatio(): BN { + const { perpLiabilityValue, perpPnl, spotAssetValue, spotLiabilityValue } = + this.getLeverageComponents(); + + const totalLiabilityValue = perpLiabilityValue.add(spotLiabilityValue); + const totalAssetValue = spotAssetValue.add(perpPnl); + + if (totalLiabilityValue.eq(ZERO)) { + return BN_MAX; + } + + const netAssetValue = totalAssetValue.sub(spotLiabilityValue); + + return netAssetValue.mul(TEN_THOUSAND).div(totalLiabilityValue); + } + + public canBeLiquidated(): { + canBeLiquidated: boolean; + marginRequirement: BN; + totalCollateral: BN; + } { + const liquidationBuffer = this.getLiquidationBuffer(); + + const totalCollateral = this.getTotalCollateral( + 'Maintenance', + undefined, + undefined, + liquidationBuffer + ); + + const marginRequirement = + this.getMaintenanceMarginRequirement(liquidationBuffer); + const canBeLiquidated = totalCollateral.lt(marginRequirement); + + return { + canBeLiquidated, + marginRequirement, + totalCollateral, + }; + } + + public isBeingLiquidated(): boolean { + return ( + (this.getUserAccount().status & + (UserStatus.BEING_LIQUIDATED | UserStatus.BANKRUPT)) > + 0 + ); + } + + public hasStatus(status: UserStatus): boolean { + return (this.getUserAccount().status & status) > 0; + } + + public isBankrupt(): boolean { + return (this.getUserAccount().status & UserStatus.BANKRUPT) > 0; + } + + public isHighLeverageMode(marginCategory: MarginCategory): boolean { + return ( + isVariant(this.getUserAccount().marginMode, 'highLeverage') || + (marginCategory === 'Maintenance' && + isVariant(this.getUserAccount().marginMode, 'highLeverageMaintenance')) + ); + } + + /** + * Checks if any user position cumulative funding differs from respective market cumulative funding + * @returns + */ + public needsToSettleFundingPayment(): boolean { + for (const userPosition of this.getUserAccount().perpPositions) { + if (userPosition.baseAssetAmount.eq(ZERO)) { + continue; + } + + const market = this.driftClient.getPerpMarketAccount( + userPosition.marketIndex + ); + if ( + market.amm.cumulativeFundingRateLong.eq( + userPosition.lastCumulativeFundingRate + ) || + market.amm.cumulativeFundingRateShort.eq( + userPosition.lastCumulativeFundingRate + ) + ) { + continue; + } + + return true; + } + return false; + } + + /** + * Calculate the liquidation price of a spot position + * @param marketIndex + * @returns Precision : PRICE_PRECISION + */ + public spotLiquidationPrice( + marketIndex: number, + positionBaseSizeChange: BN = ZERO + ): BN { + const currentSpotPosition = this.getSpotPosition(marketIndex); + + if (!currentSpotPosition) { + return new BN(-1); + } + + const totalCollateral = this.getTotalCollateral('Maintenance'); + const maintenanceMarginRequirement = this.getMaintenanceMarginRequirement(); + const freeCollateral = BN.max( + ZERO, + totalCollateral.sub(maintenanceMarginRequirement) + ); + + const market = this.driftClient.getSpotMarketAccount(marketIndex); + let signedTokenAmount = getSignedTokenAmount( + getTokenAmount( + currentSpotPosition.scaledBalance, + market, + currentSpotPosition.balanceType + ), + currentSpotPosition.balanceType + ); + signedTokenAmount = signedTokenAmount.add(positionBaseSizeChange); + + if (signedTokenAmount.eq(ZERO)) { + return new BN(-1); + } + + let freeCollateralDelta = this.calculateFreeCollateralDeltaForSpot( + market, + signedTokenAmount + ); + + const oracle = market.oracle; + const perpMarketWithSameOracle = this.driftClient + .getPerpMarketAccounts() + .find((market) => market.amm.oracle.equals(oracle)); + const oraclePrice = + this.driftClient.getOracleDataForSpotMarket(marketIndex).price; + if (perpMarketWithSameOracle) { + const perpPosition = this.getPerpPositionOrEmpty( + perpMarketWithSameOracle.marketIndex + ); + if (perpPosition) { + let freeCollateralDeltaForPerp = + this.calculateFreeCollateralDeltaForPerp( + perpMarketWithSameOracle, + perpPosition, + ZERO, + oraclePrice + ); + + if (freeCollateralDeltaForPerp) { + const { numerator, denominator } = getMultipleBetweenOracleSources( + market.oracleSource, + perpMarketWithSameOracle.amm.oracleSource + ); + freeCollateralDeltaForPerp = freeCollateralDeltaForPerp + .mul(numerator) + .div(denominator); + } + + freeCollateralDelta = freeCollateralDelta.add( + freeCollateralDeltaForPerp || ZERO + ); + } + } + + if (freeCollateralDelta.eq(ZERO)) { + return new BN(-1); + } + + const liqPriceDelta = freeCollateral + .mul(QUOTE_PRECISION) + .div(freeCollateralDelta); + + const liqPrice = oraclePrice.sub(liqPriceDelta); + + if (liqPrice.lt(ZERO)) { + return new BN(-1); + } + + return liqPrice; + } + + /** + * Calculate the liquidation price of a perp position, with optional parameter to calculate the liquidation price after a trade + * @param marketIndex + * @param positionBaseSizeChange // change in position size to calculate liquidation price for : Precision 10^9 + * @param estimatedEntryPrice + * @param marginCategory // allow Initial to be passed in if we are trying to calculate price for DLP de-risking + * @param includeOpenOrders + * @param offsetCollateral // allows calculating the liquidation price after this offset collateral is added to the user's account (e.g. : what will the liquidation price be for this position AFTER I deposit $x worth of collateral) + * @returns Precision : PRICE_PRECISION + */ + public liquidationPrice( + marketIndex: number, + positionBaseSizeChange: BN = ZERO, + estimatedEntryPrice: BN = ZERO, + marginCategory: MarginCategory = 'Maintenance', + includeOpenOrders = false, + offsetCollateral = ZERO, + enteringHighLeverage = undefined + ): BN { + const totalCollateral = this.getTotalCollateral( + marginCategory, + false, + includeOpenOrders + ); + // console.log( + // 'line 2219 old user liq price totalCollateral', + // totalCollateral.toString() + // ); + const marginRequirement = this.getMarginRequirement( + marginCategory, + undefined, + false, + includeOpenOrders, + enteringHighLeverage + ); + + // console.log( + // ' line2150 old user liq price marginRequirement', + // marginRequirement.toString() + // ); + let freeCollateral = BN.max( + ZERO, + totalCollateral.sub(marginRequirement) + ).add(offsetCollateral); + + const oracle = + this.driftClient.getPerpMarketAccount(marketIndex).amm.oracle; + + const oraclePrice = + this.driftClient.getOracleDataForPerpMarket(marketIndex).price; + + const market = this.driftClient.getPerpMarketAccount(marketIndex); + const currentPerpPosition = this.getPerpPositionOrEmpty(marketIndex); + + positionBaseSizeChange = standardizeBaseAssetAmount( + positionBaseSizeChange, + market.amm.orderStepSize + ); + + const freeCollateralChangeFromNewPosition = + this.calculateEntriesEffectOnFreeCollateral( + market, + oraclePrice, + currentPerpPosition, + positionBaseSizeChange, + estimatedEntryPrice, + includeOpenOrders, + enteringHighLeverage + ); + + freeCollateral = freeCollateral.add(freeCollateralChangeFromNewPosition); + + // console.log( + // ' line2266 old user freeCollateral right after calculateEntriesEffectOnFreeCollateral', + // freeCollateral.toString() + // ); + + let freeCollateralDelta = this.calculateFreeCollateralDeltaForPerp( + market, + currentPerpPosition, + positionBaseSizeChange, + oraclePrice, + marginCategory, + includeOpenOrders, + enteringHighLeverage + ); + + // console.log( + // ' line2195 old user freeCollateralDelta', + // freeCollateralDelta?.toString() + // ); + + if (!freeCollateralDelta) { + return new BN(-1); + } + + const spotMarketWithSameOracle = this.driftClient + .getSpotMarketAccounts() + .find((market) => market.oracle.equals(oracle)); + if (spotMarketWithSameOracle) { + const spotPosition = this.getSpotPosition( + spotMarketWithSameOracle.marketIndex + ); + if (spotPosition) { + const signedTokenAmount = getSignedTokenAmount( + getTokenAmount( + spotPosition.scaledBalance, + spotMarketWithSameOracle, + spotPosition.balanceType + ), + spotPosition.balanceType + ); + + let spotFreeCollateralDelta = this.calculateFreeCollateralDeltaForSpot( + spotMarketWithSameOracle, + signedTokenAmount, + marginCategory + ); + + if (spotFreeCollateralDelta) { + const { numerator, denominator } = getMultipleBetweenOracleSources( + market.amm.oracleSource, + spotMarketWithSameOracle.oracleSource + ); + spotFreeCollateralDelta = spotFreeCollateralDelta + .mul(numerator) + .div(denominator); + } + + freeCollateralDelta = freeCollateralDelta.add( + spotFreeCollateralDelta || ZERO + ); + } + } + + // console.log( + // 'line 2324 old user freeCollateralDelta', + // freeCollateralDelta.toString() + // ); + + if (freeCollateralDelta.eq(ZERO)) { + return new BN(-1); + } + + const liqPriceDelta = freeCollateral + .mul(QUOTE_PRECISION) + .div(freeCollateralDelta); + + const liqPrice = oraclePrice.sub(liqPriceDelta); + + if (liqPrice.lt(ZERO)) { + return new BN(-1); + } + + return liqPrice; + } + + calculateEntriesEffectOnFreeCollateral( + market: PerpMarketAccount, + oraclePrice: BN, + perpPosition: PerpPosition, + positionBaseSizeChange: BN, + estimatedEntryPrice: BN, + includeOpenOrders: boolean, + enteringHighLeverage = undefined, + marginCategory: MarginCategory = 'Maintenance' + ): BN { + let freeCollateralChange = ZERO; + + // update free collateral to account for change in pnl from new position + if ( + !estimatedEntryPrice.eq(ZERO) && + !positionBaseSizeChange.eq(ZERO) && + marginCategory === 'Maintenance' + ) { + const costBasis = oraclePrice + .mul(positionBaseSizeChange.abs()) + .div(BASE_PRECISION); + const newPositionValue = estimatedEntryPrice + .mul(positionBaseSizeChange.abs()) + .div(BASE_PRECISION); + if (positionBaseSizeChange.gt(ZERO)) { + freeCollateralChange = costBasis.sub(newPositionValue); + } else { + freeCollateralChange = newPositionValue.sub(costBasis); + } + + // assume worst fee tier + const takerFeeTier = + this.driftClient.getStateAccount().perpFeeStructure.feeTiers[0]; + const takerFee = newPositionValue + .muln(takerFeeTier.feeNumerator) + .divn(takerFeeTier.feeDenominator); + freeCollateralChange = freeCollateralChange.sub(takerFee); + } + + const calculateMarginRequirement = (perpPosition: PerpPosition) => { + let baseAssetAmount: BN; + let liabilityValue: BN; + if (includeOpenOrders) { + const { worstCaseBaseAssetAmount, worstCaseLiabilityValue } = + calculateWorstCasePerpLiabilityValue( + perpPosition, + market, + oraclePrice + ); + baseAssetAmount = worstCaseBaseAssetAmount; + liabilityValue = worstCaseLiabilityValue; + } else { + baseAssetAmount = perpPosition.baseAssetAmount; + liabilityValue = calculatePerpLiabilityValue( + baseAssetAmount, + oraclePrice, + isVariant(market.contractType, 'prediction') + ); + } + + const userCustomMargin = Math.max( + perpPosition.maxMarginRatio, + this.getUserAccount().maxMarginRatio + ); + const marginRatio = calculateMarketMarginRatio( + market, + baseAssetAmount.abs(), + marginCategory, + enteringHighLeverage === false + ? Math.max(market.marginRatioInitial, userCustomMargin) + : userCustomMargin, + this.isHighLeverageMode(marginCategory) || enteringHighLeverage === true + ); + + return liabilityValue.mul(new BN(marginRatio)).div(MARGIN_PRECISION); + }; + + const freeCollateralConsumptionBefore = + calculateMarginRequirement(perpPosition); + + const perpPositionAfter = Object.assign({}, perpPosition); + perpPositionAfter.baseAssetAmount = perpPositionAfter.baseAssetAmount.add( + positionBaseSizeChange + ); + + const freeCollateralConsumptionAfter = + calculateMarginRequirement(perpPositionAfter); + + return freeCollateralChange.sub( + freeCollateralConsumptionAfter.sub(freeCollateralConsumptionBefore) + ); + } + + calculateFreeCollateralDeltaForPerp( + market: PerpMarketAccount, + perpPosition: PerpPosition, + positionBaseSizeChange: BN, + oraclePrice: BN, + marginCategory: MarginCategory = 'Maintenance', + includeOpenOrders = false, + enteringHighLeverage = undefined + ): BN | undefined { + const baseAssetAmount = includeOpenOrders + ? calculateWorstCaseBaseAssetAmount(perpPosition, market, oraclePrice) + : perpPosition.baseAssetAmount; + + // zero if include orders == false + const orderBaseAssetAmount = baseAssetAmount.sub( + perpPosition.baseAssetAmount + ); + + const proposedBaseAssetAmount = baseAssetAmount.add(positionBaseSizeChange); + + const userCustomMargin = Math.max( + perpPosition.maxMarginRatio, + this.getUserAccount().maxMarginRatio + ); + + const marginRatio = calculateMarketMarginRatio( + market, + proposedBaseAssetAmount.abs(), + marginCategory, + enteringHighLeverage === false + ? Math.max(market.marginRatioInitial, userCustomMargin) + : userCustomMargin, + this.isHighLeverageMode(marginCategory) || enteringHighLeverage === true + ); + + const marginRatioQuotePrecision = new BN(marginRatio) + .mul(QUOTE_PRECISION) + .div(MARGIN_PRECISION); + + if (proposedBaseAssetAmount.eq(ZERO)) { + return undefined; + } + + let freeCollateralDelta = ZERO; + if (isVariant(market.contractType, 'prediction')) { + // for prediction market, increase in pnl and margin requirement will net out for position + // open order margin requirement will change with price though + if (orderBaseAssetAmount.gt(ZERO)) { + freeCollateralDelta = marginRatioQuotePrecision.neg(); + } else if (orderBaseAssetAmount.lt(ZERO)) { + freeCollateralDelta = marginRatioQuotePrecision; + } + } else { + if (proposedBaseAssetAmount.gt(ZERO)) { + freeCollateralDelta = QUOTE_PRECISION.sub(marginRatioQuotePrecision) + .mul(proposedBaseAssetAmount) + .div(BASE_PRECISION); + } else { + freeCollateralDelta = QUOTE_PRECISION.neg() + .sub(marginRatioQuotePrecision) + .mul(proposedBaseAssetAmount.abs()) + .div(BASE_PRECISION); + } + + if (!orderBaseAssetAmount.eq(ZERO)) { + freeCollateralDelta = freeCollateralDelta.sub( + marginRatioQuotePrecision + .mul(orderBaseAssetAmount.abs()) + .div(BASE_PRECISION) + ); + } + } + + return freeCollateralDelta; + } + + calculateFreeCollateralDeltaForSpot( + market: SpotMarketAccount, + signedTokenAmount: BN, + marginCategory: MarginCategory = 'Maintenance' + ): BN { + const tokenPrecision = new BN(Math.pow(10, market.decimals)); + + if (signedTokenAmount.gt(ZERO)) { + const assetWeight = calculateAssetWeight( + signedTokenAmount, + this.driftClient.getOracleDataForSpotMarket(market.marketIndex).price, + market, + marginCategory + ); + + return QUOTE_PRECISION.mul(assetWeight) + .div(SPOT_MARKET_WEIGHT_PRECISION) + .mul(signedTokenAmount) + .div(tokenPrecision); + } else { + const liabilityWeight = calculateLiabilityWeight( + signedTokenAmount.abs(), + market, + marginCategory + ); + + return QUOTE_PRECISION.neg() + .mul(liabilityWeight) + .div(SPOT_MARKET_WEIGHT_PRECISION) + .mul(signedTokenAmount.abs()) + .div(tokenPrecision); + } + } + + /** + * Calculates the estimated liquidation price for a position after closing a quote amount of the position. + * @param positionMarketIndex + * @param closeQuoteAmount + * @returns : Precision PRICE_PRECISION + */ + public liquidationPriceAfterClose( + positionMarketIndex: number, + closeQuoteAmount: BN, + estimatedEntryPrice: BN = ZERO + ): BN { + const currentPosition = this.getPerpPositionOrEmpty(positionMarketIndex); + + const closeBaseAmount = currentPosition.baseAssetAmount + .mul(closeQuoteAmount) + .div(currentPosition.quoteAssetAmount.abs()) + .add( + currentPosition.baseAssetAmount + .mul(closeQuoteAmount) + .mod(currentPosition.quoteAssetAmount.abs()) + ) + .neg(); + + return this.liquidationPrice( + positionMarketIndex, + closeBaseAmount, + estimatedEntryPrice + ); + } + + public getMarginUSDCRequiredForTrade( + targetMarketIndex: number, + baseSize: BN, + estEntryPrice?: BN, + perpMarketMaxMarginRatio?: number + ): BN { + const maxMarginRatio = Math.max( + perpMarketMaxMarginRatio, + this.getUserAccount().maxMarginRatio + ); + return calculateMarginUSDCRequiredForTrade( + this.driftClient, + targetMarketIndex, + baseSize, + maxMarginRatio, + undefined, + estEntryPrice + ); + } + + public getCollateralDepositRequiredForTrade( + targetMarketIndex: number, + baseSize: BN, + collateralIndex: number, + perpMarketMaxMarginRatio?: number + ): BN { + const maxMarginRatio = Math.max( + perpMarketMaxMarginRatio, + this.getUserAccount().maxMarginRatio + ); + return calculateCollateralDepositRequiredForTrade( + this.driftClient, + targetMarketIndex, + baseSize, + collateralIndex, + maxMarginRatio, + false // assume user cant be high leverage if they havent created user account ? + ); + } + + /** + * Separates the max trade size into two parts: + * - tradeSize: The maximum trade size for target direction + * - oppositeSideTradeSize: the trade size for closing the opposite direction + * @param targetMarketIndex + * @param tradeSide + * @param isLp + * @returns { tradeSize: BN, oppositeSideTradeSize: BN} : Precision QUOTE_PRECISION + */ + public getMaxTradeSizeUSDCForPerp( + targetMarketIndex: number, + tradeSide: PositionDirection, + isLp = false, + enterHighLeverageMode = undefined, + maxMarginRatio = undefined + ): { tradeSize: BN; oppositeSideTradeSize: BN } { + let tradeSize = ZERO; + let oppositeSideTradeSize = ZERO; + const currentPosition = this.getPerpPositionOrEmpty(targetMarketIndex); + + const targetSide = isVariant(tradeSide, 'short') ? 'short' : 'long'; + + const currentPositionSide = currentPosition?.baseAssetAmount.isNeg() + ? 'short' + : 'long'; + + const targetingSameSide = !currentPosition + ? true + : targetSide === currentPositionSide; + + const oracleData = this.getMMOracleDataForPerpMarket(targetMarketIndex); + + const marketAccount = + this.driftClient.getPerpMarketAccount(targetMarketIndex); + + const lpBuffer = isLp + ? oracleData.price + .mul(marketAccount.amm.orderStepSize) + .div(AMM_RESERVE_PRECISION) + : ZERO; + + // add any position we have on the opposite side of the current trade, because we can "flip" the size of this position without taking any extra leverage. + const oppositeSizeLiabilityValue = targetingSameSide + ? ZERO + : calculatePerpLiabilityValue( + currentPosition.baseAssetAmount, + oracleData.price, + isVariant(marketAccount.contractType, 'prediction') + ); + + const maxPositionSize = this.getPerpBuyingPower( + targetMarketIndex, + lpBuffer, + enterHighLeverageMode, + maxMarginRatio + ); + + if (maxPositionSize.gte(ZERO)) { + if (oppositeSizeLiabilityValue.eq(ZERO)) { + // case 1 : Regular trade where current total position less than max, and no opposite position to account for + // do nothing + tradeSize = maxPositionSize; + } else { + // case 2 : trade where current total position less than max, but need to account for flipping the current position over to the other side + tradeSize = maxPositionSize.add(oppositeSizeLiabilityValue); + oppositeSideTradeSize = oppositeSizeLiabilityValue; + } + } else { + // current leverage is greater than max leverage - can only reduce position size + + if (!targetingSameSide) { + const market = this.driftClient.getPerpMarketAccount(targetMarketIndex); + const perpLiabilityValue = calculatePerpLiabilityValue( + currentPosition.baseAssetAmount, + oracleData.price, + isVariant(market.contractType, 'prediction') + ); + const totalCollateral = this.getTotalCollateral(); + const marginRequirement = this.getInitialMarginRequirement( + enterHighLeverageMode + ); + const marginRatio = Math.max( + currentPosition.maxMarginRatio, + this.getUserAccount().maxMarginRatio + ); + const marginFreedByClosing = perpLiabilityValue + .mul(new BN(marginRatio)) + .div(MARGIN_PRECISION); + const marginRequirementAfterClosing = + marginRequirement.sub(marginFreedByClosing); + + if (marginRequirementAfterClosing.gt(totalCollateral)) { + oppositeSideTradeSize = perpLiabilityValue; + } else { + const freeCollateralAfterClose = totalCollateral.sub( + marginRequirementAfterClosing + ); + + const buyingPowerAfterClose = + this.getPerpBuyingPowerFromFreeCollateralAndBaseAssetAmount( + targetMarketIndex, + freeCollateralAfterClose, + ZERO, + currentPosition.maxMarginRatio + ); + oppositeSideTradeSize = perpLiabilityValue; + tradeSize = buyingPowerAfterClose; + } + } else { + // do nothing if targetting same side + tradeSize = maxPositionSize; + } + } + + const freeCollateral = this.getFreeCollateral( + 'Initial', + enterHighLeverageMode + ); + + let baseTradeSize = + targetSide === 'long' + ? tradeSize.mul(BASE_PRECISION).div(oracleData.price) + : tradeSize.mul(BASE_PRECISION).div(oracleData.price).neg(); + + let freeCollateralChangeFromNewPosition = + this.calculateEntriesEffectOnFreeCollateral( + marketAccount, + oracleData.price, + currentPosition, + baseTradeSize, + oracleData.price, + false, + enterHighLeverageMode, + 'Initial' + ); + + while ( + freeCollateralChangeFromNewPosition.isNeg() && + freeCollateralChangeFromNewPosition.abs().gt(freeCollateral) + ) { + tradeSize = tradeSize.mul(new BN(99)).div(new BN(100)); + baseTradeSize = + targetSide === 'long' + ? tradeSize.mul(BASE_PRECISION).div(oracleData.price) + : tradeSize.mul(BASE_PRECISION).div(oracleData.price).neg(); + freeCollateralChangeFromNewPosition = + this.calculateEntriesEffectOnFreeCollateral( + marketAccount, + oracleData.price, + currentPosition, + baseTradeSize, + oracleData.price, + false, + enterHighLeverageMode, + 'Initial' + ); + } + + return { tradeSize, oppositeSideTradeSize }; + } + + /** + * Get the maximum trade size for a given market, taking into account the user's current leverage, positions, collateral, etc. + * + * @param targetMarketIndex + * @param direction + * @param currentQuoteAssetValue + * @param currentSpotMarketNetValue + * @returns tradeSizeAllowed : Precision QUOTE_PRECISION + */ + public getMaxTradeSizeUSDCForSpot( + targetMarketIndex: number, + direction: PositionDirection, + currentQuoteAssetValue?: BN, + currentSpotMarketNetValue?: BN + ): BN { + const market = this.driftClient.getSpotMarketAccount(targetMarketIndex); + const oraclePrice = + this.driftClient.getOracleDataForSpotMarket(targetMarketIndex).price; + + currentQuoteAssetValue = this.getSpotMarketAssetValue( + QUOTE_SPOT_MARKET_INDEX + ); + + currentSpotMarketNetValue = + currentSpotMarketNetValue ?? this.getSpotPositionValue(targetMarketIndex); + + let freeCollateral = this.getFreeCollateral(); + const marginRatio = calculateSpotMarketMarginRatio( + market, + oraclePrice, + 'Initial', + ZERO, + isVariant(direction, 'long') + ? SpotBalanceType.DEPOSIT + : SpotBalanceType.BORROW, + this.getUserAccount().maxMarginRatio + ); + + let tradeAmount = ZERO; + if (this.getUserAccount().isMarginTradingEnabled) { + // if the user is buying/selling and already short/long, need to account for closing out short/long + if (isVariant(direction, 'long') && currentSpotMarketNetValue.lt(ZERO)) { + tradeAmount = currentSpotMarketNetValue.abs(); + const marginRatio = calculateSpotMarketMarginRatio( + market, + oraclePrice, + 'Initial', + this.getTokenAmount(targetMarketIndex).abs(), + SpotBalanceType.BORROW, + this.getUserAccount().maxMarginRatio + ); + freeCollateral = freeCollateral.add( + tradeAmount.mul(new BN(marginRatio)).div(MARGIN_PRECISION) + ); + } else if ( + isVariant(direction, 'short') && + currentSpotMarketNetValue.gt(ZERO) + ) { + tradeAmount = currentSpotMarketNetValue; + const marginRatio = calculateSpotMarketMarginRatio( + market, + oraclePrice, + 'Initial', + this.getTokenAmount(targetMarketIndex), + SpotBalanceType.DEPOSIT, + this.getUserAccount().maxMarginRatio + ); + freeCollateral = freeCollateral.add( + tradeAmount.mul(new BN(marginRatio)).div(MARGIN_PRECISION) + ); + } + + tradeAmount = tradeAmount.add( + freeCollateral.mul(MARGIN_PRECISION).div(new BN(marginRatio)) + ); + } else if (isVariant(direction, 'long')) { + tradeAmount = BN.min( + currentQuoteAssetValue, + freeCollateral.mul(MARGIN_PRECISION).div(new BN(marginRatio)) + ); + } else { + tradeAmount = BN.max(ZERO, currentSpotMarketNetValue); + } + + return tradeAmount; + } + + /** + * Calculates the max amount of token that can be swapped from inMarket to outMarket + * Assumes swap happens at oracle price + * + * @param inMarketIndex + * @param outMarketIndex + * @param calculateSwap function to similate in to out swa + * @param iterationLimit how long to run appromixation before erroring out + */ + public getMaxSwapAmount({ + inMarketIndex, + outMarketIndex, + calculateSwap, + iterationLimit = 1000, + }: { + inMarketIndex: number; + outMarketIndex: number; + calculateSwap?: (inAmount: BN) => BN; + iterationLimit?: number; + }): { inAmount: BN; outAmount: BN; leverage: BN } { + const inMarket = this.driftClient.getSpotMarketAccount(inMarketIndex); + const outMarket = this.driftClient.getSpotMarketAccount(outMarketIndex); + + const inOraclePriceData = this.getOracleDataForSpotMarket(inMarketIndex); + const inOraclePrice = inOraclePriceData.price; + const outOraclePriceData = this.getOracleDataForSpotMarket(outMarketIndex); + const outOraclePrice = outOraclePriceData.price; + + const inStrictOraclePrice = new StrictOraclePrice(inOraclePrice); + const outStrictOraclePrice = new StrictOraclePrice(outOraclePrice); + + const inPrecision = new BN(10 ** inMarket.decimals); + const outPrecision = new BN(10 ** outMarket.decimals); + + const inSpotPosition = + this.getSpotPosition(inMarketIndex) || + this.getEmptySpotPosition(inMarketIndex); + const outSpotPosition = + this.getSpotPosition(outMarketIndex) || + this.getEmptySpotPosition(outMarketIndex); + + const freeCollateral = this.getFreeCollateral(); + + const inContributionInitial = + this.calculateSpotPositionFreeCollateralContribution( + inSpotPosition, + inStrictOraclePrice + ); + const { + totalAssetValue: inTotalAssetValueInitial, + totalLiabilityValue: inTotalLiabilityValueInitial, + } = this.calculateSpotPositionLeverageContribution( + inSpotPosition, + inStrictOraclePrice + ); + const outContributionInitial = + this.calculateSpotPositionFreeCollateralContribution( + outSpotPosition, + outStrictOraclePrice + ); + const { + totalAssetValue: outTotalAssetValueInitial, + totalLiabilityValue: outTotalLiabilityValueInitial, + } = this.calculateSpotPositionLeverageContribution( + outSpotPosition, + outStrictOraclePrice + ); + const initialContribution = inContributionInitial.add( + outContributionInitial + ); + + const { perpLiabilityValue, perpPnl, spotAssetValue, spotLiabilityValue } = + this.getLeverageComponents(); + + if (!calculateSwap) { + calculateSwap = (inSwap: BN) => { + return inSwap + .mul(outPrecision) + .mul(inOraclePrice) + .div(outOraclePrice) + .div(inPrecision); + }; + } + + let inSwap = ZERO; + let outSwap = ZERO; + const inTokenAmount = this.getTokenAmount(inMarketIndex); + const outTokenAmount = this.getTokenAmount(outMarketIndex); + + const inAssetWeight = calculateAssetWeight( + inTokenAmount, + inOraclePriceData.price, + inMarket, + 'Initial' + ); + const outAssetWeight = calculateAssetWeight( + outTokenAmount, + outOraclePriceData.price, + outMarket, + 'Initial' + ); + + const outSaferThanIn = + // selling asset to close borrow + (inTokenAmount.gt(ZERO) && outTokenAmount.lt(ZERO)) || + // buying asset with higher initial asset weight + inAssetWeight.lte(outAssetWeight); + + if (freeCollateral.lt(PRICE_PRECISION.divn(100))) { + if (outSaferThanIn && inTokenAmount.gt(ZERO)) { + inSwap = inTokenAmount; + outSwap = calculateSwap(inSwap); + } + } else { + let minSwap = ZERO; + let maxSwap = BN.max( + freeCollateral.mul(inPrecision).mul(new BN(100)).div(inOraclePrice), // 100x current free collateral + inTokenAmount.abs().mul(new BN(10)) // 10x current position + ); + inSwap = maxSwap.div(TWO); + const error = freeCollateral.div(new BN(10000)); + + let i = 0; + let freeCollateralAfter = freeCollateral; + while (freeCollateralAfter.gt(error) || freeCollateralAfter.isNeg()) { + outSwap = calculateSwap(inSwap); + + const inPositionAfter = this.cloneAndUpdateSpotPosition( + inSpotPosition, + inSwap.neg(), + inMarket + ); + const outPositionAfter = this.cloneAndUpdateSpotPosition( + outSpotPosition, + outSwap, + outMarket + ); + + const inContributionAfter = + this.calculateSpotPositionFreeCollateralContribution( + inPositionAfter, + inStrictOraclePrice + ); + const outContributionAfter = + this.calculateSpotPositionFreeCollateralContribution( + outPositionAfter, + outStrictOraclePrice + ); + + const contributionAfter = inContributionAfter.add(outContributionAfter); + + const contributionDelta = contributionAfter.sub(initialContribution); + + freeCollateralAfter = freeCollateral.add(contributionDelta); + + if (freeCollateralAfter.gt(error)) { + minSwap = inSwap; + inSwap = minSwap.add(maxSwap).div(TWO); + } else if (freeCollateralAfter.isNeg()) { + maxSwap = inSwap; + inSwap = minSwap.add(maxSwap).div(TWO); + } + + if (i++ > iterationLimit) { + console.log('getMaxSwapAmount iteration limit reached'); + break; + } + } + } + + const inPositionAfter = this.cloneAndUpdateSpotPosition( + inSpotPosition, + inSwap.neg(), + inMarket + ); + const outPositionAfter = this.cloneAndUpdateSpotPosition( + outSpotPosition, + outSwap, + outMarket + ); + + const { + totalAssetValue: inTotalAssetValueAfter, + totalLiabilityValue: inTotalLiabilityValueAfter, + } = this.calculateSpotPositionLeverageContribution( + inPositionAfter, + inStrictOraclePrice + ); + + const { + totalAssetValue: outTotalAssetValueAfter, + totalLiabilityValue: outTotalLiabilityValueAfter, + } = this.calculateSpotPositionLeverageContribution( + outPositionAfter, + outStrictOraclePrice + ); + + const spotAssetValueDelta = inTotalAssetValueAfter + .add(outTotalAssetValueAfter) + .sub(inTotalAssetValueInitial) + .sub(outTotalAssetValueInitial); + const spotLiabilityValueDelta = inTotalLiabilityValueAfter + .add(outTotalLiabilityValueAfter) + .sub(inTotalLiabilityValueInitial) + .sub(outTotalLiabilityValueInitial); + + const spotAssetValueAfter = spotAssetValue.add(spotAssetValueDelta); + const spotLiabilityValueAfter = spotLiabilityValue.add( + spotLiabilityValueDelta + ); + + const leverage = this.calculateLeverageFromComponents({ + perpLiabilityValue, + perpPnl, + spotAssetValue: spotAssetValueAfter, + spotLiabilityValue: spotLiabilityValueAfter, + }); + + return { inAmount: inSwap, outAmount: outSwap, leverage }; + } + + public cloneAndUpdateSpotPosition( + position: SpotPosition, + tokenAmount: BN, + market: SpotMarketAccount + ): SpotPosition { + const clonedPosition = Object.assign({}, position); + if (tokenAmount.eq(ZERO)) { + return clonedPosition; + } + + const preTokenAmount = getSignedTokenAmount( + getTokenAmount(position.scaledBalance, market, position.balanceType), + position.balanceType + ); + + if (sigNum(preTokenAmount).eq(sigNum(tokenAmount))) { + const scaledBalanceDelta = getBalance( + tokenAmount.abs(), + market, + position.balanceType + ); + clonedPosition.scaledBalance = + clonedPosition.scaledBalance.add(scaledBalanceDelta); + return clonedPosition; + } + + const updateDirection = tokenAmount.isNeg() + ? SpotBalanceType.BORROW + : SpotBalanceType.DEPOSIT; + + if (tokenAmount.abs().gte(preTokenAmount.abs())) { + clonedPosition.scaledBalance = getBalance( + tokenAmount.abs().sub(preTokenAmount.abs()), + market, + updateDirection + ); + clonedPosition.balanceType = updateDirection; + } else { + const scaledBalanceDelta = getBalance( + tokenAmount.abs(), + market, + position.balanceType + ); + + clonedPosition.scaledBalance = + clonedPosition.scaledBalance.sub(scaledBalanceDelta); + } + return clonedPosition; + } + + calculateSpotPositionFreeCollateralContribution( + spotPosition: SpotPosition, + strictOraclePrice: StrictOraclePrice + ): BN { + const marginCategory = 'Initial'; + + const spotMarketAccount: SpotMarketAccount = + this.driftClient.getSpotMarketAccount(spotPosition.marketIndex); + + const { freeCollateralContribution } = getWorstCaseTokenAmounts( + spotPosition, + spotMarketAccount, + strictOraclePrice, + marginCategory, + this.getUserAccount().maxMarginRatio + ); + + return freeCollateralContribution; + } + + calculateSpotPositionLeverageContribution( + spotPosition: SpotPosition, + strictOraclePrice: StrictOraclePrice + ): { + totalAssetValue: BN; + totalLiabilityValue: BN; + } { + let totalAssetValue = ZERO; + let totalLiabilityValue = ZERO; + + const spotMarketAccount: SpotMarketAccount = + this.driftClient.getSpotMarketAccount(spotPosition.marketIndex); + + const { tokenValue, ordersValue } = getWorstCaseTokenAmounts( + spotPosition, + spotMarketAccount, + strictOraclePrice, + 'Initial', + this.getUserAccount().maxMarginRatio + ); + + if (tokenValue.gte(ZERO)) { + totalAssetValue = tokenValue; + } else { + totalLiabilityValue = tokenValue.abs(); + } + + if (ordersValue.gt(ZERO)) { + totalAssetValue = totalAssetValue.add(ordersValue); + } else { + totalLiabilityValue = totalLiabilityValue.add(ordersValue.abs()); + } + + return { + totalAssetValue, + totalLiabilityValue, + }; + } + + /** + * Estimates what the user leverage will be after swap + * @param inMarketIndex + * @param outMarketIndex + * @param inAmount + * @param outAmount + */ + public accountLeverageAfterSwap({ + inMarketIndex, + outMarketIndex, + inAmount, + outAmount, + }: { + inMarketIndex: number; + outMarketIndex: number; + inAmount: BN; + outAmount: BN; + }): BN { + const inMarket = this.driftClient.getSpotMarketAccount(inMarketIndex); + const outMarket = this.driftClient.getSpotMarketAccount(outMarketIndex); + + const inOraclePriceData = this.getOracleDataForSpotMarket(inMarketIndex); + const inOraclePrice = inOraclePriceData.price; + const outOraclePriceData = this.getOracleDataForSpotMarket(outMarketIndex); + const outOraclePrice = outOraclePriceData.price; + const inStrictOraclePrice = new StrictOraclePrice(inOraclePrice); + const outStrictOraclePrice = new StrictOraclePrice(outOraclePrice); + + const inSpotPosition = + this.getSpotPosition(inMarketIndex) || + this.getEmptySpotPosition(inMarketIndex); + const outSpotPosition = + this.getSpotPosition(outMarketIndex) || + this.getEmptySpotPosition(outMarketIndex); + + const { + totalAssetValue: inTotalAssetValueInitial, + totalLiabilityValue: inTotalLiabilityValueInitial, + } = this.calculateSpotPositionLeverageContribution( + inSpotPosition, + inStrictOraclePrice + ); + const { + totalAssetValue: outTotalAssetValueInitial, + totalLiabilityValue: outTotalLiabilityValueInitial, + } = this.calculateSpotPositionLeverageContribution( + outSpotPosition, + outStrictOraclePrice + ); + + const { perpLiabilityValue, perpPnl, spotAssetValue, spotLiabilityValue } = + this.getLeverageComponents(); + + const inPositionAfter = this.cloneAndUpdateSpotPosition( + inSpotPosition, + inAmount.abs().neg(), + inMarket + ); + const outPositionAfter = this.cloneAndUpdateSpotPosition( + outSpotPosition, + outAmount.abs(), + outMarket + ); + + const { + totalAssetValue: inTotalAssetValueAfter, + totalLiabilityValue: inTotalLiabilityValueAfter, + } = this.calculateSpotPositionLeverageContribution( + inPositionAfter, + inStrictOraclePrice + ); + + const { + totalAssetValue: outTotalAssetValueAfter, + totalLiabilityValue: outTotalLiabilityValueAfter, + } = this.calculateSpotPositionLeverageContribution( + outPositionAfter, + outStrictOraclePrice + ); + + const spotAssetValueDelta = inTotalAssetValueAfter + .add(outTotalAssetValueAfter) + .sub(inTotalAssetValueInitial) + .sub(outTotalAssetValueInitial); + const spotLiabilityValueDelta = inTotalLiabilityValueAfter + .add(outTotalLiabilityValueAfter) + .sub(inTotalLiabilityValueInitial) + .sub(outTotalLiabilityValueInitial); + + const spotAssetValueAfter = spotAssetValue.add(spotAssetValueDelta); + const spotLiabilityValueAfter = spotLiabilityValue.add( + spotLiabilityValueDelta + ); + + return this.calculateLeverageFromComponents({ + perpLiabilityValue, + perpPnl, + spotAssetValue: spotAssetValueAfter, + spotLiabilityValue: spotLiabilityValueAfter, + }); + } + + // TODO - should this take the price impact of the trade into account for strict accuracy? + + /** + * Returns the leverage ratio for the account after adding (or subtracting) the given quote size to the given position + * @param targetMarketIndex + * @param: targetMarketType + * @param tradeQuoteAmount + * @param tradeSide + * @param includeOpenOrders + * @returns leverageRatio : Precision TEN_THOUSAND + */ + public accountLeverageRatioAfterTrade( + targetMarketIndex: number, + targetMarketType: MarketType, + tradeQuoteAmount: BN, + tradeSide: PositionDirection, + includeOpenOrders = true + ): BN { + const tradeIsPerp = isVariant(targetMarketType, 'perp'); + + if (!tradeIsPerp) { + // calculate new asset/liability values for base and quote market to find new account leverage + const totalLiabilityValue = this.getTotalLiabilityValue(); + const totalAssetValue = this.getTotalAssetValue(); + const spotLiabilityValue = this.getSpotMarketLiabilityValue( + undefined, + undefined, + undefined, + includeOpenOrders + ); + + const currentQuoteAssetValue = this.getSpotMarketAssetValue( + QUOTE_SPOT_MARKET_INDEX, + undefined, + includeOpenOrders + ); + const currentQuoteLiabilityValue = this.getSpotMarketLiabilityValue( + QUOTE_SPOT_MARKET_INDEX, + undefined, + undefined, + includeOpenOrders + ); + const currentQuoteValue = currentQuoteAssetValue.sub( + currentQuoteLiabilityValue + ); + + const currentSpotMarketAssetValue = this.getSpotMarketAssetValue( + targetMarketIndex, + undefined, + includeOpenOrders + ); + const currentSpotMarketLiabilityValue = this.getSpotMarketLiabilityValue( + targetMarketIndex, + undefined, + undefined, + includeOpenOrders + ); + const currentSpotMarketNetValue = currentSpotMarketAssetValue.sub( + currentSpotMarketLiabilityValue + ); + + let assetValueToAdd = ZERO; + let liabilityValueToAdd = ZERO; + + const newQuoteNetValue = + tradeSide == PositionDirection.SHORT + ? currentQuoteValue.add(tradeQuoteAmount) + : currentQuoteValue.sub(tradeQuoteAmount); + const newQuoteAssetValue = BN.max(newQuoteNetValue, ZERO); + const newQuoteLiabilityValue = BN.min(newQuoteNetValue, ZERO).abs(); + + assetValueToAdd = assetValueToAdd.add( + newQuoteAssetValue.sub(currentQuoteAssetValue) + ); + liabilityValueToAdd = liabilityValueToAdd.add( + newQuoteLiabilityValue.sub(currentQuoteLiabilityValue) + ); + + const newSpotMarketNetValue = + tradeSide == PositionDirection.LONG + ? currentSpotMarketNetValue.add(tradeQuoteAmount) + : currentSpotMarketNetValue.sub(tradeQuoteAmount); + const newSpotMarketAssetValue = BN.max(newSpotMarketNetValue, ZERO); + const newSpotMarketLiabilityValue = BN.min( + newSpotMarketNetValue, + ZERO + ).abs(); + + assetValueToAdd = assetValueToAdd.add( + newSpotMarketAssetValue.sub(currentSpotMarketAssetValue) + ); + liabilityValueToAdd = liabilityValueToAdd.add( + newSpotMarketLiabilityValue.sub(currentSpotMarketLiabilityValue) + ); + + const totalAssetValueAfterTrade = totalAssetValue.add(assetValueToAdd); + const totalSpotLiabilityValueAfterTrade = + spotLiabilityValue.add(liabilityValueToAdd); + + const totalLiabilityValueAfterTrade = + totalLiabilityValue.add(liabilityValueToAdd); + + const netAssetValueAfterTrade = totalAssetValueAfterTrade.sub( + totalSpotLiabilityValueAfterTrade + ); + + if (netAssetValueAfterTrade.eq(ZERO)) { + return ZERO; + } + + const newLeverage = totalLiabilityValueAfterTrade + .mul(TEN_THOUSAND) + .div(netAssetValueAfterTrade); + + return newLeverage; + } + + const currentPosition = this.getPerpPositionOrEmpty(targetMarketIndex); + + const perpMarket = this.driftClient.getPerpMarketAccount(targetMarketIndex); + const oracleData = this.getOracleDataForPerpMarket(targetMarketIndex); + + let { + // eslint-disable-next-line prefer-const + worstCaseBaseAssetAmount: worstCaseBase, + worstCaseLiabilityValue: currentPositionQuoteAmount, + } = calculateWorstCasePerpLiabilityValue( + currentPosition, + perpMarket, + oracleData.price + ); + + // current side is short if position base asset amount is negative OR there is no position open but open orders are short + const currentSide = + currentPosition.baseAssetAmount.isNeg() || + (currentPosition.baseAssetAmount.eq(ZERO) && worstCaseBase.isNeg()) + ? PositionDirection.SHORT + : PositionDirection.LONG; + + if (currentSide === PositionDirection.SHORT) + currentPositionQuoteAmount = currentPositionQuoteAmount.neg(); + + if (tradeSide === PositionDirection.SHORT) + tradeQuoteAmount = tradeQuoteAmount.neg(); + + const currentPerpPositionAfterTrade = currentPositionQuoteAmount + .add(tradeQuoteAmount) + .abs(); + + const totalPositionAfterTradeExcludingTargetMarket = + this.getTotalPerpPositionValueExcludingMarket( + targetMarketIndex, + undefined, + undefined, + includeOpenOrders + ); + + const totalAssetValue = this.getTotalAssetValue(); + + const totalPerpPositionLiability = currentPerpPositionAfterTrade + .add(totalPositionAfterTradeExcludingTargetMarket) + .abs(); + + const totalSpotLiability = this.getSpotMarketLiabilityValue( + undefined, + undefined, + undefined, + includeOpenOrders + ); + + const totalLiabilitiesAfterTrade = + totalPerpPositionLiability.add(totalSpotLiability); + + const netAssetValue = totalAssetValue.sub(totalSpotLiability); + + if (netAssetValue.eq(ZERO)) { + return ZERO; + } + + const newLeverage = totalLiabilitiesAfterTrade + .mul(TEN_THOUSAND) + .div(netAssetValue); + + return newLeverage; + } + + public getUserFeeTier(marketType: MarketType, now?: BN) { + const state = this.driftClient.getStateAccount(); + + const feeTierIndex = 0; + if (isVariant(marketType, 'perp')) { + if (this.isHighLeverageMode('Initial')) { + return state.perpFeeStructure.feeTiers[0]; + } + + const userStatsAccount: UserStatsAccount = this.driftClient + .getUserStats() + .getAccount(); + + const total30dVolume = getUser30dRollingVolumeEstimate( + userStatsAccount, + now + ); + const stakedGovAssetAmount = userStatsAccount.ifStakedGovTokenAmount; + + const volumeThresholds = [ + new BN(2_000_000).mul(QUOTE_PRECISION), + new BN(10_000_000).mul(QUOTE_PRECISION), + new BN(20_000_000).mul(QUOTE_PRECISION), + new BN(80_000_000).mul(QUOTE_PRECISION), + new BN(200_000_000).mul(QUOTE_PRECISION), + ]; + const stakeThresholds = [ + new BN(1_000 - 1).mul(QUOTE_PRECISION), + new BN(10_000 - 1).mul(QUOTE_PRECISION), + new BN(50_000 - 1).mul(QUOTE_PRECISION), + new BN(100_000 - 1).mul(QUOTE_PRECISION), + new BN(250_000 - 5).mul(QUOTE_PRECISION), + ]; + const stakeBenefitFrac = [0, 5, 10, 20, 30, 40]; + + let feeTierIndex = 5; + for (let i = 0; i < volumeThresholds.length; i++) { + if (total30dVolume.lt(volumeThresholds[i])) { + feeTierIndex = i; + break; + } + } + + let stakeBenefitIndex = 5; + for (let i = 0; i < stakeThresholds.length; i++) { + if (stakedGovAssetAmount.lt(stakeThresholds[i])) { + stakeBenefitIndex = i; + break; + } + } + + const stakeBenefit = stakeBenefitFrac[stakeBenefitIndex]; + + const tier = { ...state.perpFeeStructure.feeTiers[feeTierIndex] }; + + if (stakeBenefit > 0) { + tier.feeNumerator = (tier.feeNumerator * (100 - stakeBenefit)) / 100; + + tier.makerRebateNumerator = + (tier.makerRebateNumerator * (100 + stakeBenefit)) / 100; + } + + return tier; + } + + return state.spotFeeStructure.feeTiers[feeTierIndex]; + } + + /** + * Calculates how much perp fee will be taken for a given sized trade + * @param quoteAmount + * @returns feeForQuote : Precision QUOTE_PRECISION + */ + public calculateFeeForQuoteAmount( + quoteAmount: BN, + marketIndex?: number, + enteringHighLeverageMode?: boolean + ): BN { + if (marketIndex !== undefined) { + const takerFeeMultiplier = this.driftClient.getMarketFees( + MarketType.PERP, + marketIndex, + this, + enteringHighLeverageMode + ).takerFee; + const feeAmountNum = + BigNum.from(quoteAmount, QUOTE_PRECISION_EXP).toNum() * + takerFeeMultiplier; + return BigNum.fromPrint(feeAmountNum.toString(), QUOTE_PRECISION_EXP).val; + } else { + const feeTier = this.getUserFeeTier(MarketType.PERP); + return quoteAmount + .mul(new BN(feeTier.feeNumerator)) + .div(new BN(feeTier.feeDenominator)); + } + } + + /** + * Calculates a user's max withdrawal amounts for a spot market. If reduceOnly is true, + * it will return the max withdrawal amount without opening a liability for the user + * @param marketIndex + * @returns withdrawalLimit : Precision is the token precision for the chosen SpotMarket + */ + public getWithdrawalLimit(marketIndex: number, reduceOnly?: boolean): BN { + const nowTs = new BN(Math.floor(Date.now() / 1000)); + const spotMarket = this.driftClient.getSpotMarketAccount(marketIndex); + + // eslint-disable-next-line prefer-const + let { borrowLimit, withdrawLimit } = calculateWithdrawLimit( + spotMarket, + nowTs + ); + + const freeCollateral = this.getFreeCollateral(); + const initialMarginRequirement = this.getInitialMarginRequirement(); + const oracleData = this.getOracleDataForSpotMarket(marketIndex); + const { numeratorScale, denominatorScale } = + spotMarket.decimals > 6 + ? { + numeratorScale: new BN(10).pow(new BN(spotMarket.decimals - 6)), + denominatorScale: new BN(1), + } + : { + numeratorScale: new BN(1), + denominatorScale: new BN(10).pow(new BN(6 - spotMarket.decimals)), + }; + + const { canBypass, depositAmount: userDepositAmount } = + this.canBypassWithdrawLimits(marketIndex); + if (canBypass) { + withdrawLimit = BN.max(withdrawLimit, userDepositAmount); + } + + const assetWeight = calculateAssetWeight( + userDepositAmount, + oracleData.price, + spotMarket, + 'Initial' + ); + + let amountWithdrawable; + if (assetWeight.eq(ZERO)) { + amountWithdrawable = userDepositAmount; + } else if (initialMarginRequirement.eq(ZERO)) { + amountWithdrawable = userDepositAmount; + } else { + amountWithdrawable = divCeil( + divCeil(freeCollateral.mul(MARGIN_PRECISION), assetWeight).mul( + PRICE_PRECISION + ), + oracleData.price + ) + .mul(numeratorScale) + .div(denominatorScale); + } + + const maxWithdrawValue = BN.min( + BN.min(amountWithdrawable, userDepositAmount), + withdrawLimit.abs() + ); + + if (reduceOnly) { + return BN.max(maxWithdrawValue, ZERO); + } else { + const weightedAssetValue = this.getSpotMarketAssetValue( + marketIndex, + 'Initial', + false + ); + + const freeCollatAfterWithdraw = userDepositAmount.gt(ZERO) + ? freeCollateral.sub(weightedAssetValue) + : freeCollateral; + + const maxLiabilityAllowed = freeCollatAfterWithdraw + .mul(MARGIN_PRECISION) + .div(new BN(spotMarket.initialLiabilityWeight)) + .mul(PRICE_PRECISION) + .div(oracleData.price) + .mul(numeratorScale) + .div(denominatorScale); + + const maxBorrowValue = BN.min( + maxWithdrawValue.add(maxLiabilityAllowed), + borrowLimit.abs() + ); + + return BN.max(maxBorrowValue, ZERO); + } + } + + public canBypassWithdrawLimits(marketIndex: number): { + canBypass: boolean; + netDeposits: BN; + depositAmount: BN; + maxDepositAmount: BN; + } { + const spotMarket = this.driftClient.getSpotMarketAccount(marketIndex); + const maxDepositAmount = spotMarket.withdrawGuardThreshold.div(new BN(10)); + const position = this.getSpotPosition(marketIndex); + + const netDeposits = this.getUserAccount().totalDeposits.sub( + this.getUserAccount().totalWithdraws + ); + + if (!position) { + return { + canBypass: false, + maxDepositAmount, + depositAmount: ZERO, + netDeposits, + }; + } + + if (isVariant(position.balanceType, 'borrow')) { + return { + canBypass: false, + maxDepositAmount, + netDeposits, + depositAmount: ZERO, + }; + } + + const depositAmount = getTokenAmount( + position.scaledBalance, + spotMarket, + SpotBalanceType.DEPOSIT + ); + + if (netDeposits.lt(ZERO)) { + return { + canBypass: false, + maxDepositAmount, + depositAmount, + netDeposits, + }; + } + + return { + canBypass: depositAmount.lt(maxDepositAmount), + maxDepositAmount, + netDeposits, + depositAmount, + }; + } + + public canMakeIdle(slot: BN): boolean { + const userAccount = this.getUserAccount(); + if (userAccount.idle) { + return false; + } + + const { totalAssetValue, totalLiabilityValue } = + this.getSpotMarketAssetAndLiabilityValue(); + const equity = totalAssetValue.sub(totalLiabilityValue); + + let slotsBeforeIdle: BN; + if (equity.lt(QUOTE_PRECISION.muln(1000))) { + slotsBeforeIdle = new BN(9000); // 1 hour + } else { + slotsBeforeIdle = new BN(1512000); // 1 week + } + + const userLastActiveSlot = userAccount.lastActiveSlot; + const slotsSinceLastActive = slot.sub(userLastActiveSlot); + if (slotsSinceLastActive.lt(slotsBeforeIdle)) { + return false; + } + + if (this.isBeingLiquidated()) { + return false; + } + + for (const perpPosition of userAccount.perpPositions) { + if (!positionIsAvailable(perpPosition)) { + return false; + } + } + + for (const spotPosition of userAccount.spotPositions) { + if ( + isVariant(spotPosition.balanceType, 'borrow') && + spotPosition.scaledBalance.gt(ZERO) + ) { + return false; + } + + if (spotPosition.openOrders !== 0) { + return false; + } + } + + for (const order of userAccount.orders) { + if (isVariant(order.status, 'open')) { + return false; + } + } + + return true; + } + + public getSafestTiers(): { perpTier: number; spotTier: number } { + let safestPerpTier = 4; + let safestSpotTier = 4; + + for (const perpPosition of this.getActivePerpPositions()) { + safestPerpTier = Math.min( + safestPerpTier, + getPerpMarketTierNumber( + this.driftClient.getPerpMarketAccount(perpPosition.marketIndex) + ) + ); + } + + for (const spotPosition of this.getActiveSpotPositions()) { + if (isVariant(spotPosition.balanceType, 'deposit')) { + continue; + } + + safestSpotTier = Math.min( + safestSpotTier, + getSpotMarketTierNumber( + this.driftClient.getSpotMarketAccount(spotPosition.marketIndex) + ) + ); + } + + return { + perpTier: safestPerpTier, + spotTier: safestSpotTier, + }; + } + + public getPerpPositionHealth({ + marginCategory, + perpPosition, + oraclePriceData, + quoteOraclePriceData, + includeOpenOrders = true, + }: { + marginCategory: MarginCategory; + perpPosition: PerpPosition; + oraclePriceData?: OraclePriceData; + quoteOraclePriceData?: OraclePriceData; + includeOpenOrders?: boolean; + }): HealthComponent { + const perpMarket = this.driftClient.getPerpMarketAccount( + perpPosition.marketIndex + ); + const _oraclePriceData = + oraclePriceData || + this.driftClient.getOracleDataForPerpMarket(perpMarket.marketIndex); + const oraclePrice = _oraclePriceData.price; + + let worstCaseBaseAmount; + let worstCaseLiabilityValue; + if (includeOpenOrders) { + const worstCaseIncludeOrders = calculateWorstCasePerpLiabilityValue( + perpPosition, + perpMarket, + oraclePrice + ); + worstCaseBaseAmount = worstCaseIncludeOrders.worstCaseBaseAssetAmount; + worstCaseLiabilityValue = worstCaseIncludeOrders.worstCaseLiabilityValue; + } else { + worstCaseBaseAmount = perpPosition.baseAssetAmount; + worstCaseLiabilityValue = calculatePerpLiabilityValue( + perpPosition.baseAssetAmount, + oraclePrice, + isVariant(perpMarket.contractType, 'prediction') + ); + } + + const userCustomMargin = Math.max( + perpPosition.maxMarginRatio, + this.getUserAccount().maxMarginRatio + ); + const marginRatio = new BN( + calculateMarketMarginRatio( + perpMarket, + worstCaseBaseAmount.abs(), + marginCategory, + userCustomMargin, + this.isHighLeverageMode(marginCategory) + ) + ); + + const _quoteOraclePriceData = + quoteOraclePriceData || + this.driftClient.getOracleDataForSpotMarket(QUOTE_SPOT_MARKET_INDEX); + + let marginRequirement = worstCaseLiabilityValue + .mul(_quoteOraclePriceData.price) + .div(PRICE_PRECISION) + .mul(marginRatio) + .div(MARGIN_PRECISION); + + marginRequirement = marginRequirement.add( + new BN(perpPosition.openOrders).mul(OPEN_ORDER_MARGIN_REQUIREMENT) + ); + + return { + marketIndex: perpMarket.marketIndex, + size: worstCaseBaseAmount, + value: worstCaseLiabilityValue, + weight: marginRatio, + weightedValue: marginRequirement, + }; + } + + public getHealthComponents({ + marginCategory, + }: { + marginCategory: MarginCategory; + }): HealthComponents { + const healthComponents: HealthComponents = { + deposits: [], + borrows: [], + perpPositions: [], + perpPnl: [], + }; + + for (const perpPosition of this.getActivePerpPositions()) { + const perpMarket = this.driftClient.getPerpMarketAccount( + perpPosition.marketIndex + ); + + const oraclePriceData = this.driftClient.getOracleDataForPerpMarket( + perpMarket.marketIndex + ); + + const quoteOraclePriceData = this.driftClient.getOracleDataForSpotMarket( + QUOTE_SPOT_MARKET_INDEX + ); + + healthComponents.perpPositions.push( + this.getPerpPositionHealth({ + marginCategory, + perpPosition, + oraclePriceData, + quoteOraclePriceData, + }) + ); + + const quoteSpotMarket = this.driftClient.getSpotMarketAccount( + perpMarket.quoteSpotMarketIndex + ); + + const positionUnrealizedPnl = calculatePositionPNL( + perpMarket, + perpPosition, + true, + oraclePriceData + ); + + let pnlWeight; + if (positionUnrealizedPnl.gt(ZERO)) { + pnlWeight = calculateUnrealizedAssetWeight( + perpMarket, + quoteSpotMarket, + positionUnrealizedPnl, + marginCategory, + oraclePriceData + ); + } else { + pnlWeight = SPOT_MARKET_WEIGHT_PRECISION; + } + + const pnlValue = positionUnrealizedPnl + .mul(quoteOraclePriceData.price) + .div(PRICE_PRECISION); + + const wegithedPnlValue = pnlValue + .mul(pnlWeight) + .div(SPOT_MARKET_WEIGHT_PRECISION); + + healthComponents.perpPnl.push({ + marketIndex: perpMarket.marketIndex, + size: positionUnrealizedPnl, + value: pnlValue, + weight: pnlWeight, + weightedValue: wegithedPnlValue, + }); + } + + let netQuoteValue = ZERO; + for (const spotPosition of this.getActiveSpotPositions()) { + const spotMarketAccount: SpotMarketAccount = + this.driftClient.getSpotMarketAccount(spotPosition.marketIndex); + + const oraclePriceData = this.getOracleDataForSpotMarket( + spotPosition.marketIndex + ); + + const strictOraclePrice = new StrictOraclePrice(oraclePriceData.price); + + if (spotPosition.marketIndex === QUOTE_SPOT_MARKET_INDEX) { + const tokenAmount = getSignedTokenAmount( + getTokenAmount( + spotPosition.scaledBalance, + spotMarketAccount, + spotPosition.balanceType + ), + spotPosition.balanceType + ); + + netQuoteValue = netQuoteValue.add(tokenAmount); + continue; + } + + const { + tokenAmount: worstCaseTokenAmount, + tokenValue: tokenValue, + weight, + weightedTokenValue: weightedTokenValue, + ordersValue: ordersValue, + } = getWorstCaseTokenAmounts( + spotPosition, + spotMarketAccount, + strictOraclePrice, + marginCategory, + this.getUserAccount().maxMarginRatio + ); + + netQuoteValue = netQuoteValue.add(ordersValue); + + const baseAssetValue = tokenValue.abs(); + const weightedValue = weightedTokenValue.abs(); + + if (weightedTokenValue.lt(ZERO)) { + healthComponents.borrows.push({ + marketIndex: spotMarketAccount.marketIndex, + size: worstCaseTokenAmount, + value: baseAssetValue, + weight: weight, + weightedValue: weightedValue, + }); + } else { + healthComponents.deposits.push({ + marketIndex: spotMarketAccount.marketIndex, + size: worstCaseTokenAmount, + value: baseAssetValue, + weight: weight, + weightedValue: weightedValue, + }); + } + } + + if (!netQuoteValue.eq(ZERO)) { + const spotMarketAccount = this.driftClient.getQuoteSpotMarketAccount(); + const oraclePriceData = this.getOracleDataForSpotMarket( + QUOTE_SPOT_MARKET_INDEX + ); + + const baseAssetValue = getTokenValue( + netQuoteValue, + spotMarketAccount.decimals, + oraclePriceData + ); + + const { weight, weightedTokenValue } = calculateWeightedTokenValue( + netQuoteValue, + baseAssetValue, + oraclePriceData.price, + spotMarketAccount, + marginCategory, + this.getUserAccount().maxMarginRatio + ); + + if (netQuoteValue.lt(ZERO)) { + healthComponents.borrows.push({ + marketIndex: spotMarketAccount.marketIndex, + size: netQuoteValue, + value: baseAssetValue.abs(), + weight: weight, + weightedValue: weightedTokenValue.abs(), + }); + } else { + healthComponents.deposits.push({ + marketIndex: spotMarketAccount.marketIndex, + size: netQuoteValue, + value: baseAssetValue, + weight: weight, + weightedValue: weightedTokenValue, + }); + } + } + + return healthComponents; + } + + /** + * Get the total position value, excluding any position coming from the given target market + * @param marketToIgnore + * @returns positionValue : Precision QUOTE_PRECISION + */ + private getTotalPerpPositionValueExcludingMarket( + marketToIgnore: number, + marginCategory?: MarginCategory, + liquidationBuffer?: BN, + includeOpenOrders?: boolean + ): BN { + const currentPerpPosition = this.getPerpPositionOrEmpty(marketToIgnore); + + const oracleData = this.getOracleDataForPerpMarket(marketToIgnore); + + let currentPerpPositionValueUSDC = ZERO; + if (currentPerpPosition) { + currentPerpPositionValueUSDC = this.getPerpLiabilityValue( + marketToIgnore, + oracleData, + includeOpenOrders + ); + } + + return this.getTotalPerpPositionLiability( + marginCategory, + liquidationBuffer, + includeOpenOrders + ).sub(currentPerpPositionValueUSDC); + } + + private getMMOracleDataForPerpMarket(marketIndex: number): MMOraclePriceData { + return this.driftClient.getMMOracleDataForPerpMarket(marketIndex); + } + + private getOracleDataForPerpMarket(marketIndex: number): OraclePriceData { + return this.driftClient.getOracleDataForPerpMarket(marketIndex); + } + + private getOracleDataForSpotMarket(marketIndex: number): OraclePriceData { + return this.driftClient.getOracleDataForSpotMarket(marketIndex); + } + + /** + * Get the active perp and spot positions of the user. + */ + public getActivePositions(): { + activePerpPositions: number[]; + activeSpotPositions: number[]; + } { + const activePerpMarkets = this.getActivePerpPositions().map( + (position) => position.marketIndex + ); + + const activeSpotMarkets = this.getActiveSpotPositions().map( + (position) => position.marketIndex + ); + + return { + activePerpPositions: activePerpMarkets, + activeSpotPositions: activeSpotMarkets, + }; + } +} From e9872e17467cc8f429d37293bca2c650150e6c75 Mon Sep 17 00:00:00 2001 From: Lukas deConantsesznak Date: Mon, 10 Nov 2025 09:09:19 -0700 Subject: [PATCH 08/13] fix: linting errors --- sdk/scripts/compare-user-parity.ts | 29 ++++++++++++++--------------- sdk/src/user.ts | 3 --- 2 files changed, 14 insertions(+), 18 deletions(-) diff --git a/sdk/scripts/compare-user-parity.ts b/sdk/scripts/compare-user-parity.ts index b388552f82..4437258536 100644 --- a/sdk/scripts/compare-user-parity.ts +++ b/sdk/scripts/compare-user-parity.ts @@ -164,24 +164,23 @@ async function main(): Promise { ) : Array.from(userMap.entries()); + function noteMismatch(functionName: string, userPubkey): void { + mismatchesByFunction[functionName] = + (mismatchesByFunction[functionName] ?? 0) + 1; + usersWithDiscrepancies.add(userPubkey.toBase58()); + mismatches += 1; + } + for (const [userKey, currUser] of usersFilterd) { usersChecked += 1; const userPubkey = new PublicKey(userKey); - - function noteMismatch(functionName: string): void { - mismatchesByFunction[functionName] = - (mismatchesByFunction[functionName] ?? 0) + 1; - usersWithDiscrepancies.add(userPubkey.toBase58()); - mismatches += 1; - } - // clean curr User position flags to be all 0 currUser.getActivePerpPositions().forEach((position) => { position.positionFlag = 0; }); - const oldUser = buildOldUserFromSnapshot(driftClient, currUser, COMMITMENT); + const oldUser = buildOldUserFromSnapshot(driftClient, currUser); try { // Cross-account level comparisons @@ -202,7 +201,7 @@ async function main(): Promise { vNew_fc, vOld_fc ); - noteMismatch('getFreeCollateral'); + noteMismatch('getFreeCollateral', userPubkey); } // only do free collateral for now @@ -219,7 +218,7 @@ async function main(): Promise { vNew_tc, vOld_tc ); - noteMismatch('getTotalCollateral'); + noteMismatch('getTotalCollateral', userPubkey); } // getMarginRequirement (strict=true, includeOpenOrders=true) @@ -243,7 +242,7 @@ async function main(): Promise { vNew_mr, vOld_mr ); - noteMismatch('getMarginRequirement'); + noteMismatch('getMarginRequirement', userPubkey); } } // continue; @@ -264,7 +263,7 @@ async function main(): Promise { vNew_pbp, vOld_pbp ); - noteMismatch('getPerpBuyingPower'); + noteMismatch('getPerpBuyingPower', userPubkey); } // liquidationPrice (defaults) @@ -278,7 +277,7 @@ async function main(): Promise { vNew_lp, vOld_lp ); - noteMismatch('liquidationPrice'); + noteMismatch('liquidationPrice', userPubkey); } // liquidationPriceAfterClose with 10% of current quote as close amount (skip if zero/absent) @@ -303,7 +302,7 @@ async function main(): Promise { vNew_lpac, vOld_lpac ); - noteMismatch('liquidationPriceAfterClose'); + noteMismatch('liquidationPriceAfterClose', userPubkey); } } } diff --git a/sdk/src/user.ts b/sdk/src/user.ts index fad96cd122..5519f70c23 100644 --- a/sdk/src/user.ts +++ b/sdk/src/user.ts @@ -4212,8 +4212,6 @@ export class User { const marginBuffer = opts?.liquidationBuffer; // treat as MARGIN_BUFFER ratio if provided const marginRatioOverride = opts?.marginRatioOverride; - let totalUnrealizedPnl = ZERO; - // Equivalent to on-chain user_custom_margin_ratio let userCustomMarginRatio = marginCategory === 'Initial' ? this.getUserAccount().maxMarginRatio : 0; @@ -4293,7 +4291,6 @@ export class User { tokenAmount: worstCaseTokenAmount, ordersValue: worstCaseOrdersValue, tokenValue: worstCaseTokenValue, - weightedTokenValue: worstCaseWeightedTokenValue, } = getWorstCaseTokenAmounts( spotPosition, spotMarket, From 705d13a985828614ee11d45717ec19713ea689c3 Mon Sep 17 00:00:00 2001 From: Lukas deConantsesznak Date: Mon, 10 Nov 2025 15:36:36 -0700 Subject: [PATCH 09/13] fix: some pr feedback, additional undefined checks + better margin logic --- sdk/src/user.ts | 90 ++++++++++++++++++++++++++++++++++--------------- 1 file changed, 63 insertions(+), 27 deletions(-) diff --git a/sdk/src/user.ts b/sdk/src/user.ts index 5519f70c23..03df8d0801 100644 --- a/sdk/src/user.ts +++ b/sdk/src/user.ts @@ -108,7 +108,11 @@ import { StrictOraclePrice } from './oracles/strictOraclePrice'; import { calculateSpotFuelBonus, calculatePerpFuelBonus } from './math/fuel'; import { grpcUserAccountSubscriber } from './accounts/grpcUserAccountSubscriber'; -import { MarginCalculation, MarginContext } from './marginCalculation'; +import { + IsolatedMarginCalculation, + MarginCalculation, + MarginContext, +} from './marginCalculation'; export type MarginType = 'Cross' | 'Isolated'; @@ -350,6 +354,7 @@ export class User { public getIsolatePerpPositionTokenAmount(perpMarketIndex: number): BN { const perpPosition = this.getPerpPosition(perpMarketIndex); + if (!perpPosition) return ZERO; const perpMarket = this.driftClient.getPerpMarketAccount(perpMarketIndex); const spotMarket = this.driftClient.getSpotMarketAccount( perpMarket.quoteSpotMarketIndex @@ -609,6 +614,7 @@ export class User { if (perpMarketIndex !== undefined) { const isolatedMarginCalculation = marginCalc.isolatedMarginCalculations.get(perpMarketIndex); + if (!isolatedMarginCalculation) return ZERO; const { marginRequirement, marginRequirementPlusBuffer } = isolatedMarginCalculation; @@ -1281,11 +1287,15 @@ export class User { * @returns : number (value from [0, 100]) */ public getHealth(perpMarketIndex?: number): number { - const marginCalc = this.getMarginCalculation('Maintenance'); - if (this.isCrossMarginBeingLiquidated(marginCalc) && !perpMarketIndex) { + if (this.isCrossMarginBeingLiquidated() && !perpMarketIndex) { + return 0; + } + if (this.hasIsolatedPositionBeingLiquidated() && perpMarketIndex) { return 0; } + const marginCalc = this.getMarginCalculation('Maintenance'); + let totalCollateral: BN; let maintenanceMarginReq: BN; @@ -1671,8 +1681,14 @@ export class User { oraclePriceData ); + const tokenAmount = getTokenAmount( + perpPosition.isolatedPositionScaledBalance ?? ZERO, + quoteSpotMarket, + SpotBalanceType.DEPOSIT + ); + const spotAssetValue = getStrictTokenValue( - perpPosition.isolatedPositionScaledBalance ?? ZERO, //TODO remove ? later + tokenAmount, quoteSpotMarket.decimals, strictOracle ); @@ -2051,23 +2067,49 @@ export class User { ); } + public isCrossMarginBeingLiquidated(): boolean { + return ( + (this.getUserAccount().status & + (UserStatus.BEING_LIQUIDATED | UserStatus.BANKRUPT)) > + 0 + ); + } + /** Returns true if cross margin is currently below maintenance requirement (no buffer). */ - public isCrossMarginBeingLiquidated(marginCalc?: MarginCalculation): boolean { + public canCrossMarginBeLiquidated(marginCalc?: MarginCalculation): boolean { const calc = marginCalc ?? this.getMarginCalculation('Maintenance'); return calc.totalCollateral.lt(calc.marginRequirement); } + public hasIsolatedPositionBeingLiquidated(): boolean { + return this.getActivePerpPositions().some( + (position) => + (position.positionFlag & + (PositionFlag.BeingLiquidated | PositionFlag.Bankruptcy)) > + 0 + ); + } + /** Returns true if any isolated perp position is currently below its maintenance requirement (no buffer). */ - public isIsolatedMarginBeingLiquidated( + public getLiquidatableIsolatedPositions( marginCalc?: MarginCalculation - ): boolean { + ): number[] { + const liquidatableIsolatedPositions = []; const calc = marginCalc ?? this.getMarginCalculation('Maintenance'); - for (const [, isoCalc] of calc.isolatedMarginCalculations) { - if (isoCalc.totalCollateral.lt(isoCalc.marginRequirement)) { - return true; + for (const [marketIndex, isoCalc] of calc.isolatedMarginCalculations) { + if (this.canIsolatedPositionMarginBeLiquidated(isoCalc)) { + liquidatableIsolatedPositions.push(marketIndex); } } - return false; + return liquidatableIsolatedPositions; + } + + public canIsolatedPositionMarginBeLiquidated( + isolatedMarginCalculation: IsolatedMarginCalculation + ): boolean { + return isolatedMarginCalculation.totalCollateral.lt( + isolatedMarginCalculation.marginRequirement + ); } public hasStatus(status: UserStatus): boolean { @@ -2247,6 +2289,7 @@ export class User { }); const isolatedMarginCalculation = marginCalculation.isolatedMarginCalculations.get(marketIndex); + if (!isolatedMarginCalculation) return ZERO; const { totalCollateral, marginRequirement } = isolatedMarginCalculation; const freeCollateral = BN.max( @@ -2287,10 +2330,6 @@ export class User { includeOpenOrders ); - // console.log( - // 'new user liq price totalCollateral', - // totalCollateral.toString() - // ); const marginRequirement = this.getMarginRequirement( marginCategory, undefined, @@ -2299,10 +2338,6 @@ export class User { enteringHighLeverage ); - // console.log( - // 'new user liq price marginRequirement', - // marginRequirement.toString() - // ); let freeCollateral = BN.max( ZERO, totalCollateral.sub(marginRequirement) @@ -2514,7 +2549,6 @@ export class User { this.getUserAccount().maxMarginRatio ); - // TODO: does this work in an isolated position context, cc perp const marginRatio = calculateMarketMarginRatio( market, proposedBaseAssetAmount.abs(), @@ -4467,6 +4501,15 @@ export class User { } } + // perp position liability + const hasPerpLiability = + !marketPosition.baseAssetAmount.eq(ZERO) || + marketPosition.quoteAssetAmount.lt(ZERO) || + marketPosition.openOrders !== 0; + if (hasPerpLiability) { + calc.addPerpLiability(); + } + // Add perp contribution: isolated vs cross const isIsolated = this.isPerpPositionIsolated(marketPosition); if (isIsolated) { @@ -4513,13 +4556,6 @@ export class User { worstCaseLiabilityValue ); calc.addCrossMarginTotalCollateral(positionUnrealizedPnl); - const hasPerpLiability = - !marketPosition.baseAssetAmount.eq(ZERO) || - marketPosition.quoteAssetAmount.lt(ZERO) || - marketPosition.openOrders !== 0; - if (hasPerpLiability) { - calc.addPerpLiability(); - } } } return calc; From c0a89fea3c45ce36ead03826ef50c94adef508a3 Mon Sep 17 00:00:00 2001 From: Lukas deConantsesznak Date: Tue, 11 Nov 2025 15:57:49 -0500 Subject: [PATCH 10/13] fix: better align margin calc with on chain + tests --- sdk/package.json | 1 + sdk/scripts/compare-user-parity.ts | 150 +++++++++++++++++---- sdk/src/marginCalculation.ts | 67 +++------- sdk/src/user.ts | 138 +++++++++---------- sdk/tests/user/getMarginCalculation.ts | 178 +++++++++++-------------- sdk/tests/user/helpers.ts | 98 +++++++++++++- sdk/tests/user/liquidations.ts | 129 ++++++++++++++++++ 7 files changed, 520 insertions(+), 241 deletions(-) create mode 100644 sdk/tests/user/liquidations.ts diff --git a/sdk/package.json b/sdk/package.json index 540d72539d..7d08ad6010 100644 --- a/sdk/package.json +++ b/sdk/package.json @@ -16,6 +16,7 @@ "build:browser": "yarn clean && tsc -p tsconfig.json && tsc -p tsconfig.browser.json && node scripts/postbuild.js --force-env browser", "clean": "rm -rf lib", "test": "mocha -r ts-node/register tests/**/*.ts --ignore 'tests/dlob/**/*.ts'", + "test:match": "mocha -r ts-node/register --ignore 'tests/dlob/**/*.ts'", "test:inspect": "mocha --inspect-brk -r ts-node/register tests/**/*.ts", "test:bignum": "mocha -r ts-node/register tests/bn/**/*.ts", "test:ci": "mocha -r ts-node/register tests/ci/**/*.ts", diff --git a/sdk/scripts/compare-user-parity.ts b/sdk/scripts/compare-user-parity.ts index 4437258536..10e0cd09ab 100644 --- a/sdk/scripts/compare-user-parity.ts +++ b/sdk/scripts/compare-user-parity.ts @@ -33,6 +33,19 @@ function bnEq(a: BN, b: BN): boolean { return a.eq(b); } +const EXPECTED_DIFF_THRESHOLD_BPS = 10; // 0.1% + +function isWithinRelativeBps(a: BN, b: BN, thresholdBps: number): boolean { + const aAbs = a.abs(); + const bAbs = b.abs(); + const maxAbs = aAbs.gt(bAbs) ? aAbs : bAbs; + if (maxAbs.isZero()) return true; // both zero + const diff = a.sub(b).abs(); + // diff / maxAbs <= thresholdBps / 10000 + // => diff * 10000 <= thresholdBps * maxAbs + return diff.mul(new BN(10000)).lte(maxAbs.mul(new BN(thresholdBps))); +} + function buildOldUserFromSnapshot( driftClient: DriftClient, currentUser: CurrentUser @@ -90,6 +103,40 @@ function logMismatch( ); } +function logWarning( + userPubkey: PublicKey, + fn: string, + args: Record, + vNew: BN, + vOld: BN +): void { + const argsLines = Object.keys(args) + .map((k) => `\t- ${k}: ${String(args[k])}`) + .join('|'); + console.warn( + `- ⚠️ expected change | user: ${userPubkey.toBase58()} | function: ${fn}\n` + + `- args:\n${argsLines || '\t- none'}\n` + + `- new: ${vNew.toString()} | old: ${vOld.toString()}\n` + ); +} + +function logBooleanMismatch( + userPubkey: PublicKey, + fn: string, + args: Record, + vNew: boolean, + vOld: boolean +): void { + const argsLines = Object.keys(args) + .map((k) => `\t- ${k}: ${String(args[k])}`) + .join('|'); + console.error( + `- ❌ user: ${userPubkey.toBase58()} | function: ${fn}\n` + + `- args:\n${argsLines || '\t- none'}\n` + + `- new: ${String(vNew)} | old: ${String(vOld)}\n` + ); +} + async function main(): Promise { const RPC_ENDPOINT = getEnv('RPC_ENDPOINT'); const COMMITMENT = asCommitment(process.env.COMMITMENT, 'processed'); @@ -193,7 +240,10 @@ async function main(): Promise { // getFreeCollateral const vNew_fc = currUser.getFreeCollateral(cat); const vOld_fc = oldUser.getFreeCollateral(cat); - if (!bnEq(vNew_fc, vOld_fc)) { + if ( + !bnEq(vNew_fc, vOld_fc) && + !isWithinRelativeBps(vNew_fc, vOld_fc, EXPECTED_DIFF_THRESHOLD_BPS) + ) { logMismatch( userPubkey, 'getFreeCollateral', @@ -211,14 +261,25 @@ async function main(): Promise { const vNew_tc = currUser.getTotalCollateral(cat); const vOld_tc = oldUser.getTotalCollateral(cat); if (!bnEq(vNew_tc, vOld_tc)) { - logMismatch( - userPubkey, - 'getTotalCollateral', - { marginCategory: cat }, - vNew_tc, - vOld_tc - ); - noteMismatch('getTotalCollateral', userPubkey); + if ( + isWithinRelativeBps(vNew_tc, vOld_tc, EXPECTED_DIFF_THRESHOLD_BPS) + ) { + logWarning( + userPubkey, + 'getTotalCollateral', + { marginCategory: cat }, + vNew_tc, + vOld_tc + ); + } else { + logWarning( + userPubkey, + 'getTotalCollateral', + { marginCategory: cat }, + vNew_tc, + vOld_tc + ); + } } // getMarginRequirement (strict=true, includeOpenOrders=true) @@ -235,17 +296,46 @@ async function main(): Promise { true ); if (!bnEq(vNew_mr, vOld_mr)) { - logMismatch( + if ( + isWithinRelativeBps(vNew_mr, vOld_mr, EXPECTED_DIFF_THRESHOLD_BPS) + ) { + logWarning( + userPubkey, + 'getMarginRequirement', + { marginCategory: cat, strict: true, includeOpenOrders: true }, + vNew_mr, + vOld_mr + ); + } else { + logWarning( + userPubkey, + 'getMarginRequirement', + { marginCategory: cat, strict: true, includeOpenOrders: true }, + vNew_mr, + vOld_mr + ); + } + } + } + // continue; + + // canBeLiquidated parity (cross margin status) + { + const vNew_liq = currUser.canBeLiquidated(); + const vOld_liq = oldUser.canBeLiquidated(); + + // boolean + if (vNew_liq.canBeLiquidated !== vOld_liq.canBeLiquidated) { + logBooleanMismatch( userPubkey, - 'getMarginRequirement', - { marginCategory: cat, strict: true, includeOpenOrders: true }, - vNew_mr, - vOld_mr + 'canBeLiquidated', + { field: 'canBeLiquidated' }, + vNew_liq.canBeLiquidated, + vOld_liq.canBeLiquidated ); - noteMismatch('getMarginRequirement', userPubkey); + noteMismatch('canBeLiquidated', userPubkey); } } - // continue; // Per-perp-market comparisons const activePerpPositions = currUser.getActivePerpPositions(); @@ -256,14 +346,26 @@ async function main(): Promise { const vNew_pbp = currUser.getPerpBuyingPower(marketIndex); const vOld_pbp = oldUser.getPerpBuyingPower(marketIndex); if (!bnEq(vNew_pbp, vOld_pbp)) { - logMismatch( - userPubkey, - 'getPerpBuyingPower', - { marketIndex }, - vNew_pbp, - vOld_pbp - ); - noteMismatch('getPerpBuyingPower', userPubkey); + if ( + isWithinRelativeBps(vNew_pbp, vOld_pbp, EXPECTED_DIFF_THRESHOLD_BPS) + ) { + logWarning( + userPubkey, + 'getPerpBuyingPower', + { marketIndex }, + vNew_pbp, + vOld_pbp + ); + } else { + logMismatch( + userPubkey, + 'getPerpBuyingPower', + { marketIndex }, + vNew_pbp, + vOld_pbp + ); + noteMismatch('getPerpBuyingPower', userPubkey); + } } // liquidationPrice (defaults) diff --git a/sdk/src/marginCalculation.ts b/sdk/src/marginCalculation.ts index c26333db00..9343e3d3d0 100644 --- a/sdk/src/marginCalculation.ts +++ b/sdk/src/marginCalculation.ts @@ -39,25 +39,24 @@ export class MarginContext { mode: MarginCalculationMode; strict: boolean; ignoreInvalidDepositOracles: boolean; - marginBuffer: BN; // scaled by MARGIN_PRECISION - marginRatioOverride?: number; + marginBuffer: Map; // scaled by MARGIN_PRECISION private constructor(marginType: MarginCategory) { this.marginType = marginType; this.mode = { type: 'Standard' }; this.strict = false; this.ignoreInvalidDepositOracles = false; - this.marginBuffer = new BN(0); + this.marginBuffer = new Map(); } static standard(marginType: MarginCategory): MarginContext { return new MarginContext(marginType); } - static liquidation(marginBuffer: BN): MarginContext { + static liquidation(marginBuffer: Map): MarginContext { const ctx = new MarginContext('Maintenance'); ctx.mode = { type: 'Liquidation' }; - ctx.marginBuffer = marginBuffer ?? new BN(0); + ctx.marginBuffer = marginBuffer; return ctx; } @@ -71,13 +70,8 @@ export class MarginContext { return this; } - setMarginBuffer(buffer?: BN): this { - this.marginBuffer = buffer ?? new BN(0); - return this; - } - - setMarginRatioOverride(ratio: number): this { - this.marginRatioOverride = ratio; + setMarginBuffer(buffer: Map): this { + this.marginBuffer = buffer; return this; } } @@ -124,8 +118,6 @@ export class MarginCalculation { marginRequirement: BN; marginRequirementPlusBuffer: BN; isolatedMarginCalculations: Map; - numSpotLiabilities: number; - numPerpLiabilities: number; allDepositOraclesValid: boolean; allLiabilityOraclesValid: boolean; withPerpIsolatedLiability: boolean; @@ -144,8 +136,6 @@ export class MarginCalculation { this.marginRequirement = new BN(0); this.marginRequirementPlusBuffer = new BN(0); this.isolatedMarginCalculations = new Map(); - this.numSpotLiabilities = 0; - this.numPerpLiabilities = 0; this.allDepositOraclesValid = true; this.allLiabilityOraclesValid = true; this.withPerpIsolatedLiability = false; @@ -159,20 +149,24 @@ export class MarginCalculation { } addCrossMarginTotalCollateral(delta: BN): void { + const crossMarginBuffer = + this.context.marginBuffer.get('cross') ?? new BN(0); this.totalCollateral = this.totalCollateral.add(delta); - if (this.context.marginBuffer.gt(new BN(0)) && delta.isNeg()) { + if (crossMarginBuffer.gt(new BN(0)) && delta.isNeg()) { this.totalCollateralBuffer = this.totalCollateralBuffer.add( - delta.mul(this.context.marginBuffer).div(MARGIN_PRECISION) + delta.mul(crossMarginBuffer).div(MARGIN_PRECISION) ); } } addCrossMarginRequirement(marginRequirement: BN, liabilityValue: BN): void { + const crossMarginBuffer = + this.context.marginBuffer.get('cross') ?? new BN(0); this.marginRequirement = this.marginRequirement.add(marginRequirement); - if (this.context.marginBuffer.gt(new BN(0))) { + if (crossMarginBuffer.gt(new BN(0))) { this.marginRequirementPlusBuffer = this.marginRequirementPlusBuffer.add( marginRequirement.add( - liabilityValue.mul(this.context.marginBuffer).div(MARGIN_PRECISION) + liabilityValue.mul(crossMarginBuffer).div(MARGIN_PRECISION) ) ); } @@ -186,16 +180,19 @@ export class MarginCalculation { marginRequirement: BN ): void { const totalCollateral = depositValue.add(pnl); + const isolatedMarginBuffer = + this.context.marginBuffer.get(marketIndex) ?? new BN(0); + const totalCollateralBuffer = - this.context.marginBuffer.gt(new BN(0)) && pnl.isNeg() - ? pnl.mul(this.context.marginBuffer).div(MARGIN_PRECISION) + isolatedMarginBuffer.gt(new BN(0)) && pnl.isNeg() + ? pnl.mul(isolatedMarginBuffer).div(MARGIN_PRECISION) : new BN(0); - const marginRequirementPlusBuffer = this.context.marginBuffer.gt(new BN(0)) + const marginRequirementPlusBuffer = isolatedMarginBuffer.gt(new BN(0)) ? marginRequirement.add( - liabilityValue.mul(this.context.marginBuffer).div(MARGIN_PRECISION) + liabilityValue.mul(isolatedMarginBuffer).div(MARGIN_PRECISION) ) - : new BN(0); + : marginRequirement; const iso = new IsolatedMarginCalculation(); iso.marginRequirement = marginRequirement; @@ -205,14 +202,6 @@ export class MarginCalculation { this.isolatedMarginCalculations.set(marketIndex, iso); } - addSpotLiability(): void { - this.numSpotLiabilities += 1; - } - - addPerpLiability(): void { - this.numPerpLiabilities += 1; - } - addSpotLiabilityValue(spotLiabilityValue: BN): void { this.totalSpotLiabilityValue = this.totalSpotLiabilityValue.add(spotLiabilityValue); @@ -239,18 +228,6 @@ export class MarginCalculation { this.withPerpIsolatedLiability = this.withPerpIsolatedLiability || isolated; } - validateNumSpotLiabilities(): void { - if (this.numSpotLiabilities > 0 && this.marginRequirement.eq(new BN(0))) { - throw new Error( - 'InvalidMarginRatio: num_spot_liabilities>0 but margin_requirement=0' - ); - } - } - - getNumOfLiabilities(): number { - return this.numSpotLiabilities + this.numPerpLiabilities; - } - getCrossTotalCollateralPlusBuffer(): BN { return this.totalCollateral.add(this.totalCollateralBuffer); } diff --git a/sdk/src/user.ts b/sdk/src/user.ts index 03df8d0801..18b15ff891 100644 --- a/sdk/src/user.ts +++ b/sdk/src/user.ts @@ -603,11 +603,19 @@ export class User { enteringHighLeverage?: boolean, perpMarketIndex?: number ): BN { + const liquidationBufferMap = (() => { + if (liquidationBuffer && perpMarketIndex !== undefined) { + return new Map([[perpMarketIndex, liquidationBuffer]]); + } else if (liquidationBuffer) { + return new Map([['cross', liquidationBuffer]]); + } + return new Map(); + })(); const marginCalc = this.getMarginCalculation(marginCategory, { strict, includeOpenOrders, enteringHighLeverage, - liquidationBuffer, + liquidationBufferMap, }); // If perpMarketIndex is provided, compute only for that market index @@ -1250,10 +1258,18 @@ export class User { liquidationBuffer?: BN, perpMarketIndex?: number ): BN { + const liquidationBufferMap = (() => { + if (liquidationBuffer && perpMarketIndex !== undefined) { + return new Map([[perpMarketIndex, liquidationBuffer]]); + } else if (liquidationBuffer) { + return new Map([['cross', liquidationBuffer]]); + } + return new Map(); + })(); const marginCalc = this.getMarginCalculation(marginCategory, { strict, includeOpenOrders, - liquidationBuffer, + liquidationBufferMap, }); if (perpMarketIndex !== undefined) { @@ -1271,15 +1287,28 @@ export class User { return marginCalc.totalCollateral; } - public getLiquidationBuffer(): BN | undefined { - // if user being liq'd, can continue to be liq'd until total collateral above the margin requirement plus buffer - let liquidationBuffer = undefined; + public getLiquidationBuffer(): Map { + const liquidationBufferMap = new Map(); if (this.isBeingLiquidated()) { - liquidationBuffer = new BN( - this.driftClient.getStateAccount().liquidationMarginBufferRatio + liquidationBufferMap.set( + 'cross', + new BN(this.driftClient.getStateAccount().liquidationMarginBufferRatio) ); } - return liquidationBuffer; + for (const position of this.getActivePerpPositions()) { + if ( + position.positionFlag & + (PositionFlag.BeingLiquidated | PositionFlag.Bankruptcy) + ) { + liquidationBufferMap.set( + position.marketIndex, + new BN( + this.driftClient.getStateAccount().liquidationMarginBufferRatio + ) + ); + } + } + return liquidationBufferMap; } /** @@ -1290,7 +1319,10 @@ export class User { if (this.isCrossMarginBeingLiquidated() && !perpMarketIndex) { return 0; } - if (this.hasIsolatedPositionBeingLiquidated() && perpMarketIndex) { + if ( + perpMarketIndex && + this.isIsolatedPositionBeingLiquidated(perpMarketIndex) + ) { return 0; } @@ -2025,9 +2057,9 @@ export class User { ): Map<'cross' | number, AccountLiquidatableStatus> { // If not provided, use buffer-aware calc for canBeLiquidated checks if (!marginCalc) { - const liquidationBuffer = this.getLiquidationBuffer(); + const liquidationBufferMap = this.getLiquidationBuffer(); marginCalc = this.getMarginCalculation('Maintenance', { - liquidationBuffer, + liquidationBufferMap, }); } @@ -2061,9 +2093,8 @@ export class User { public isBeingLiquidated(): boolean { return ( - (this.getUserAccount().status & - (UserStatus.BEING_LIQUIDATED | UserStatus.BANKRUPT)) > - 0 + this.isCrossMarginBeingLiquidated() || + this.hasIsolatedPositionBeingLiquidated() ); } @@ -2090,6 +2121,18 @@ export class User { ); } + public isIsolatedPositionBeingLiquidated(perpMarketIndex: number): boolean { + const position = this.getActivePerpPositions().find( + (position) => position.marketIndex === perpMarketIndex + ); + + return ( + (position?.positionFlag & + (PositionFlag.BeingLiquidated | PositionFlag.Bankruptcy)) > + 0 + ); + } + /** Returns true if any isolated perp position is currently below its maintenance requirement (no buffer). */ public getLiquidatableIsolatedPositions( marginCalc?: MarginCalculation @@ -4229,44 +4272,31 @@ export class User { * Compute a consolidated margin snapshot once, without caching. * Consumers can use this to avoid duplicating work across separate calls. */ - public getMarginCalculation( marginCategory: MarginCategory = 'Initial', opts?: { strict?: boolean; // mirror StrictOraclePrice application includeOpenOrders?: boolean; enteringHighLeverage?: boolean; - liquidationBuffer?: BN; // margin_buffer analog for buffer mode - marginRatioOverride?: number; // mirrors context.margin_ratio_override + liquidationBufferMap?: Map; // margin_buffer analog for buffer mode } ): MarginCalculation { const strict = opts?.strict ?? false; const enteringHighLeverage = opts?.enteringHighLeverage ?? false; - const includeOpenOrders = opts?.includeOpenOrders ?? true; // TODO: remove this ?? - const marginBuffer = opts?.liquidationBuffer; // treat as MARGIN_BUFFER ratio if provided - const marginRatioOverride = opts?.marginRatioOverride; + const liquidationBufferMap = opts?.liquidationBufferMap ?? new Map(); + const includeOpenOrders = opts?.includeOpenOrders ?? true; // Equivalent to on-chain user_custom_margin_ratio - let userCustomMarginRatio = + const userCustomMarginRatio = marginCategory === 'Initial' ? this.getUserAccount().maxMarginRatio : 0; - if (marginRatioOverride !== undefined) { - userCustomMarginRatio = Math.max( - userCustomMarginRatio, - marginRatioOverride - ); - } // Initialize calc via JS mirror of Rust MarginCalculation const ctx = MarginContext.standard(marginCategory) .strictMode(strict) - .setMarginBuffer(marginBuffer) - .setMarginRatioOverride(userCustomMarginRatio); + .setMarginBuffer(opts?.liquidationBufferMap ?? new Map()); const calc = new MarginCalculation(ctx); // SPOT POSITIONS - // TODO: include open orders in the worst-case simulation in the same way on both spot and perp positions - - let netQuoteValue = ZERO; for (const spotPosition of this.getUserAccount().spotPositions) { if (isSpotPositionAvailable(spotPosition)) continue; @@ -4305,7 +4335,7 @@ export class User { spotMarket, marginCategory ); - netQuoteValue = netQuoteValue.add(weightedTokenValue); + calc.addCrossMarginTotalCollateral(weightedTokenValue); } else { // borrow on quote contributes to margin requirement const tokenValueAbs = this.getSpotLiabilityValue( @@ -4313,11 +4343,11 @@ export class User { strictOracle, spotMarket, marginCategory, - marginBuffer + liquidationBufferMap.get('cross') ?? new BN(0) ).abs(); - calc.addSpotLiability(); - netQuoteValue = netQuoteValue.sub(tokenValueAbs); + calc.addCrossMarginRequirement(tokenValueAbs, tokenValueAbs); } + continue; } // Non-quote spot: worst-case simulation @@ -4354,41 +4384,32 @@ export class User { calc.addCrossMarginTotalCollateral(baseAssetValue); } else if (worstCaseTokenAmount.lt(ZERO) && !isQuote) { // liability side increases margin requirement (weighted >= abs(token_value)) - // const liabilityWeighted = worstCaseWeightedTokenValue.abs(); const getSpotLiabilityValue = this.getSpotLiabilityValue( worstCaseTokenAmount, strictOracle, spotMarket, marginCategory, - marginBuffer + liquidationBufferMap.get('cross') ); - // TODO need to sync with perp on passing liability weighted or getSpotLiabilityValue.abs() for each param here calc.addCrossMarginRequirement( getSpotLiabilityValue.abs(), getSpotLiabilityValue.abs() ); - calc.addSpotLiability(); calc.addSpotLiabilityValue(worstCaseTokenValue.abs()); } else if (spotPosition.openOrders !== 0 && !isQuote) { - calc.addSpotLiability(); calc.addSpotLiabilityValue(worstCaseTokenValue.abs()); } // orders value contributes to collateral or requirement if (worstCaseOrdersValue.gt(ZERO)) { - netQuoteValue = netQuoteValue.add(worstCaseOrdersValue); + calc.addCrossMarginTotalCollateral(worstCaseOrdersValue); } else if (worstCaseOrdersValue.lt(ZERO)) { const absVal = worstCaseOrdersValue.abs(); - netQuoteValue = netQuoteValue.sub(absVal); + calc.addCrossMarginRequirement(absVal, absVal); } } - if (netQuoteValue.gt(ZERO)) { - calc.addCrossMarginTotalCollateral(netQuoteValue); - } else if (netQuoteValue.lt(ZERO)) { - calc.addCrossMarginRequirement(netQuoteValue.abs(), netQuoteValue.abs()); - } // PERP POSITIONS for (const marketPosition of this.getActivePerpPositions()) { const market = this.driftClient.getPerpMarketAccount( @@ -4419,7 +4440,7 @@ export class User { // margin ratio for this perp const customMarginRatio = Math.max( - this.getUserAccount().maxMarginRatio, + userCustomMarginRatio, marketPosition.maxMarginRatio ); let marginRatio = new BN( @@ -4493,21 +4514,6 @@ export class User { ) .div(new BN(SPOT_MARKET_WEIGHT_PRECISION)); } - - if (marginBuffer && positionUnrealizedPnl.lt(ZERO)) { - positionUnrealizedPnl = positionUnrealizedPnl.add( - positionUnrealizedPnl.mul(marginBuffer).div(MARGIN_PRECISION) - ); - } - } - - // perp position liability - const hasPerpLiability = - !marketPosition.baseAssetAmount.eq(ZERO) || - marketPosition.quoteAssetAmount.lt(ZERO) || - marketPosition.openOrders !== 0; - if (hasPerpLiability) { - calc.addPerpLiability(); } // Add perp contribution: isolated vs cross @@ -4515,7 +4521,6 @@ export class User { if (isIsolated) { // derive isolated quote deposit value, mirroring on-chain logic let depositValue = ZERO; - // TODO this field(isolatedPositionScaledBalance) should not be undefined in the future, remove ? later if (marketPosition.isolatedPositionScaledBalance?.gt(ZERO)) { const quoteSpotMarket = this.driftClient.getSpotMarketAccount( market.quoteSpotMarketIndex @@ -4530,7 +4535,7 @@ export class User { : undefined ); const quoteTokenAmount = getTokenAmount( - marketPosition.isolatedPositionScaledBalance ?? ZERO, //TODO remove ? later + marketPosition.isolatedPositionScaledBalance ?? ZERO, quoteSpotMarket, SpotBalanceType.DEPOSIT ); @@ -4547,7 +4552,6 @@ export class User { worstCaseLiabilityValue, perpMarginRequirement ); - calc.addPerpLiability(); calc.addPerpLiabilityValue(worstCaseLiabilityValue); } else { // cross: add to global requirement and collateral diff --git a/sdk/tests/user/getMarginCalculation.ts b/sdk/tests/user/getMarginCalculation.ts index 49f954528b..8bb8176b89 100644 --- a/sdk/tests/user/getMarginCalculation.ts +++ b/sdk/tests/user/getMarginCalculation.ts @@ -2,12 +2,7 @@ import { BN, ZERO, User, - UserAccount, PublicKey, - PerpMarketAccount, - SpotMarketAccount, - PRICE_PRECISION, - OraclePriceData, BASE_PRECISION, QUOTE_PRECISION, SPOT_MARKET_BALANCE_PRECISION, @@ -16,73 +11,14 @@ import { SPOT_MARKET_WEIGHT_PRECISION, PositionFlag, } from '../../src'; -import { MockUserMap, mockPerpMarkets, mockSpotMarkets } from '../dlob/helpers'; +import { mockPerpMarkets, mockSpotMarkets } from '../dlob/helpers'; import { assert } from '../../src/assert/assert'; -import { mockUserAccount as baseMockUserAccount } from './helpers'; +import { + mockUserAccount as baseMockUserAccount, + makeMockUser, +} from './helpers'; import * as _ from 'lodash'; -async function makeMockUser( - myMockPerpMarkets: Array, - myMockSpotMarkets: Array, - myMockUserAccount: UserAccount, - perpOraclePriceList: number[], - spotOraclePriceList: number[] -): Promise { - const umap = new MockUserMap(); - const mockUser: User = await umap.mustGet('1'); - mockUser._isSubscribed = true; - mockUser.driftClient._isSubscribed = true; - mockUser.driftClient.accountSubscriber.isSubscribed = true; - - const oraclePriceMap: Record = {}; - for (let i = 0; i < myMockPerpMarkets.length; i++) { - oraclePriceMap[myMockPerpMarkets[i].amm.oracle.toString()] = - perpOraclePriceList[i] ?? 1; - } - for (let i = 0; i < myMockSpotMarkets.length; i++) { - oraclePriceMap[myMockSpotMarkets[i].oracle.toString()] = - spotOraclePriceList[i] ?? 1; - } - - function getMockUserAccount(): UserAccount { - return myMockUserAccount; - } - function getMockPerpMarket(marketIndex: number): PerpMarketAccount { - return myMockPerpMarkets[marketIndex]; - } - function getMockSpotMarket(marketIndex: number): SpotMarketAccount { - return myMockSpotMarkets[marketIndex]; - } - function getMockOracle(oracleKey: PublicKey) { - const data: OraclePriceData = { - price: new BN( - (oraclePriceMap[oracleKey.toString()] ?? 1) * - PRICE_PRECISION.toNumber() - ), - slot: new BN(0), - confidence: new BN(1), - hasSufficientNumberOfDataPoints: true, - }; - return { data, slot: 0 }; - } - function getOracleDataForPerpMarket(marketIndex: number) { - const oracle = getMockPerpMarket(marketIndex).amm.oracle; - return getMockOracle(oracle).data; - } - function getOracleDataForSpotMarket(marketIndex: number) { - const oracle = getMockSpotMarket(marketIndex).oracle; - return getMockOracle(oracle).data; - } - - mockUser.getUserAccount = getMockUserAccount; - mockUser.driftClient.getPerpMarketAccount = getMockPerpMarket as any; - mockUser.driftClient.getSpotMarketAccount = getMockSpotMarket as any; - mockUser.driftClient.getOraclePriceDataAndSlot = getMockOracle as any; - mockUser.driftClient.getOracleDataForPerpMarket = getOracleDataForPerpMarket as any; - mockUser.driftClient.getOracleDataForSpotMarket = getOracleDataForSpotMarket as any; - return mockUser; -} - describe('getMarginCalculation snapshot', () => { it('empty account returns zeroed snapshot', async () => { const myMockPerpMarkets = _.cloneDeep(mockPerpMarkets); @@ -100,8 +36,6 @@ describe('getMarginCalculation snapshot', () => { const calc = user.getMarginCalculation('Initial'); assert(calc.totalCollateral.eq(ZERO)); assert(calc.marginRequirement.eq(ZERO)); - assert(calc.numSpotLiabilities === 0); - assert(calc.numPerpLiabilities === 0); }); it('quote deposit increases totalCollateral, no requirement', async () => { @@ -149,17 +83,23 @@ describe('getMarginCalculation snapshot', () => { const tenPercent = new BN(1000); const calc = user.getMarginCalculation('Initial', { - liquidationBuffer: tenPercent, + liquidationBufferMap: new Map([['cross', tenPercent]]), }); - const liability = new BN(100).mul(QUOTE_PRECISION); // $100 + const liability = new BN(110).mul(QUOTE_PRECISION); // $110 assert(calc.totalCollateral.eq(ZERO)); - assert(calc.marginRequirement.eq(liability)); + assert( + calc.marginRequirement.eq(liability), + `margin requirement does not equal liability: ${calc.marginRequirement.toString()} != ${liability.toString()}` + ); assert( calc.marginRequirementPlusBuffer.eq( liability.div(new BN(10)).add(calc.marginRequirement) // 10% of liability + margin requirement - ) + ), + `margin requirement plus buffer does not equal 10% of liability + margin requirement: ${calc.marginRequirementPlusBuffer.toString()} != ${liability + .div(new BN(10)) + .add(calc.marginRequirement) + .toString()}` ); - assert(calc.numSpotLiabilities === 1); }); it('non-quote spot open orders add IM', async () => { @@ -193,9 +133,6 @@ describe('getMarginCalculation snapshot', () => { myMockUserAccount.perpPositions[0].baseAssetAmount = new BN(20).mul( BASE_PRECISION ); - myMockUserAccount.perpPositions[0].quoteAssetAmount = new BN(-10).mul( - QUOTE_PRECISION - ); const user: User = await makeMockUser( myMockPerpMarkets, @@ -210,7 +147,7 @@ describe('getMarginCalculation snapshot', () => { assert(calc.marginRequirement.eq(new BN('2000000'))); }); - it.only('maker position reducing: collateral equals maintenance requirement', async () => { + it('collateral equals maintenance requirement', async () => { const myMockPerpMarkets = _.cloneDeep(mockPerpMarkets); const myMockSpotMarkets = _.cloneDeep(mockSpotMarkets); const myMockUserAccount = _.cloneDeep(baseMockUserAccount); @@ -218,8 +155,10 @@ describe('getMarginCalculation snapshot', () => { myMockUserAccount.perpPositions[0].baseAssetAmount = new BN(200000000).mul( BASE_PRECISION ); - myMockUserAccount.perpPositions[0].quoteAssetAmount = new BN(-180000000).mul( - QUOTE_PRECISION + + myMockUserAccount.spotPositions[0].balanceType = SpotBalanceType.DEPOSIT; + myMockUserAccount.spotPositions[0].scaledBalance = new BN(20000000).mul( + SPOT_MARKET_BALANCE_PRECISION ); const user: User = await makeMockUser( @@ -231,7 +170,10 @@ describe('getMarginCalculation snapshot', () => { ); const calc = user.getMarginCalculation('Maintenance'); - assert(calc.marginRequirement.eq(calc.totalCollateral)); + assert( + calc.marginRequirement.eq(calc.totalCollateral), + `margin requirement does not equal total collateral: ${calc.marginRequirement.toString()} != ${calc.totalCollateral.toString()}` + ); }); it('maker reducing after simulated fill: collateral equals maintenance requirement', async () => { @@ -247,9 +189,15 @@ describe('getMarginCalculation snapshot', () => { const spotOracles = [1, 1, 1, 1, 1, 1, 1, 1]; // Pre-fill: maker has 21 base long at entry 1 ($21 notional), taker flat - makerAccount.perpPositions[0].baseAssetAmount = new BN(21).mul(BASE_PRECISION); - makerAccount.perpPositions[0].quoteEntryAmount = new BN(-21).mul(QUOTE_PRECISION); - makerAccount.perpPositions[0].quoteBreakEvenAmount = new BN(-21).mul(QUOTE_PRECISION); + makerAccount.perpPositions[0].baseAssetAmount = new BN(21).mul( + BASE_PRECISION + ); + makerAccount.perpPositions[0].quoteEntryAmount = new BN(-21).mul( + QUOTE_PRECISION + ); + makerAccount.perpPositions[0].quoteBreakEvenAmount = new BN(-21).mul( + QUOTE_PRECISION + ); // Provide exactly $2 in quote collateral to equal 10% maintenance of 20 notional post-fill makerAccount.spotPositions[0].balanceType = SpotBalanceType.DEPOSIT; makerAccount.spotPositions[0].scaledBalance = new BN(2).mul( @@ -281,9 +229,9 @@ describe('getMarginCalculation snapshot', () => { maker.getUserAccount().perpPositions[0].quoteEntryAmount = new BN(-20).mul( QUOTE_PRECISION ); - maker.getUserAccount().perpPositions[0].quoteBreakEvenAmount = new BN(-20).mul( - QUOTE_PRECISION - ); + maker.getUserAccount().perpPositions[0].quoteBreakEvenAmount = new BN( + -20 + ).mul(QUOTE_PRECISION); // Align quoteAssetAmount with base value so unrealized PnL = 0 at price 1 maker.getUserAccount().perpPositions[0].quoteAssetAmount = new BN(-20).mul( QUOTE_PRECISION @@ -295,9 +243,9 @@ describe('getMarginCalculation snapshot', () => { taker.getUserAccount().perpPositions[0].quoteEntryAmount = new BN(-1).mul( QUOTE_PRECISION ); - taker.getUserAccount().perpPositions[0].quoteBreakEvenAmount = new BN(-1).mul( - QUOTE_PRECISION - ); + taker.getUserAccount().perpPositions[0].quoteBreakEvenAmount = new BN( + -1 + ).mul(QUOTE_PRECISION); // Also set taker's quoteAssetAmount consistently taker.getUserAccount().perpPositions[0].quoteAssetAmount = new BN(-1).mul( QUOTE_PRECISION @@ -344,12 +292,16 @@ describe('getMarginCalculation snapshot', () => { SPOT_MARKET_BALANCE_PRECISION ); // No perp exposure in cross calc - crossAccount.perpPositions[0].baseAssetAmount = new BN(100 * BASE_PRECISION.toNumber()); - crossAccount.perpPositions[0].quoteAssetAmount = new BN(-11000 * QUOTE_PRECISION.toNumber()); - crossAccount.perpPositions[0].positionFlag = PositionFlag.IsolatedPosition; - crossAccount.perpPositions[0].isolatedPositionScaledBalance = new BN(100).mul( - SPOT_MARKET_BALANCE_PRECISION + crossAccount.perpPositions[0].baseAssetAmount = new BN( + 100 * BASE_PRECISION.toNumber() ); + crossAccount.perpPositions[0].quoteAssetAmount = new BN( + -11000 * QUOTE_PRECISION.toNumber() + ); + crossAccount.perpPositions[0].positionFlag = PositionFlag.IsolatedPosition; + crossAccount.perpPositions[0].isolatedPositionScaledBalance = new BN( + 100 + ).mul(SPOT_MARKET_BALANCE_PRECISION); const userCross: User = await makeMockUser( myMockPerpMarkets, @@ -373,17 +325,37 @@ describe('getMarginCalculation snapshot', () => { // With 10% buffer const tenPct = new BN(1000); const crossCalcBuf = userCross.getMarginCalculation('Initial', { - liquidationBuffer: tenPct, + liquidationBufferMap: new Map([ + ['cross', tenPct], + [0, new BN(100)], + ]), }); - assert(crossCalcBuf.marginRequirementPlusBuffer.eq(new BN('13000000000'))); // replicate 10% buffer + assert( + crossCalcBuf.marginRequirementPlusBuffer.eq(new BN('14300000000')), + `margin requirement plus buffer does not equal 110% of liability + margin requirement: ${crossCalcBuf.marginRequirementPlusBuffer.toString()} != ${new BN( + '14300000000' + ).toString()}` + ); // replicate 10% buffer const crossTotalPlusBuffer = crossCalcBuf.totalCollateral.add( crossCalcBuf.totalCollateralBuffer ); assert(crossTotalPlusBuffer.eq(new BN('20000000000'))); - const isoPosition = crossCalcBuf.isolatedMarginCalculations.get(0); - assert(isoPosition?.marginRequirementPlusBuffer.eq(new BN('2000000000'))); - assert(isoPosition?.totalCollateralBuffer.add(isoPosition?.totalCollateral).eq(new BN('-1000000000'))); + const isoPositionBuf = crossCalcBuf.isolatedMarginCalculations.get(0); + assert( + isoPositionBuf?.marginRequirementPlusBuffer.eq(new BN('1100000000')), + `margin requirement plus buffer does not equal 10% of liability + margin requirement: ${isoPositionBuf?.marginRequirementPlusBuffer.toString()} != ${new BN( + '1100000000' + ).toString()}` + ); + assert(isoPositionBuf?.marginRequirement.eq(new BN('1000000000'))); + assert( + isoPositionBuf?.totalCollateralBuffer + .add(isoPositionBuf?.totalCollateral) + .eq(new BN('-910000000')), + `total collateral buffer plus total collateral does not equal -$9100: ${isoPositionBuf?.totalCollateralBuffer + .add(isoPositionBuf?.totalCollateral) + .toString()} != ${new BN('-900000000').toString()}` + ); }); }); - diff --git a/sdk/tests/user/helpers.ts b/sdk/tests/user/helpers.ts index aa6b3fc8d8..896cd8178c 100644 --- a/sdk/tests/user/helpers.ts +++ b/sdk/tests/user/helpers.ts @@ -1,6 +1,13 @@ import { PublicKey } from '@solana/web3.js'; import { + BN, + User, + UserAccount, + PerpMarketAccount, + SpotMarketAccount, + PRICE_PRECISION, + OraclePriceData, SpotPosition, SpotBalanceType, Order, @@ -9,12 +16,12 @@ import { OrderType, PositionDirection, OrderTriggerCondition, - UserAccount, ZERO, MarginMode, + MMOraclePriceData, } from '../../src'; -import { mockPerpPosition } from '../dlob/helpers'; +import { MockUserMap, mockPerpPosition } from '../dlob/helpers'; export const mockOrder: Order = { status: OrderStatus.INIT, @@ -92,3 +99,90 @@ export const mockUserAccount: UserAccount = { marginMode: MarginMode.DEFAULT, poolId: 0, }; + +export async function makeMockUser( + myMockPerpMarkets: Array, + myMockSpotMarkets: Array, + myMockUserAccount: UserAccount, + perpOraclePriceList: number[], + spotOraclePriceList: number[] +): Promise { + const umap = new MockUserMap(); + const mockUser: User = await umap.mustGet('1'); + mockUser._isSubscribed = true; + mockUser.driftClient._isSubscribed = true; + mockUser.driftClient.accountSubscriber.isSubscribed = true; + const getStateAccount = () => + ({ + data: { + liquidationMarginBufferRatio: 1000, + }, + slot: 0, + }) as any; + mockUser.driftClient.getStateAccount = getStateAccount; + + const oraclePriceMap: Record = {}; + for (let i = 0; i < myMockPerpMarkets.length; i++) { + oraclePriceMap[myMockPerpMarkets[i].amm.oracle.toString()] = + perpOraclePriceList[i] ?? 1; + } + for (let i = 0; i < myMockSpotMarkets.length; i++) { + oraclePriceMap[myMockSpotMarkets[i].oracle.toString()] = + spotOraclePriceList[i] ?? 1; + } + + function getMockUserAccount(): UserAccount { + return myMockUserAccount; + } + function getMockPerpMarket(marketIndex: number): PerpMarketAccount { + return myMockPerpMarkets[marketIndex]; + } + function getMockSpotMarket(marketIndex: number): SpotMarketAccount { + return myMockSpotMarkets[marketIndex]; + } + function getMockOracle(oracleKey: PublicKey) { + const data: OraclePriceData = { + price: new BN( + (oraclePriceMap[oracleKey.toString()] ?? 1) * PRICE_PRECISION.toNumber() + ), + slot: new BN(0), + confidence: new BN(1), + hasSufficientNumberOfDataPoints: true, + }; + return { data, slot: 0 }; + } + function getOracleDataForPerpMarket(marketIndex: number) { + const oracle = getMockPerpMarket(marketIndex).amm.oracle; + return getMockOracle(oracle).data; + } + function getOracleDataForSpotMarket(marketIndex: number) { + const oracle = getMockSpotMarket(marketIndex).oracle; + return getMockOracle(oracle).data; + } + + function getMMOracleDataForPerpMarket( + marketIndex: number + ): MMOraclePriceData { + const oracle = getMockPerpMarket(marketIndex).amm.oracle; + return { + price: getMockOracle(oracle).data.price, + slot: getMockOracle(oracle).data.slot, + confidence: getMockOracle(oracle).data.confidence, + hasSufficientNumberOfDataPoints: + getMockOracle(oracle).data.hasSufficientNumberOfDataPoints, + isMMOracleActive: true, + }; + } + + mockUser.getUserAccount = getMockUserAccount; + mockUser.driftClient.getPerpMarketAccount = getMockPerpMarket as any; + mockUser.driftClient.getSpotMarketAccount = getMockSpotMarket as any; + mockUser.driftClient.getOraclePriceDataAndSlot = getMockOracle as any; + mockUser.driftClient.getOracleDataForPerpMarket = + getOracleDataForPerpMarket as any; + mockUser.driftClient.getOracleDataForSpotMarket = + getOracleDataForSpotMarket as any; + mockUser.driftClient.getMMOracleDataForPerpMarket = + getMMOracleDataForPerpMarket as any; + return mockUser; +} diff --git a/sdk/tests/user/liquidations.ts b/sdk/tests/user/liquidations.ts new file mode 100644 index 0000000000..e154662048 --- /dev/null +++ b/sdk/tests/user/liquidations.ts @@ -0,0 +1,129 @@ +import { assert } from 'chai'; +import _ from 'lodash'; +import { SpotBalanceType, PositionFlag } from '../../src/types'; +import { + BASE_PRECISION, + QUOTE_PRECISION, + SPOT_MARKET_BALANCE_PRECISION, +} from '../../src/constants/numericConstants'; +import { BN } from '../../src'; +import { mockPerpMarkets, mockSpotMarkets } from '../dlob/helpers'; +import { + mockUserAccount as baseMockUserAccount, + makeMockUser, +} from './helpers'; + +// Helper for easy async test creation +async function makeUserWithAccount( + account, + perpOraclePrices: number[], + spotOraclePrices: number[] +) { + const user = await makeMockUser( + _.cloneDeep(mockPerpMarkets), + _.cloneDeep(mockSpotMarkets), + account, + perpOraclePrices, + spotOraclePrices + ); + return user; +} + +describe('User.getLiquidationStatuses', () => { + it('isolated account: healthy, then becomes liquidatable on IM', async () => { + const isoAccount = _.cloneDeep(baseMockUserAccount); + + // put full isolated perp position in the first market (marketIndex 0 = KEN) + isoAccount.perpPositions[0].baseAssetAmount = new BN(100).mul( + BASE_PRECISION + ); + isoAccount.perpPositions[0].quoteAssetAmount = new BN(100).mul( + QUOTE_PRECISION + ); + isoAccount.perpPositions[0].positionFlag = PositionFlag.IsolatedPosition; + isoAccount.perpPositions[0].isolatedPositionScaledBalance = new BN( + 1000 + ).mul(SPOT_MARKET_BALANCE_PRECISION); + + // enough deposit for margin + isoAccount.spotPositions[0].marketIndex = 0; + isoAccount.spotPositions[0].balanceType = SpotBalanceType.DEPOSIT; + isoAccount.spotPositions[0].scaledBalance = new BN(10000).mul( + SPOT_MARKET_BALANCE_PRECISION + ); + + const user = await makeUserWithAccount( + isoAccount, + [100, 1, 1, 1, 1, 1, 1, 1], + [1, 100, 1, 1, 1, 1, 1, 1] + ); + + let statuses = user.getLiquidationStatuses(); + // Isolated position is not liquidatable + const cross1 = statuses.get('cross'); + const iso0_1 = statuses.get(0); + assert.equal(iso0_1?.canBeLiquidated, false); + assert.equal(cross1?.canBeLiquidated, false); + + // Lower spot deposit to make isolated margin not enough for IM (but still above MM) + isoAccount.perpPositions[0].isolatedPositionScaledBalance = new BN(1).mul( + SPOT_MARKET_BALANCE_PRECISION + ); + + const underfundedUser = await makeUserWithAccount( + isoAccount, + [100, 1, 1, 1, 1, 1, 1, 1], + [1, 100, 1, 1, 1, 1, 1, 1] + ); + + statuses = underfundedUser.getLiquidationStatuses(); + const cross2 = statuses.get('cross'); + const iso0_2 = statuses.get(0); + assert.equal(iso0_2?.canBeLiquidated, true); + assert.equal(cross2?.canBeLiquidated, false); + }); + + it('isolated position becomes fully bankrupt (both margin requirements breached)', async () => { + const bankruptAccount = _.cloneDeep(baseMockUserAccount); + + bankruptAccount.perpPositions[0].baseAssetAmount = new BN(100).mul( + BASE_PRECISION + ); + bankruptAccount.perpPositions[0].quoteAssetAmount = new BN(-14000).mul( + QUOTE_PRECISION + ); + bankruptAccount.perpPositions[0].positionFlag = + PositionFlag.IsolatedPosition; + bankruptAccount.perpPositions[0].isolatedPositionScaledBalance = new BN( + 100 + ).mul(SPOT_MARKET_BALANCE_PRECISION); + + bankruptAccount.spotPositions[0].marketIndex = 0; + bankruptAccount.spotPositions[0].balanceType = SpotBalanceType.DEPOSIT; + bankruptAccount.spotPositions[0].scaledBalance = new BN(1000).mul( + SPOT_MARKET_BALANCE_PRECISION + ); + + const user = await makeUserWithAccount( + bankruptAccount, + [100, 1, 1, 1, 1, 1, 1, 1], + [1, 100, 1, 1, 1, 1, 1, 1] + ); + + const statuses = user.getLiquidationStatuses(); + const cross = statuses.get('cross'); + const iso0 = statuses.get(0); + assert.equal( + iso0?.canBeLiquidated, + true, + 'isolated position 0 should be liquidatable' + ); + // Breaches maintenance requirement if MR > total collateral + assert.ok(iso0 && iso0.marginRequirement.gt(iso0.totalCollateral)); + assert.equal( + cross?.canBeLiquidated, + false, + 'cross margin should not be liquidatable' + ); + }); +}); From aa45b967144ce901781a5f7dbcc0df429a8980e2 Mon Sep 17 00:00:00 2001 From: Lukas deConantsesznak Date: Wed, 12 Nov 2025 11:41:38 -0500 Subject: [PATCH 11/13] feat: address pr nit feedback --- sdk/src/marginCalculation.ts | 78 +++++++++++++++++++----------------- sdk/src/user.ts | 44 ++++++++++---------- 2 files changed, 64 insertions(+), 58 deletions(-) diff --git a/sdk/src/marginCalculation.ts b/sdk/src/marginCalculation.ts index 9343e3d3d0..e757feadca 100644 --- a/sdk/src/marginCalculation.ts +++ b/sdk/src/marginCalculation.ts @@ -1,5 +1,5 @@ import { BN } from '@coral-xyz/anchor'; -import { MARGIN_PRECISION } from './constants/numericConstants'; +import { MARGIN_PRECISION, ZERO } from './constants/numericConstants'; import { MarketType } from './types'; export type MarginCategory = 'Initial' | 'Maintenance' | 'Fill'; @@ -39,24 +39,29 @@ export class MarginContext { mode: MarginCalculationMode; strict: boolean; ignoreInvalidDepositOracles: boolean; - marginBuffer: Map; // scaled by MARGIN_PRECISION + isolatedMarginBuffers: Map; + crossMarginBuffer: BN; private constructor(marginType: MarginCategory) { this.marginType = marginType; this.mode = { type: 'Standard' }; this.strict = false; this.ignoreInvalidDepositOracles = false; - this.marginBuffer = new Map(); + this.isolatedMarginBuffers = new Map(); } static standard(marginType: MarginCategory): MarginContext { return new MarginContext(marginType); } - static liquidation(marginBuffer: Map): MarginContext { + static liquidation( + crossMarginBuffer: BN, + isolatedMarginBuffers: Map + ): MarginContext { const ctx = new MarginContext('Maintenance'); ctx.mode = { type: 'Liquidation' }; - ctx.marginBuffer = marginBuffer; + ctx.crossMarginBuffer = crossMarginBuffer; + ctx.isolatedMarginBuffers = isolatedMarginBuffers; return ctx; } @@ -70,8 +75,16 @@ export class MarginContext { return this; } - setMarginBuffer(buffer: Map): this { - this.marginBuffer = buffer; + setCrossMarginBuffer(crossMarginBuffer: BN): this { + this.crossMarginBuffer = crossMarginBuffer; + return this; + } + setIsolatedMarginBuffers(isolatedMarginBuffers: Map): this { + this.isolatedMarginBuffers = isolatedMarginBuffers; + return this; + } + setIsolatedMarginBuffer(marketIndex: number, isolatedMarginBuffer: BN): this { + this.isolatedMarginBuffers.set(marketIndex, isolatedMarginBuffer); return this; } } @@ -83,10 +96,10 @@ export class IsolatedMarginCalculation { marginRequirementPlusBuffer: BN; constructor() { - this.marginRequirement = new BN(0); - this.totalCollateral = new BN(0); - this.totalCollateralBuffer = new BN(0); - this.marginRequirementPlusBuffer = new BN(0); + this.marginRequirement = ZERO; + this.totalCollateral = ZERO; + this.totalCollateralBuffer = ZERO; + this.marginRequirementPlusBuffer = ZERO; } getTotalCollateralPlusBuffer(): BN { @@ -107,7 +120,7 @@ export class IsolatedMarginCalculation { const shortage = this.marginRequirementPlusBuffer.sub( this.getTotalCollateralPlusBuffer() ); - return shortage.isNeg() ? new BN(0) : shortage; + return shortage.isNeg() ? ZERO : shortage; } } @@ -122,7 +135,6 @@ export class MarginCalculation { allLiabilityOraclesValid: boolean; withPerpIsolatedLiability: boolean; withSpotIsolatedLiability: boolean; - totalSpotLiabilityValue: BN; totalPerpLiabilityValue: BN; trackedMarketMarginRequirement: BN; fuelDeposits: number; @@ -131,28 +143,26 @@ export class MarginCalculation { constructor(context: MarginContext) { this.context = context; - this.totalCollateral = new BN(0); - this.totalCollateralBuffer = new BN(0); - this.marginRequirement = new BN(0); - this.marginRequirementPlusBuffer = new BN(0); + this.totalCollateral = ZERO; + this.totalCollateralBuffer = ZERO; + this.marginRequirement = ZERO; + this.marginRequirementPlusBuffer = ZERO; this.isolatedMarginCalculations = new Map(); this.allDepositOraclesValid = true; this.allLiabilityOraclesValid = true; this.withPerpIsolatedLiability = false; this.withSpotIsolatedLiability = false; - this.totalSpotLiabilityValue = new BN(0); - this.totalPerpLiabilityValue = new BN(0); - this.trackedMarketMarginRequirement = new BN(0); + this.totalPerpLiabilityValue = ZERO; + this.trackedMarketMarginRequirement = ZERO; this.fuelDeposits = 0; this.fuelBorrows = 0; this.fuelPositions = 0; } addCrossMarginTotalCollateral(delta: BN): void { - const crossMarginBuffer = - this.context.marginBuffer.get('cross') ?? new BN(0); + const crossMarginBuffer = this.context.crossMarginBuffer; this.totalCollateral = this.totalCollateral.add(delta); - if (crossMarginBuffer.gt(new BN(0)) && delta.isNeg()) { + if (crossMarginBuffer.gt(ZERO) && delta.isNeg()) { this.totalCollateralBuffer = this.totalCollateralBuffer.add( delta.mul(crossMarginBuffer).div(MARGIN_PRECISION) ); @@ -160,10 +170,9 @@ export class MarginCalculation { } addCrossMarginRequirement(marginRequirement: BN, liabilityValue: BN): void { - const crossMarginBuffer = - this.context.marginBuffer.get('cross') ?? new BN(0); + const crossMarginBuffer = this.context.crossMarginBuffer; this.marginRequirement = this.marginRequirement.add(marginRequirement); - if (crossMarginBuffer.gt(new BN(0))) { + if (crossMarginBuffer.gt(ZERO)) { this.marginRequirementPlusBuffer = this.marginRequirementPlusBuffer.add( marginRequirement.add( liabilityValue.mul(crossMarginBuffer).div(MARGIN_PRECISION) @@ -181,14 +190,14 @@ export class MarginCalculation { ): void { const totalCollateral = depositValue.add(pnl); const isolatedMarginBuffer = - this.context.marginBuffer.get(marketIndex) ?? new BN(0); + this.context.isolatedMarginBuffers.get(marketIndex) ?? ZERO; const totalCollateralBuffer = - isolatedMarginBuffer.gt(new BN(0)) && pnl.isNeg() + isolatedMarginBuffer.gt(ZERO) && pnl.isNeg() ? pnl.mul(isolatedMarginBuffer).div(MARGIN_PRECISION) - : new BN(0); + : ZERO; - const marginRequirementPlusBuffer = isolatedMarginBuffer.gt(new BN(0)) + const marginRequirementPlusBuffer = isolatedMarginBuffer.gt(ZERO) ? marginRequirement.add( liabilityValue.mul(isolatedMarginBuffer).div(MARGIN_PRECISION) ) @@ -202,11 +211,6 @@ export class MarginCalculation { this.isolatedMarginCalculations.set(marketIndex, iso); } - addSpotLiabilityValue(spotLiabilityValue: BN): void { - this.totalSpotLiabilityValue = - this.totalSpotLiabilityValue.add(spotLiabilityValue); - } - addPerpLiabilityValue(perpLiabilityValue: BN): void { this.totalPerpLiabilityValue = this.totalPerpLiabilityValue.add(perpLiabilityValue); @@ -260,7 +264,7 @@ export class MarginCalculation { getCrossFreeCollateral(): BN { const free = this.totalCollateral.sub(this.marginRequirement); - return free.isNeg() ? new BN(0) : free; + return free.isNeg() ? ZERO : free; } getIsolatedFreeCollateral(marketIndex: number): BN { @@ -268,7 +272,7 @@ export class MarginCalculation { if (!iso) throw new Error('InvalidMarginCalculation: missing isolated calc'); const free = iso.totalCollateral.sub(iso.marginRequirement); - return free.isNeg() ? new BN(0) : free; + return free.isNeg() ? ZERO : free; } getIsolatedMarginCalculation( diff --git a/sdk/src/user.ts b/sdk/src/user.ts index 18b15ff891..a9c631ae00 100644 --- a/sdk/src/user.ts +++ b/sdk/src/user.ts @@ -547,7 +547,7 @@ export class User { ): BN { const { totalCollateral } = this.getMarginCalculation(marginCategory, { enteringHighLeverage: enterHighLeverageMode, - strict: true, + strict: marginCategory === 'Initial', }); const marginCalc = this.getMarginCalculation(marginCategory, { @@ -603,14 +603,13 @@ export class User { enteringHighLeverage?: boolean, perpMarketIndex?: number ): BN { - const liquidationBufferMap = (() => { - if (liquidationBuffer && perpMarketIndex !== undefined) { - return new Map([[perpMarketIndex, liquidationBuffer]]); - } else if (liquidationBuffer) { - return new Map([['cross', liquidationBuffer]]); - } - return new Map(); - })(); + const liquidationBufferMap = new Map(); + if (liquidationBuffer && perpMarketIndex !== undefined) { + liquidationBufferMap.set(perpMarketIndex, liquidationBuffer); + } else if (liquidationBuffer) { + liquidationBufferMap.set('cross', liquidationBuffer); + } + const marginCalc = this.getMarginCalculation(marginCategory, { strict, includeOpenOrders, @@ -742,7 +741,6 @@ export class User { const market = this.driftClient.getPerpMarketAccount( perpPosition.marketIndex ); - if (!market) return unrealizedPnl; const oraclePriceData = this.getMMOracleDataForPerpMarket( market.marketIndex ); @@ -1377,8 +1375,6 @@ export class User { perpPosition.marketIndex ); - if (!market) return ZERO; - let valuationPrice = this.getOracleDataForPerpMarket( market.marketIndex ).price; @@ -2332,7 +2328,7 @@ export class User { }); const isolatedMarginCalculation = marginCalculation.isolatedMarginCalculations.get(marketIndex); - if (!isolatedMarginCalculation) return ZERO; + if (!isolatedMarginCalculation) return new BN(-1); const { totalCollateral, marginRequirement } = isolatedMarginCalculation; const freeCollateral = BN.max( @@ -4290,10 +4286,20 @@ export class User { const userCustomMarginRatio = marginCategory === 'Initial' ? this.getUserAccount().maxMarginRatio : 0; - // Initialize calc via JS mirror of Rust MarginCalculation + // Initialize calc via JS mirror of Rust/on-chain MarginCalculation + const isolatedMarginBuffers = new Map(); + for (const [ + marketIndex, + isolatedMarginBuffer, + ] of opts?.liquidationBufferMap ?? new Map()) { + if (marketIndex !== 'cross') { + isolatedMarginBuffers.set(marketIndex, isolatedMarginBuffer); + } + } const ctx = MarginContext.standard(marginCategory) .strictMode(strict) - .setMarginBuffer(opts?.liquidationBufferMap ?? new Map()); + .setCrossMarginBuffer(opts?.liquidationBufferMap?.get('cross') ?? ZERO) + .setIsolatedMarginBuffers(isolatedMarginBuffers); const calc = new MarginCalculation(ctx); // SPOT POSITIONS @@ -4354,7 +4360,6 @@ export class User { const { tokenAmount: worstCaseTokenAmount, ordersValue: worstCaseOrdersValue, - tokenValue: worstCaseTokenValue, } = getWorstCaseTokenAmounts( spotPosition, spotMarket, @@ -4373,7 +4378,7 @@ export class User { ); } - if (worstCaseTokenAmount.gt(ZERO) && !isQuote) { + if (worstCaseTokenAmount.gt(ZERO)) { const baseAssetValue = this.getSpotAssetValue( worstCaseTokenAmount, strictOracle, @@ -4382,7 +4387,7 @@ export class User { ); // asset side increases total collateral (weighted) calc.addCrossMarginTotalCollateral(baseAssetValue); - } else if (worstCaseTokenAmount.lt(ZERO) && !isQuote) { + } else if (worstCaseTokenAmount.lt(ZERO)) { // liability side increases margin requirement (weighted >= abs(token_value)) const getSpotLiabilityValue = this.getSpotLiabilityValue( worstCaseTokenAmount, @@ -4396,9 +4401,6 @@ export class User { getSpotLiabilityValue.abs(), getSpotLiabilityValue.abs() ); - calc.addSpotLiabilityValue(worstCaseTokenValue.abs()); - } else if (spotPosition.openOrders !== 0 && !isQuote) { - calc.addSpotLiabilityValue(worstCaseTokenValue.abs()); } // orders value contributes to collateral or requirement From 71ebbe9a8686ee4b8107b8bcd987a97a47d87c68 Mon Sep 17 00:00:00 2001 From: Lukas deConantsesznak Date: Wed, 12 Nov 2025 11:43:08 -0500 Subject: [PATCH 12/13] rm: old user comparison script and old user margin calc --- sdk/scripts/compare-user-parity.ts | 452 --- sdk/src/user_oldMarginCalculation.ts | 4131 -------------------------- 2 files changed, 4583 deletions(-) delete mode 100644 sdk/scripts/compare-user-parity.ts delete mode 100644 sdk/src/user_oldMarginCalculation.ts diff --git a/sdk/scripts/compare-user-parity.ts b/sdk/scripts/compare-user-parity.ts deleted file mode 100644 index 10e0cd09ab..0000000000 --- a/sdk/scripts/compare-user-parity.ts +++ /dev/null @@ -1,452 +0,0 @@ -import { Connection, Commitment, PublicKey } from '@solana/web3.js'; -import { AnchorProvider, BN } from '@coral-xyz/anchor'; -import { Keypair } from '@solana/web3.js'; - -import { DriftClient } from '../src/driftClient'; -import { BulkAccountLoader } from '../src/accounts/bulkAccountLoader'; -import { DRIFT_PROGRAM_ID, Wallet } from '../src'; -import { User as CurrentUser } from '../src/user'; -import { User as OldUser } from '../src/user_oldMarginCalculation'; -import { UserMap } from '../src/userMap/userMap'; -import { UserMapConfig } from '../src/userMap/userMapConfig'; - -type MarginCategory = 'Initial' | 'Maintenance'; - -function getEnv(name: string, fallback?: string): string { - const v = process.env[name]; - if (v === undefined || v === '') { - if (fallback !== undefined) return fallback; - throw new Error(`${name} env var must be set.`); - } - return v; -} - -function asCommitment( - maybe: string | undefined, - fallback: Commitment -): Commitment { - const val = (maybe as Commitment) || fallback; - return val; -} - -function bnEq(a: BN, b: BN): boolean { - return a.eq(b); -} - -const EXPECTED_DIFF_THRESHOLD_BPS = 10; // 0.1% - -function isWithinRelativeBps(a: BN, b: BN, thresholdBps: number): boolean { - const aAbs = a.abs(); - const bAbs = b.abs(); - const maxAbs = aAbs.gt(bAbs) ? aAbs : bAbs; - if (maxAbs.isZero()) return true; // both zero - const diff = a.sub(b).abs(); - // diff / maxAbs <= thresholdBps / 10000 - // => diff * 10000 <= thresholdBps * maxAbs - return diff.mul(new BN(10000)).lte(maxAbs.mul(new BN(thresholdBps))); -} - -function buildOldUserFromSnapshot( - driftClient: DriftClient, - currentUser: CurrentUser -): OldUser { - const userAccountPubkey = currentUser.getUserAccountPublicKey(); - - const oldUser = new OldUser({ - driftClient, - userAccountPublicKey: userAccountPubkey, - accountSubscription: { - type: 'custom', - userAccountSubscriber: currentUser.accountSubscriber, - }, - }); - - return oldUser; -} - -function logMismatch( - userPubkey: PublicKey, - fn: string, - args: Record, - vNew: BN, - vOld: BN -) { - // Ensure BN values are logged as strings and arrays are printable - const serialize = (val: unknown): unknown => { - if (val instanceof BN) return val.toString(); - if (Array.isArray(val)) - return val.map((x) => (x instanceof BN ? x.toString() : x)); - return val; - }; - - const argsSerialized: Record = {}; - for (const k of Object.keys(args)) { - argsSerialized[k] = serialize(args[k]); - } - - const argsLines = Object.keys(argsSerialized) - .map( - (k) => - `\t- ${k}: ${ - Array.isArray(argsSerialized[k]) - ? (argsSerialized[k] as unknown[]).join(', ') - : String(argsSerialized[k]) - }` - ) - .join('|'); - - console.error( - // `❌ Parity mismatch\n` + - `- ❌ user: ${userPubkey.toBase58()} | function: ${fn}\n` + - `- args:\n${argsLines || '\t- none'}\n` + - `- new: ${vNew.toString()} | old: ${vOld.toString()}\n` - ); -} - -function logWarning( - userPubkey: PublicKey, - fn: string, - args: Record, - vNew: BN, - vOld: BN -): void { - const argsLines = Object.keys(args) - .map((k) => `\t- ${k}: ${String(args[k])}`) - .join('|'); - console.warn( - `- ⚠️ expected change | user: ${userPubkey.toBase58()} | function: ${fn}\n` + - `- args:\n${argsLines || '\t- none'}\n` + - `- new: ${vNew.toString()} | old: ${vOld.toString()}\n` - ); -} - -function logBooleanMismatch( - userPubkey: PublicKey, - fn: string, - args: Record, - vNew: boolean, - vOld: boolean -): void { - const argsLines = Object.keys(args) - .map((k) => `\t- ${k}: ${String(args[k])}`) - .join('|'); - console.error( - `- ❌ user: ${userPubkey.toBase58()} | function: ${fn}\n` + - `- args:\n${argsLines || '\t- none'}\n` + - `- new: ${String(vNew)} | old: ${String(vOld)}\n` - ); -} - -async function main(): Promise { - const RPC_ENDPOINT = getEnv('RPC_ENDPOINT'); - const COMMITMENT = asCommitment(process.env.COMMITMENT, 'processed'); - const POLL_FREQUENCY_MS = Number(process.env.POLL_FREQUENCY_MS || '40000'); - - const connection = new Connection(RPC_ENDPOINT, COMMITMENT); - const wallet = new Wallet(new Keypair()); - - // AnchorProvider is not strictly required for polling, but some downstream utils expect a provider on the program - // eslint-disable-next-line @typescript-eslint/no-unused-vars - const _provider = new AnchorProvider( - connection, - wallet as unknown as AnchorProvider['wallet'], - { - commitment: COMMITMENT, - preflightCommitment: COMMITMENT, - } - ); - - const bulkAccountLoader = new BulkAccountLoader( - connection, - COMMITMENT, - POLL_FREQUENCY_MS - ); - - const driftClient = new DriftClient({ - connection, - wallet, - programID: new PublicKey(DRIFT_PROGRAM_ID), - accountSubscription: { - type: 'polling', - accountLoader: bulkAccountLoader, - }, - }); - - await driftClient.subscribe(); - - const userMapConfig: UserMapConfig = { - driftClient, - subscriptionConfig: { - type: 'polling', - frequency: POLL_FREQUENCY_MS, - commitment: COMMITMENT, - }, - includeIdle: false, - fastDecode: true, - throwOnFailedSync: false, - }; - - const userMap = new UserMap(userMapConfig); - await userMap.subscribe(); - await userMap.sync(); - - let mismatches = 0; - let usersChecked = 0; - const mismatchesByFunction: Record = {}; - const usersWithDiscrepancies = new Set(); - - const isolatedKeysEnv = process.env.ISOLATED_USER_PUBKEY; - const isolatedKeys = - isolatedKeysEnv && isolatedKeysEnv.length > 0 - ? isolatedKeysEnv - .split(',') - .map((k) => k.trim()) - .filter((k) => k.length > 0) - : []; - - const usersFilterd = - isolatedKeys.length > 0 - ? Array.from(userMap.entries()).filter(([userKey]) => - isolatedKeys.includes(userKey) - ) - : Array.from(userMap.entries()); - - function noteMismatch(functionName: string, userPubkey): void { - mismatchesByFunction[functionName] = - (mismatchesByFunction[functionName] ?? 0) + 1; - usersWithDiscrepancies.add(userPubkey.toBase58()); - mismatches += 1; - } - - for (const [userKey, currUser] of usersFilterd) { - usersChecked += 1; - const userPubkey = new PublicKey(userKey); - // clean curr User position flags to be all 0 - - currUser.getActivePerpPositions().forEach((position) => { - position.positionFlag = 0; - }); - - const oldUser = buildOldUserFromSnapshot(driftClient, currUser); - - try { - // Cross-account level comparisons - // const categories: MarginCategory[] = ['Initial', 'Maintenance']; - const categories: MarginCategory[] = ['Initial']; - // const categories: MarginCategory[] = ['Maintenance']; - // const categories: MarginCategory[] = []; - - for (const cat of categories) { - // getFreeCollateral - const vNew_fc = currUser.getFreeCollateral(cat); - const vOld_fc = oldUser.getFreeCollateral(cat); - if ( - !bnEq(vNew_fc, vOld_fc) && - !isWithinRelativeBps(vNew_fc, vOld_fc, EXPECTED_DIFF_THRESHOLD_BPS) - ) { - logMismatch( - userPubkey, - 'getFreeCollateral', - { marginCategory: cat }, - vNew_fc, - vOld_fc - ); - noteMismatch('getFreeCollateral', userPubkey); - } - - // only do free collateral for now - // continue; - - // getTotalCollateral - const vNew_tc = currUser.getTotalCollateral(cat); - const vOld_tc = oldUser.getTotalCollateral(cat); - if (!bnEq(vNew_tc, vOld_tc)) { - if ( - isWithinRelativeBps(vNew_tc, vOld_tc, EXPECTED_DIFF_THRESHOLD_BPS) - ) { - logWarning( - userPubkey, - 'getTotalCollateral', - { marginCategory: cat }, - vNew_tc, - vOld_tc - ); - } else { - logWarning( - userPubkey, - 'getTotalCollateral', - { marginCategory: cat }, - vNew_tc, - vOld_tc - ); - } - } - - // getMarginRequirement (strict=true, includeOpenOrders=true) - const vNew_mr = currUser.getMarginRequirement( - cat, - undefined, - true, - true - ); - const vOld_mr = oldUser.getMarginRequirement( - cat, - undefined, - true, - true - ); - if (!bnEq(vNew_mr, vOld_mr)) { - if ( - isWithinRelativeBps(vNew_mr, vOld_mr, EXPECTED_DIFF_THRESHOLD_BPS) - ) { - logWarning( - userPubkey, - 'getMarginRequirement', - { marginCategory: cat, strict: true, includeOpenOrders: true }, - vNew_mr, - vOld_mr - ); - } else { - logWarning( - userPubkey, - 'getMarginRequirement', - { marginCategory: cat, strict: true, includeOpenOrders: true }, - vNew_mr, - vOld_mr - ); - } - } - } - // continue; - - // canBeLiquidated parity (cross margin status) - { - const vNew_liq = currUser.canBeLiquidated(); - const vOld_liq = oldUser.canBeLiquidated(); - - // boolean - if (vNew_liq.canBeLiquidated !== vOld_liq.canBeLiquidated) { - logBooleanMismatch( - userPubkey, - 'canBeLiquidated', - { field: 'canBeLiquidated' }, - vNew_liq.canBeLiquidated, - vOld_liq.canBeLiquidated - ); - noteMismatch('canBeLiquidated', userPubkey); - } - } - - // Per-perp-market comparisons - const activePerpPositions = currUser.getActivePerpPositions(); - for (const pos of activePerpPositions) { - const marketIndex = pos.marketIndex; - - // getPerpBuyingPower - const vNew_pbp = currUser.getPerpBuyingPower(marketIndex); - const vOld_pbp = oldUser.getPerpBuyingPower(marketIndex); - if (!bnEq(vNew_pbp, vOld_pbp)) { - if ( - isWithinRelativeBps(vNew_pbp, vOld_pbp, EXPECTED_DIFF_THRESHOLD_BPS) - ) { - logWarning( - userPubkey, - 'getPerpBuyingPower', - { marketIndex }, - vNew_pbp, - vOld_pbp - ); - } else { - logMismatch( - userPubkey, - 'getPerpBuyingPower', - { marketIndex }, - vNew_pbp, - vOld_pbp - ); - noteMismatch('getPerpBuyingPower', userPubkey); - } - } - - // liquidationPrice (defaults) - const vNew_lp = currUser.liquidationPrice(marketIndex); - const vOld_lp = oldUser.liquidationPrice(marketIndex); - if (!bnEq(vNew_lp, vOld_lp)) { - logMismatch( - userPubkey, - 'liquidationPrice', - { marketIndex }, - vNew_lp, - vOld_lp - ); - noteMismatch('liquidationPrice', userPubkey); - } - - // liquidationPriceAfterClose with 10% of current quote as close amount (skip if zero/absent) - const quoteAbs = pos.quoteAssetAmount - ? pos.quoteAssetAmount.abs() - : new BN(0); - const closeQuoteAmount = quoteAbs.div(new BN(10)); - if (closeQuoteAmount.gt(new BN(0))) { - const vNew_lpac = currUser.liquidationPriceAfterClose( - marketIndex, - closeQuoteAmount - ); - const vOld_lpac = oldUser.liquidationPriceAfterClose( - marketIndex, - closeQuoteAmount - ); - if (!bnEq(vNew_lpac, vOld_lpac)) { - logMismatch( - userPubkey, - 'liquidationPriceAfterClose', - { marketIndex, closeQuoteAmount: closeQuoteAmount.toString() }, - vNew_lpac, - vOld_lpac - ); - noteMismatch('liquidationPriceAfterClose', userPubkey); - } - } - } - } catch (e) { - console.error( - `💥 Parity exception\n` + - `- user: ${userPubkey.toBase58()}\n` + - `- error: ${(e as Error).message}` - ); - usersWithDiscrepancies.add(userPubkey.toBase58()); - mismatches += 1; - } finally { - await oldUser.unsubscribe(); - } - } - - const byFunctionLines = Object.entries(mismatchesByFunction) - .sort((a, b) => b[1] - a[1]) - .map(([fn, count]) => `\t- ${fn}: ${count}`) - .join('\n'); - - console.log( - `\n📊 User parity summary\n` + - `- users checked: ${usersChecked}\n` + - `- users with discrepancy: ${usersWithDiscrepancies.size}\n` + - `- percentage of users with discrepancy: ${ - (usersWithDiscrepancies.size / usersChecked) * 100 - }%\n` + - `- total mismatches: ${mismatches}\n` + - // `- percentage of mismatches: ${(mismatches / usersChecked) * 100}%\n` + - `- mismatches by function:\n${byFunctionLines || '\t- none'}\n` - ); - - await userMap.unsubscribe(); - await driftClient.unsubscribe(); - - if (mismatches > 0) { - process.exit(1); - } else { - process.exit(0); - } -} - -// eslint-disable-next-line @typescript-eslint/no-floating-promises -main(); diff --git a/sdk/src/user_oldMarginCalculation.ts b/sdk/src/user_oldMarginCalculation.ts deleted file mode 100644 index 6a13c75277..0000000000 --- a/sdk/src/user_oldMarginCalculation.ts +++ /dev/null @@ -1,4131 +0,0 @@ -import { PublicKey } from '@solana/web3.js'; -import { EventEmitter } from 'events'; -import StrictEventEmitter from 'strict-event-emitter-types'; -import { DriftClient } from './driftClient'; -import { - HealthComponent, - HealthComponents, - isVariant, - MarginCategory, - Order, - PerpMarketAccount, - PerpPosition, - SpotPosition, - UserAccount, - UserStatus, - UserStatsAccount, -} from './types'; -import { - calculateEntryPrice, - calculateUnsettledFundingPnl, - positionIsAvailable, -} from './math/position'; -import { - AMM_RESERVE_PRECISION, - AMM_TO_QUOTE_PRECISION_RATIO, - BASE_PRECISION, - BN_MAX, - DUST_POSITION_SIZE, - FIVE_MINUTE, - MARGIN_PRECISION, - OPEN_ORDER_MARGIN_REQUIREMENT, - PRICE_PRECISION, - QUOTE_PRECISION, - QUOTE_PRECISION_EXP, - QUOTE_SPOT_MARKET_INDEX, - SPOT_MARKET_WEIGHT_PRECISION, - TEN_THOUSAND, - TWO, - ZERO, - FUEL_START_TS, -} from './constants/numericConstants'; -import { - DataAndSlot, - UserAccountEvents, - UserAccountSubscriber, -} from './accounts/types'; -import { BigNum } from './factory/bigNum'; -import { BN } from '@coral-xyz/anchor'; -import { calculateBaseAssetValue, calculatePositionPNL } from './math/position'; -import { - calculateMarketMarginRatio, - calculateReservePrice, - calculateUnrealizedAssetWeight, -} from './math/market'; -import { - calculatePerpLiabilityValue, - calculateWorstCasePerpLiabilityValue, -} from './math/margin'; -import { calculateSpotMarketMarginRatio } from './math/spotMarket'; -import { divCeil, sigNum } from './math/utils'; -import { - getBalance, - getSignedTokenAmount, - getStrictTokenValue, - getTokenValue, -} from './math/spotBalance'; -import { getUser30dRollingVolumeEstimate } from './math/trade'; -import { - MarketType, - PositionDirection, - SpotBalanceType, - SpotMarketAccount, -} from './types'; -import { standardizeBaseAssetAmount } from './math/orders'; -import { UserStats } from './userStats'; -import { WebSocketProgramUserAccountSubscriber } from './accounts/websocketProgramUserAccountSubscriber'; -import { - calculateAssetWeight, - calculateLiabilityWeight, - calculateWithdrawLimit, - getSpotAssetValue, - getSpotLiabilityValue, - getTokenAmount, -} from './math/spotBalance'; -import { - calculateBaseAssetValueWithOracle, - calculateCollateralDepositRequiredForTrade, - calculateMarginUSDCRequiredForTrade, - calculateWorstCaseBaseAssetAmount, -} from './math/margin'; -import { MMOraclePriceData, OraclePriceData } from './oracles/types'; -import { UserConfig } from './userConfig'; -import { PollingUserAccountSubscriber } from './accounts/pollingUserAccountSubscriber'; -import { WebSocketUserAccountSubscriber } from './accounts/webSocketUserAccountSubscriber'; -import { - calculateWeightedTokenValue, - getWorstCaseTokenAmounts, - isSpotPositionAvailable, -} from './math/spotPosition'; -import { - calculateLiveOracleTwap, - getMultipleBetweenOracleSources, -} from './math/oracles'; -import { getPerpMarketTierNumber, getSpotMarketTierNumber } from './math/tiers'; -import { StrictOraclePrice } from './oracles/strictOraclePrice'; - -import { calculateSpotFuelBonus, calculatePerpFuelBonus } from './math/fuel'; -import { grpcUserAccountSubscriber } from './accounts/grpcUserAccountSubscriber'; - -export class User { - driftClient: DriftClient; - userAccountPublicKey: PublicKey; - accountSubscriber: UserAccountSubscriber; - _isSubscribed = false; - eventEmitter: StrictEventEmitter; - - public get isSubscribed() { - return this._isSubscribed && this.accountSubscriber.isSubscribed; - } - - public set isSubscribed(val: boolean) { - this._isSubscribed = val; - } - - public constructor(config: UserConfig) { - this.driftClient = config.driftClient; - this.userAccountPublicKey = config.userAccountPublicKey; - if (config.accountSubscription?.type === 'polling') { - this.accountSubscriber = new PollingUserAccountSubscriber( - config.driftClient.connection, - config.userAccountPublicKey, - config.accountSubscription.accountLoader, - this.driftClient.program.account.user.coder.accounts.decodeUnchecked.bind( - this.driftClient.program.account.user.coder.accounts - ) - ); - } else if (config.accountSubscription?.type === 'custom') { - this.accountSubscriber = config.accountSubscription.userAccountSubscriber; - } else if (config.accountSubscription?.type === 'grpc') { - if (config.accountSubscription.grpcMultiUserAccountSubscriber) { - this.accountSubscriber = - config.accountSubscription.grpcMultiUserAccountSubscriber.forUser( - config.userAccountPublicKey - ); - } else { - this.accountSubscriber = new grpcUserAccountSubscriber( - config.accountSubscription.grpcConfigs, - config.driftClient.program, - config.userAccountPublicKey, - { - resubTimeoutMs: config.accountSubscription?.resubTimeoutMs, - logResubMessages: config.accountSubscription?.logResubMessages, - } - ); - } - } else { - if ( - config.accountSubscription?.type === 'websocket' && - config.accountSubscription?.programUserAccountSubscriber - ) { - this.accountSubscriber = new WebSocketProgramUserAccountSubscriber( - config.driftClient.program, - config.userAccountPublicKey, - config.accountSubscription.programUserAccountSubscriber - ); - } else { - this.accountSubscriber = new WebSocketUserAccountSubscriber( - config.driftClient.program, - config.userAccountPublicKey, - { - resubTimeoutMs: config.accountSubscription?.resubTimeoutMs, - logResubMessages: config.accountSubscription?.logResubMessages, - }, - config.accountSubscription?.commitment - ); - } - } - this.eventEmitter = this.accountSubscriber.eventEmitter; - } - - /** - * Subscribe to User state accounts - * @returns SusbcriptionSuccess result - */ - public async subscribe(userAccount?: UserAccount): Promise { - this.isSubscribed = await this.accountSubscriber.subscribe(userAccount); - return this.isSubscribed; - } - - /** - * Forces the accountSubscriber to fetch account updates from rpc - */ - public async fetchAccounts(): Promise { - await this.accountSubscriber.fetch(); - } - - public async unsubscribe(): Promise { - await this.accountSubscriber.unsubscribe(); - this.isSubscribed = false; - } - - public getUserAccount(): UserAccount { - return this.accountSubscriber.getUserAccountAndSlot().data; - } - - public async forceGetUserAccount(): Promise { - await this.fetchAccounts(); - return this.accountSubscriber.getUserAccountAndSlot().data; - } - - public getUserAccountAndSlot(): DataAndSlot | undefined { - return this.accountSubscriber.getUserAccountAndSlot(); - } - - public getPerpPositionForUserAccount( - userAccount: UserAccount, - marketIndex: number - ): PerpPosition | undefined { - return this.getActivePerpPositionsForUserAccount(userAccount).find( - (position) => position.marketIndex === marketIndex - ); - } - - /** - * Gets the user's current position for a given perp market. If the user has no position returns undefined - * @param marketIndex - * @returns userPerpPosition - */ - public getPerpPosition(marketIndex: number): PerpPosition | undefined { - const userAccount = this.getUserAccount(); - return this.getPerpPositionForUserAccount(userAccount, marketIndex); - } - - public getPerpPositionOrEmpty(marketIndex: number): PerpPosition { - const userAccount = this.getUserAccount(); - return ( - this.getPerpPositionForUserAccount(userAccount, marketIndex) ?? - this.getEmptyPosition(marketIndex) - ); - } - - public getPerpPositionAndSlot( - marketIndex: number - ): DataAndSlot { - const userAccount = this.getUserAccountAndSlot(); - const perpPosition = this.getPerpPositionForUserAccount( - userAccount.data, - marketIndex - ); - return { - data: perpPosition, - slot: userAccount.slot, - }; - } - - public getSpotPositionForUserAccount( - userAccount: UserAccount, - marketIndex: number - ): SpotPosition | undefined { - return userAccount.spotPositions.find( - (position) => position.marketIndex === marketIndex - ); - } - - /** - * Gets the user's current position for a given spot market. If the user has no position returns undefined - * @param marketIndex - * @returns userSpotPosition - */ - public getSpotPosition(marketIndex: number): SpotPosition | undefined { - const userAccount = this.getUserAccount(); - return this.getSpotPositionForUserAccount(userAccount, marketIndex); - } - - public getSpotPositionAndSlot( - marketIndex: number - ): DataAndSlot { - const userAccount = this.getUserAccountAndSlot(); - const spotPosition = this.getSpotPositionForUserAccount( - userAccount.data, - marketIndex - ); - return { - data: spotPosition, - slot: userAccount.slot, - }; - } - - getEmptySpotPosition(marketIndex: number): SpotPosition { - return { - marketIndex, - scaledBalance: ZERO, - balanceType: SpotBalanceType.DEPOSIT, - cumulativeDeposits: ZERO, - openAsks: ZERO, - openBids: ZERO, - openOrders: 0, - }; - } - - /** - * Returns the token amount for a given market. The spot market precision is based on the token mint decimals. - * Positive if it is a deposit, negative if it is a borrow. - * - * @param marketIndex - */ - public getTokenAmount(marketIndex: number): BN { - const spotPosition = this.getSpotPosition(marketIndex); - if (spotPosition === undefined) { - return ZERO; - } - const spotMarket = this.driftClient.getSpotMarketAccount(marketIndex); - return getSignedTokenAmount( - getTokenAmount( - spotPosition.scaledBalance, - spotMarket, - spotPosition.balanceType - ), - spotPosition.balanceType - ); - } - - public getEmptyPosition(marketIndex: number): PerpPosition { - return { - baseAssetAmount: ZERO, - remainderBaseAssetAmount: 0, - lastCumulativeFundingRate: ZERO, - marketIndex, - quoteAssetAmount: ZERO, - quoteEntryAmount: ZERO, - quoteBreakEvenAmount: ZERO, - openOrders: 0, - openBids: ZERO, - openAsks: ZERO, - settledPnl: ZERO, - lpShares: ZERO, - lastBaseAssetAmountPerLp: ZERO, - lastQuoteAssetAmountPerLp: ZERO, - perLpBase: 0, - maxMarginRatio: 0, - isolatedPositionScaledBalance: ZERO, - positionFlag: 0, - }; - } - - public getClonedPosition(position: PerpPosition): PerpPosition { - const clonedPosition = Object.assign({}, position); - return clonedPosition; - } - - public getOrderForUserAccount( - userAccount: UserAccount, - orderId: number - ): Order | undefined { - return userAccount.orders.find((order) => order.orderId === orderId); - } - - /** - * @param orderId - * @returns Order - */ - public getOrder(orderId: number): Order | undefined { - const userAccount = this.getUserAccount(); - return this.getOrderForUserAccount(userAccount, orderId); - } - - public getOrderAndSlot(orderId: number): DataAndSlot { - const userAccount = this.getUserAccountAndSlot(); - const order = this.getOrderForUserAccount(userAccount.data, orderId); - return { - data: order, - slot: userAccount.slot, - }; - } - - public getOrderByUserIdForUserAccount( - userAccount: UserAccount, - userOrderId: number - ): Order | undefined { - return userAccount.orders.find( - (order) => order.userOrderId === userOrderId - ); - } - - /** - * @param userOrderId - * @returns Order - */ - public getOrderByUserOrderId(userOrderId: number): Order | undefined { - const userAccount = this.getUserAccount(); - return this.getOrderByUserIdForUserAccount(userAccount, userOrderId); - } - - public getOrderByUserOrderIdAndSlot( - userOrderId: number - ): DataAndSlot { - const userAccount = this.getUserAccountAndSlot(); - const order = this.getOrderByUserIdForUserAccount( - userAccount.data, - userOrderId - ); - return { - data: order, - slot: userAccount.slot, - }; - } - - public getOpenOrdersForUserAccount(userAccount?: UserAccount): Order[] { - return userAccount?.orders.filter((order) => - isVariant(order.status, 'open') - ); - } - - public getOpenOrders(): Order[] { - const userAccount = this.getUserAccount(); - return this.getOpenOrdersForUserAccount(userAccount); - } - - public getOpenOrdersAndSlot(): DataAndSlot { - const userAccount = this.getUserAccountAndSlot(); - const openOrders = this.getOpenOrdersForUserAccount(userAccount.data); - return { - data: openOrders, - slot: userAccount.slot, - }; - } - - public getUserAccountPublicKey(): PublicKey { - return this.userAccountPublicKey; - } - - public async exists(): Promise { - const userAccountRPCResponse = - await this.driftClient.connection.getParsedAccountInfo( - this.userAccountPublicKey - ); - return userAccountRPCResponse.value !== null; - } - - /** - * calculates the total open bids/asks in a perp market (including lps) - * @returns : open bids - * @returns : open asks - */ - public getPerpBidAsks(marketIndex: number): [BN, BN] { - const position = this.getPerpPosition(marketIndex); - - const totalOpenBids = position.openBids; - const totalOpenAsks = position.openAsks; - - return [totalOpenBids, totalOpenAsks]; - } - - /** - * calculates Buying Power = free collateral / initial margin ratio - * @returns : Precision QUOTE_PRECISION - */ - public getPerpBuyingPower( - marketIndex: number, - collateralBuffer = ZERO, - enterHighLeverageMode = undefined, - maxMarginRatio = undefined - ): BN { - const perpPosition = this.getPerpPositionOrEmpty(marketIndex); - - const perpMarket = this.driftClient.getPerpMarketAccount(marketIndex); - const oraclePriceData = this.getOracleDataForPerpMarket(marketIndex); - const worstCaseBaseAssetAmount = perpPosition - ? calculateWorstCaseBaseAssetAmount( - perpPosition, - perpMarket, - oraclePriceData.price - ) - : ZERO; - - const freeCollateral = this.getFreeCollateral( - 'Initial', - enterHighLeverageMode - ).sub(collateralBuffer); - - return this.getPerpBuyingPowerFromFreeCollateralAndBaseAssetAmount( - marketIndex, - freeCollateral, - worstCaseBaseAssetAmount, - enterHighLeverageMode, - maxMarginRatio || perpPosition.maxMarginRatio - ); - } - - getPerpBuyingPowerFromFreeCollateralAndBaseAssetAmount( - marketIndex: number, - freeCollateral: BN, - baseAssetAmount: BN, - enterHighLeverageMode = undefined, - perpMarketMaxMarginRatio = undefined - ): BN { - const maxMarginRatio = Math.max( - perpMarketMaxMarginRatio, - this.getUserAccount().maxMarginRatio - ); - const marginRatio = calculateMarketMarginRatio( - this.driftClient.getPerpMarketAccount(marketIndex), - baseAssetAmount, - 'Initial', - maxMarginRatio, - enterHighLeverageMode || this.isHighLeverageMode('Initial') - ); - - return freeCollateral.mul(MARGIN_PRECISION).div(new BN(marginRatio)); - } - - /** - * calculates Free Collateral = Total collateral - margin requirement - * @returns : Precision QUOTE_PRECISION - */ - public getFreeCollateral( - marginCategory: MarginCategory = 'Initial', - enterHighLeverageMode = undefined - ): BN { - const totalCollateral = this.getTotalCollateral(marginCategory, true); - const marginRequirement = - marginCategory === 'Initial' - ? this.getInitialMarginRequirement(enterHighLeverageMode) - : this.getMaintenanceMarginRequirement(); - const freeCollateral = totalCollateral.sub(marginRequirement); - return freeCollateral.gte(ZERO) ? freeCollateral : ZERO; - } - - /** - * @returns The margin requirement of a certain type (Initial or Maintenance) in USDC. : QUOTE_PRECISION - */ - public getMarginRequirement( - marginCategory: MarginCategory, - liquidationBuffer?: BN, - strict = false, - includeOpenOrders = true, - enteringHighLeverage = undefined - ): BN { - const perpPositionLiabilityValue = this.getTotalPerpPositionLiability( - marginCategory, - liquidationBuffer, - includeOpenOrders, - strict, - enteringHighLeverage - ); - const spotMarketLiabilityValue = this.getSpotMarketLiabilityValue( - undefined, - marginCategory, - liquidationBuffer, - includeOpenOrders, - strict - ); - const totalMarginRequirement = perpPositionLiabilityValue.add( - spotMarketLiabilityValue - ); - return totalMarginRequirement; - } - - /** - * @returns The initial margin requirement in USDC. : QUOTE_PRECISION - */ - public getInitialMarginRequirement(enterHighLeverageMode = undefined): BN { - return this.getMarginRequirement( - 'Initial', - undefined, - true, - undefined, - enterHighLeverageMode - ); - } - - /** - * @returns The maintenance margin requirement in USDC. : QUOTE_PRECISION - */ - public getMaintenanceMarginRequirement(liquidationBuffer?: BN): BN { - return this.getMarginRequirement('Maintenance', liquidationBuffer); - } - - public getActivePerpPositionsForUserAccount( - userAccount: UserAccount - ): PerpPosition[] { - return userAccount.perpPositions.filter( - (pos) => - !pos.baseAssetAmount.eq(ZERO) || - !pos.quoteAssetAmount.eq(ZERO) || - !(pos.openOrders == 0) - ); - } - - public getActivePerpPositions(): PerpPosition[] { - const userAccount = this.getUserAccount(); - return this.getActivePerpPositionsForUserAccount(userAccount); - } - public getActivePerpPositionsAndSlot(): DataAndSlot { - const userAccount = this.getUserAccountAndSlot(); - const positions = this.getActivePerpPositionsForUserAccount( - userAccount.data - ); - return { - data: positions, - slot: userAccount.slot, - }; - } - - public getActiveSpotPositionsForUserAccount( - userAccount: UserAccount - ): SpotPosition[] { - return userAccount.spotPositions.filter( - (pos) => !isSpotPositionAvailable(pos) - ); - } - - public getActiveSpotPositions(): SpotPosition[] { - const userAccount = this.getUserAccount(); - return this.getActiveSpotPositionsForUserAccount(userAccount); - } - public getActiveSpotPositionsAndSlot(): DataAndSlot { - const userAccount = this.getUserAccountAndSlot(); - const positions = this.getActiveSpotPositionsForUserAccount( - userAccount.data - ); - return { - data: positions, - slot: userAccount.slot, - }; - } - - /** - * calculates unrealized position price pnl - * @returns : Precision QUOTE_PRECISION - */ - public getUnrealizedPNL( - withFunding?: boolean, - marketIndex?: number, - withWeightMarginCategory?: MarginCategory, - strict = false, - liquidationBuffer?: BN - ): BN { - return this.getActivePerpPositions() - .filter((pos) => - marketIndex !== undefined ? pos.marketIndex === marketIndex : true - ) - .reduce((unrealizedPnl, perpPosition) => { - const market = this.driftClient.getPerpMarketAccount( - perpPosition.marketIndex - ); - const oraclePriceData = this.getMMOracleDataForPerpMarket( - market.marketIndex - ); - - const quoteSpotMarket = this.driftClient.getSpotMarketAccount( - market.quoteSpotMarketIndex - ); - const quoteOraclePriceData = this.getOracleDataForSpotMarket( - market.quoteSpotMarketIndex - ); - - // console.log( - // 'old user oralcePriceData for unrealized pnl', - // oraclePriceData.price.toString() - // ); - - let positionUnrealizedPnl = calculatePositionPNL( - market, - perpPosition, - withFunding, - oraclePriceData - ); - - // console.log( - // 'old user positionUnrealizedPnl before pnlQuotePrice', - // positionUnrealizedPnl.toString() - // ); - // console.log( - // 'old user quoteOraclePriceData.price', - // quoteOraclePriceData.price.toString() - // ); - // console.log( - // 'old user quoteSpotMarket.historicalOracleData.lastOraclePriceTwap5Min', - // quoteSpotMarket.historicalOracleData.lastOraclePriceTwap5Min.toString() - // ); - - let quotePrice; - if (strict && positionUnrealizedPnl.gt(ZERO)) { - quotePrice = BN.min( - quoteOraclePriceData.price, - quoteSpotMarket.historicalOracleData.lastOraclePriceTwap5Min - ); - } else if (strict && positionUnrealizedPnl.lt(ZERO)) { - quotePrice = BN.max( - quoteOraclePriceData.price, - quoteSpotMarket.historicalOracleData.lastOraclePriceTwap5Min - ); - } else { - quotePrice = quoteOraclePriceData.price; - } - - positionUnrealizedPnl = positionUnrealizedPnl - .mul(quotePrice) - .div(PRICE_PRECISION); - - // console.log( - // 'old user positionUnrealizedPnl after pnlQuotePrice', - // positionUnrealizedPnl.toString() - // ); - - if (withWeightMarginCategory !== undefined) { - if (positionUnrealizedPnl.gt(ZERO)) { - positionUnrealizedPnl = positionUnrealizedPnl - .mul( - calculateUnrealizedAssetWeight( - market, - quoteSpotMarket, - positionUnrealizedPnl, - withWeightMarginCategory, - oraclePriceData - ) - ) - .div(new BN(SPOT_MARKET_WEIGHT_PRECISION)); - } - - if (liquidationBuffer && positionUnrealizedPnl.lt(ZERO)) { - positionUnrealizedPnl = positionUnrealizedPnl.add( - positionUnrealizedPnl.mul(liquidationBuffer).div(MARGIN_PRECISION) - ); - // console.log( - // 'old user positionUnrealizedPnl post liquidationBuffer', - // positionUnrealizedPnl.toString() - // ); - } - } - - // console.log( - // `old user positionUnrealizedPnl [${perpPosition.marketIndex}]`, - // positionUnrealizedPnl.toString() - // ); - - return unrealizedPnl.add(positionUnrealizedPnl); - }, ZERO); - } - - /** - * calculates unrealized funding payment pnl - * @returns : Precision QUOTE_PRECISION - */ - public getUnrealizedFundingPNL(marketIndex?: number): BN { - return this.getUserAccount() - .perpPositions.filter((pos) => - marketIndex !== undefined ? pos.marketIndex === marketIndex : true - ) - .reduce((pnl, perpPosition) => { - const market = this.driftClient.getPerpMarketAccount( - perpPosition.marketIndex - ); - return pnl.add(calculateUnsettledFundingPnl(market, perpPosition)); - }, ZERO); - } - - public getFuelBonus( - now: BN, - includeSettled = true, - includeUnsettled = true, - givenUserStats?: UserStats - ): { - depositFuel: BN; - borrowFuel: BN; - positionFuel: BN; - takerFuel: BN; - makerFuel: BN; - insuranceFuel: BN; - } { - const userAccount: UserAccount = this.getUserAccount(); - - const result = { - insuranceFuel: ZERO, - takerFuel: ZERO, - makerFuel: ZERO, - depositFuel: ZERO, - borrowFuel: ZERO, - positionFuel: ZERO, - }; - - const userStats = givenUserStats ?? this.driftClient.getUserStats(); - const userStatsAccount: UserStatsAccount = userStats.getAccount(); - - if (includeSettled) { - result.takerFuel = result.takerFuel.add( - new BN(userStatsAccount.fuelTaker) - ); - result.makerFuel = result.makerFuel.add( - new BN(userStatsAccount.fuelMaker) - ); - result.depositFuel = result.depositFuel.add( - new BN(userStatsAccount.fuelDeposits) - ); - result.borrowFuel = result.borrowFuel.add( - new BN(userStatsAccount.fuelBorrows) - ); - result.positionFuel = result.positionFuel.add( - new BN(userStatsAccount.fuelPositions) - ); - } - - if (includeUnsettled) { - const fuelBonusNumerator = BN.max( - now.sub( - BN.max(new BN(userAccount.lastFuelBonusUpdateTs), FUEL_START_TS) - ), - ZERO - ); - - if (fuelBonusNumerator.gt(ZERO)) { - for (const spotPosition of this.getActiveSpotPositions()) { - const spotMarketAccount: SpotMarketAccount = - this.driftClient.getSpotMarketAccount(spotPosition.marketIndex); - - const tokenAmount = this.getTokenAmount(spotPosition.marketIndex); - const oraclePriceData = this.getOracleDataForSpotMarket( - spotPosition.marketIndex - ); - - const twap5min = calculateLiveOracleTwap( - spotMarketAccount.historicalOracleData, - oraclePriceData, - now, - FIVE_MINUTE // 5MIN - ); - const strictOraclePrice = new StrictOraclePrice( - oraclePriceData.price, - twap5min - ); - - const signedTokenValue = getStrictTokenValue( - tokenAmount, - spotMarketAccount.decimals, - strictOraclePrice - ); - - if (signedTokenValue.gt(ZERO)) { - result.depositFuel = result.depositFuel.add( - calculateSpotFuelBonus( - spotMarketAccount, - signedTokenValue, - fuelBonusNumerator - ) - ); - } else { - result.borrowFuel = result.borrowFuel.add( - calculateSpotFuelBonus( - spotMarketAccount, - signedTokenValue, - fuelBonusNumerator - ) - ); - } - } - - for (const perpPosition of this.getActivePerpPositions()) { - const oraclePriceData = this.getMMOracleDataForPerpMarket( - perpPosition.marketIndex - ); - - const perpMarketAccount = this.driftClient.getPerpMarketAccount( - perpPosition.marketIndex - ); - - const baseAssetValue = this.getPerpPositionValue( - perpPosition.marketIndex, - oraclePriceData, - false - ); - - result.positionFuel = result.positionFuel.add( - calculatePerpFuelBonus( - perpMarketAccount, - baseAssetValue, - fuelBonusNumerator - ) - ); - } - } - } - - result.insuranceFuel = userStats.getInsuranceFuelBonus( - now, - includeSettled, - includeUnsettled - ); - - return result; - } - - public getSpotMarketAssetAndLiabilityValue( - marketIndex?: number, - marginCategory?: MarginCategory, - liquidationBuffer?: BN, - includeOpenOrders?: boolean, - strict = false, - now?: BN - ): { totalAssetValue: BN; totalLiabilityValue: BN } { - // console.log( - // 'line 923 old user getSpotMarketAssetAndLiabilityValue strict?', - // strict - // ); - now = now || new BN(new Date().getTime() / 1000); - let netQuoteValue = ZERO; - let totalAssetValue = ZERO; - let totalLiabilityValue = ZERO; - for (const spotPosition of this.getUserAccount().spotPositions) { - const countForBase = - marketIndex === undefined || spotPosition.marketIndex === marketIndex; - - const countForQuote = - marketIndex === undefined || - marketIndex === QUOTE_SPOT_MARKET_INDEX || - (includeOpenOrders && spotPosition.openOrders !== 0); - - // console.log(' line936 countForBase', countForBase); - // console.log(' line937 countForQuote', countForQuote); - if ( - isSpotPositionAvailable(spotPosition) || - (!countForBase && !countForQuote) - ) { - // console.log( - // ` line945 old user SKIPPING spotPosition marketIndex: ${spotPosition.marketIndex}`, - // spotPosition.marketIndex - // ); - continue; - } - - const spotMarketAccount: SpotMarketAccount = - this.driftClient.getSpotMarketAccount(spotPosition.marketIndex); - - const oraclePriceData = this.getOracleDataForSpotMarket( - spotPosition.marketIndex - ); - - let twap5min; - if (strict) { - twap5min = calculateLiveOracleTwap( - spotMarketAccount.historicalOracleData, - oraclePriceData, - now, - FIVE_MINUTE // 5MIN - ); - } - // console.log('line 960 old user twap5min', twap5min?.toString()); - const strictOraclePrice = new StrictOraclePrice( - oraclePriceData.price, - twap5min - ); - - // console.log( - // `line 966 old user strict oracle, marketIndex: ${spotPosition.marketIndex}`, - // strictOraclePrice.current.toString() - // ); - // console.log( - // `line 978 old user strict oracle JSON, marketIndex: ${spotPosition.marketIndex}`, - // JSON.stringify(strictOraclePrice) - // ); - - if ( - spotPosition.marketIndex === QUOTE_SPOT_MARKET_INDEX && - countForQuote - ) { - const tokenAmount = getSignedTokenAmount( - getTokenAmount( - spotPosition.scaledBalance, - spotMarketAccount, - spotPosition.balanceType - ), - spotPosition.balanceType - ); - - // console.log( - // ' line984 old user spotPosition.balanceType', - // JSON.stringify(spotPosition.balanceType) - // ); - // console.log(' line988 old user tokenAmount', tokenAmount.toString()); - - if (isVariant(spotPosition.balanceType, 'borrow')) { - const weightedTokenValue = this.getSpotLiabilityValue( - tokenAmount, - strictOraclePrice, - spotMarketAccount, - marginCategory, - liquidationBuffer - ).abs(); - - // console.log( - // ` line 1003 old user weightedTokenValue on borrow marketIndex: ${spotPosition.marketIndex}`, - // weightedTokenValue.toString() - // ); - - netQuoteValue = netQuoteValue.sub(weightedTokenValue); - } else { - const weightedTokenValue = this.getSpotAssetValue( - tokenAmount, - strictOraclePrice, - spotMarketAccount, - marginCategory - ); - - // console.log( - // ' line1007 old user weightedTokenValue', - // weightedTokenValue.toString() - // ); - - netQuoteValue = netQuoteValue.add(weightedTokenValue); - } - - continue; - } - - if (!includeOpenOrders && countForBase) { - if (isVariant(spotPosition.balanceType, 'borrow')) { - const tokenAmount = getSignedTokenAmount( - getTokenAmount( - spotPosition.scaledBalance, - spotMarketAccount, - spotPosition.balanceType - ), - SpotBalanceType.BORROW - ); - const liabilityValue = this.getSpotLiabilityValue( - tokenAmount, - strictOraclePrice, - spotMarketAccount, - marginCategory, - liquidationBuffer - ).abs(); - - // console.log( - // `line 1033 old user adding to totalLiabilityValue, marketIndex: ${spotPosition.marketIndex}`, - // liabilityValue.toString() - // ); - totalLiabilityValue = totalLiabilityValue.add(liabilityValue); - - continue; - } else { - const tokenAmount = getTokenAmount( - spotPosition.scaledBalance, - spotMarketAccount, - spotPosition.balanceType - ); - const assetValue = this.getSpotAssetValue( - tokenAmount, - strictOraclePrice, - spotMarketAccount, - marginCategory - ); - // console.log( - // ` line1038 old user assetValue, marketIndex: ${spotPosition.marketIndex}`, - // assetValue.toString() - // ); - totalAssetValue = totalAssetValue.add(assetValue); - - continue; - } - } - - const { - tokenAmount: worstCaseTokenAmount, - ordersValue: worstCaseQuoteTokenAmount, - } = getWorstCaseTokenAmounts( - spotPosition, - spotMarketAccount, - strictOraclePrice, - marginCategory, - this.getUserAccount().maxMarginRatio - ); - - // console.log( - // ` line1059 old user worstCaseTokenAmount, marketIndex: ${spotPosition.marketIndex}`, - // worstCaseTokenAmount.toString() - // ); - // console.log( - // ` line1060 old user worstCaseQuoteTokenAmount, marketIndex: ${spotPosition.marketIndex}`, - // worstCaseQuoteTokenAmount.toString() - // ); - - if (worstCaseTokenAmount.gt(ZERO) && countForBase) { - // console.log( - // `line 1087 old user spotMarketAccount decimals, marketIndex: ${spotPosition.marketIndex}`, - // spotMarketAccount.decimals.toString() - // ); - // console.log( - // `line 1088 old user spotMarketAccount imfFactor, marketIndex: ${spotPosition.marketIndex}`, - // spotMarketAccount.imfFactor.toString() - // ); - - const baseAssetValue = this.getSpotAssetValue( - worstCaseTokenAmount, - strictOraclePrice, - spotMarketAccount, - marginCategory - ); - // console.log( - // ` line1085 old user asset value, marketIndex: ${spotPosition.marketIndex}`, - // baseAssetValue.toString() - // ); - totalAssetValue = totalAssetValue.add(baseAssetValue); - // console.log( - // 'old user totalAssetValue post add base', - // totalAssetValue.toString() - // ); - } - - if (worstCaseTokenAmount.lt(ZERO) && countForBase) { - // if (spotPosition.marketIndex === 1) { - // console.log( - // `line 1133 old user worstCaseTokenAmount, marketIndex: ${spotPosition.marketIndex}`, - // worstCaseTokenAmount.toString() - // ); - // console.log( - // `line 1134 old user strictOracle, marketIndex: ${spotPosition.marketIndex}`, - // strictOraclePrice.current.toString() - // ); - // console.log( - // `line 1135 old user spotMarket, marketIndex: ${spotPosition.marketIndex}`, - // spotMarketAccount.toString() - // ); - // console.log( - // `line 1136 old user marginCategory, marketIndex: ${spotPosition.marketIndex}`, - // marginCategory - // ); - // console.log( - // `line 1137 old user liquidationBuffer, marketIndex: ${spotPosition.marketIndex}`, - // liquidationBuffer?.toString() - // ); - // } - - const baseLiabilityValue = this.getSpotLiabilityValue( - worstCaseTokenAmount, - strictOraclePrice, - spotMarketAccount, - marginCategory, - liquidationBuffer - ).abs(); - - // console.log( - // `line 1109 old user adding to totalLiabilityValue, marketIndex: ${spotPosition.marketIndex}`, - // baseLiabilityValue.toString() - // ); - - totalLiabilityValue = totalLiabilityValue.add(baseLiabilityValue); - } - - if (worstCaseQuoteTokenAmount.gt(ZERO) && countForQuote) { - // console.log( - // ' line1108 old user worstCaseQuoteTokenAmount', - // worstCaseQuoteTokenAmount.toString() - // ); - netQuoteValue = netQuoteValue.add(worstCaseQuoteTokenAmount); - } - - if (worstCaseQuoteTokenAmount.lt(ZERO) && countForQuote) { - let weight = SPOT_MARKET_WEIGHT_PRECISION; - if (marginCategory === 'Initial') { - weight = BN.max(weight, new BN(this.getUserAccount().maxMarginRatio)); - } - - const weightedTokenValue = worstCaseQuoteTokenAmount - .abs() - .mul(weight) - .div(SPOT_MARKET_WEIGHT_PRECISION); - - // console.log( - // `line1126 old user weightedTokenValue ${spotPosition.marketIndex}`, - // weightedTokenValue.toString() - // ); - netQuoteValue = netQuoteValue.sub(weightedTokenValue); - } - - // console.log( - // ` line1165 old user open orders marketIndex: ${spotPosition.marketIndex}`, - // new BN(spotPosition.openOrders) - // .mul(OPEN_ORDER_MARGIN_REQUIREMENT) - // .toString() - // ); - - totalLiabilityValue = totalLiabilityValue.add( - new BN(spotPosition.openOrders).mul(OPEN_ORDER_MARGIN_REQUIREMENT) - ); - } - - // console.log( - // ' line1116 old user totalAssetValue', - // totalAssetValue.toString() - // ); - - // console.log(' line1121 old user netQuoteValue', netQuoteValue.toString()); - - if (marketIndex === undefined || marketIndex === QUOTE_SPOT_MARKET_INDEX) { - if (netQuoteValue.gt(ZERO)) { - // console.log( - // 'old user netQuoteValue post add', - // netQuoteValue.toString() - // ); - totalAssetValue = totalAssetValue.add(netQuoteValue); - // console.log( - // 'old user totalAssetValue post add netQuoteValue', - // totalAssetValue.toString() - // ); - } else { - // console.log( - // 'old user adding to totalLiabilityValue', - // netQuoteValue.abs().toString() - // ); - totalLiabilityValue = totalLiabilityValue.add(netQuoteValue.abs()); - } - } - - // console.log( - // ' line1138 old user totalAssetValue', - // totalAssetValue.toString() - // ); - // console.log( - // ' line1170 old user totalLiabilityValue after spot positions looping', - // totalLiabilityValue.toString() - // ); - // console.log( - // 'line 1183 old user getSpotMarketAssetAndLiabilityValue strict?', - // strict - // ); - // console.log( - // 'line 1184 old user getSpotMarketAssetAndLiabilityValue includeOpenOrders?', - // includeOpenOrders - // ); - - return { totalAssetValue, totalLiabilityValue }; - } - - public getSpotMarketLiabilityValue( - marketIndex?: number, - marginCategory?: MarginCategory, - liquidationBuffer?: BN, - includeOpenOrders?: boolean, - strict = false, - now?: BN - ): BN { - // console.log( - // 'line 1220 old user getSpotMarketLiabilityValue strict?', - // strict - // ); - const { totalLiabilityValue } = this.getSpotMarketAssetAndLiabilityValue( - marketIndex, - marginCategory, - liquidationBuffer, - includeOpenOrders, - strict, - now - ); - return totalLiabilityValue; - } - - getSpotLiabilityValue( - tokenAmount: BN, - strictOraclePrice: StrictOraclePrice, - spotMarketAccount: SpotMarketAccount, - marginCategory?: MarginCategory, - liquidationBuffer?: BN - ): BN { - return getSpotLiabilityValue( - tokenAmount, - strictOraclePrice, - spotMarketAccount, - this.getUserAccount().maxMarginRatio, - marginCategory, - liquidationBuffer - ); - } - - public getSpotMarketAssetValue( - marketIndex?: number, - marginCategory?: MarginCategory, - includeOpenOrders?: boolean, - strict = false, - now?: BN - ): BN { - const { totalAssetValue } = this.getSpotMarketAssetAndLiabilityValue( - marketIndex, - marginCategory, - undefined, - includeOpenOrders, - strict, - now - ); - return totalAssetValue; - } - - getSpotAssetValue( - tokenAmount: BN, - strictOraclePrice: StrictOraclePrice, - spotMarketAccount: SpotMarketAccount, - marginCategory?: MarginCategory - ): BN { - return getSpotAssetValue( - tokenAmount, - strictOraclePrice, - spotMarketAccount, - this.getUserAccount().maxMarginRatio, - marginCategory - ); - } - - public getSpotPositionValue( - marketIndex: number, - marginCategory?: MarginCategory, - includeOpenOrders?: boolean, - strict = false, - now?: BN - ): BN { - const { totalAssetValue, totalLiabilityValue } = - this.getSpotMarketAssetAndLiabilityValue( - marketIndex, - marginCategory, - undefined, - includeOpenOrders, - strict, - now - ); - - return totalAssetValue.sub(totalLiabilityValue); - } - - public getNetSpotMarketValue(withWeightMarginCategory?: MarginCategory): BN { - const { totalAssetValue, totalLiabilityValue } = - this.getSpotMarketAssetAndLiabilityValue( - undefined, - withWeightMarginCategory - ); - - return totalAssetValue.sub(totalLiabilityValue); - } - - /** - * calculates TotalCollateral: collateral + unrealized pnl - * @returns : Precision QUOTE_PRECISION - */ - public getTotalCollateral( - marginCategory: MarginCategory = 'Initial', - strict = false, - includeOpenOrders = true, - liquidationBuffer?: BN - ): BN { - const unrealizedPNL = this.getUnrealizedPNL( - true, - undefined, - marginCategory, - strict, - liquidationBuffer - ); - const spotMarketAssetValue = this.getSpotMarketAssetValue( - undefined, - marginCategory, - includeOpenOrders, - strict - ); - return spotMarketAssetValue.add(unrealizedPNL); - } - - public getLiquidationBuffer(): BN | undefined { - // if user being liq'd, can continue to be liq'd until total collateral above the margin requirement plus buffer - let liquidationBuffer = undefined; - if (this.isBeingLiquidated()) { - liquidationBuffer = new BN( - this.driftClient.getStateAccount().liquidationMarginBufferRatio - ); - } - return liquidationBuffer; - } - - /** - * calculates User Health by comparing total collateral and maint. margin requirement - * @returns : number (value from [0, 100]) - */ - public getHealth(): number { - if (this.isBeingLiquidated()) { - return 0; - } - - const totalCollateral = this.getTotalCollateral('Maintenance'); - const maintenanceMarginReq = this.getMaintenanceMarginRequirement(); - - let health: number; - - if (maintenanceMarginReq.eq(ZERO) && totalCollateral.gte(ZERO)) { - health = 100; - } else if (totalCollateral.lte(ZERO)) { - health = 0; - } else { - health = Math.round( - Math.min( - 100, - Math.max( - 0, - (1 - maintenanceMarginReq.toNumber() / totalCollateral.toNumber()) * - 100 - ) - ) - ); - } - - return health; - } - - calculateWeightedPerpPositionLiability( - perpPosition: PerpPosition, - marginCategory?: MarginCategory, - liquidationBuffer?: BN, - includeOpenOrders?: boolean, - strict = false, - enteringHighLeverage = undefined - ): BN { - const market = this.driftClient.getPerpMarketAccount( - perpPosition.marketIndex - ); - - let valuationPrice = this.getOracleDataForPerpMarket( - market.marketIndex - ).price; - - if (isVariant(market.status, 'settlement')) { - valuationPrice = market.expiryPrice; - } - - let baseAssetAmount: BN; - let liabilityValue; - if (includeOpenOrders) { - const { worstCaseBaseAssetAmount, worstCaseLiabilityValue } = - calculateWorstCasePerpLiabilityValue( - perpPosition, - market, - valuationPrice - ); - baseAssetAmount = worstCaseBaseAssetAmount; - liabilityValue = worstCaseLiabilityValue; - } else { - baseAssetAmount = perpPosition.baseAssetAmount; - liabilityValue = calculatePerpLiabilityValue( - baseAssetAmount, - valuationPrice, - isVariant(market.contractType, 'prediction') - ); - } - - if (marginCategory) { - const userCustomMargin = Math.max( - perpPosition.maxMarginRatio, - this.getUserAccount().maxMarginRatio - ); - let marginRatio = new BN( - calculateMarketMarginRatio( - market, - baseAssetAmount.abs(), - marginCategory, - enteringHighLeverage === false - ? Math.max(market.marginRatioInitial, userCustomMargin) - : userCustomMargin, - this.isHighLeverageMode(marginCategory) || - enteringHighLeverage === true - ) - ); - - if (liquidationBuffer !== undefined) { - marginRatio = marginRatio.add(liquidationBuffer); - } - - if (isVariant(market.status, 'settlement')) { - marginRatio = ZERO; - } - - const quoteSpotMarket = this.driftClient.getSpotMarketAccount( - market.quoteSpotMarketIndex - ); - const quoteOraclePriceData = this.driftClient.getOracleDataForSpotMarket( - QUOTE_SPOT_MARKET_INDEX - ); - - let quotePrice; - if (strict) { - quotePrice = BN.max( - quoteOraclePriceData.price, - quoteSpotMarket.historicalOracleData.lastOraclePriceTwap5Min - ); - } else { - quotePrice = quoteOraclePriceData.price; - } - - liabilityValue = liabilityValue - .mul(quotePrice) - .div(PRICE_PRECISION) - .mul(marginRatio) - .div(MARGIN_PRECISION); - - if (includeOpenOrders) { - liabilityValue = liabilityValue.add( - new BN(perpPosition.openOrders).mul(OPEN_ORDER_MARGIN_REQUIREMENT) - ); - } - } - - return liabilityValue; - } - - /** - * calculates position value of a single perp market in margin system - * @returns : Precision QUOTE_PRECISION - */ - public getPerpMarketLiabilityValue( - marketIndex: number, - marginCategory?: MarginCategory, - liquidationBuffer?: BN, - includeOpenOrders?: boolean, - strict = false - ): BN { - const perpPosition = this.getPerpPosition(marketIndex); - return this.calculateWeightedPerpPositionLiability( - perpPosition, - marginCategory, - liquidationBuffer, - includeOpenOrders, - strict - ); - } - - /** - * calculates sum of position value across all positions in margin system - * @returns : Precision QUOTE_PRECISION - */ - getTotalPerpPositionLiability( - marginCategory?: MarginCategory, - liquidationBuffer?: BN, - includeOpenOrders?: boolean, - strict = false, - enteringHighLeverage = undefined - ): BN { - return this.getActivePerpPositions().reduce( - (totalPerpValue, perpPosition) => { - const baseAssetValue = this.calculateWeightedPerpPositionLiability( - perpPosition, - marginCategory, - liquidationBuffer, - includeOpenOrders, - strict, - enteringHighLeverage - ); - return totalPerpValue.add(baseAssetValue); - }, - ZERO - ); - } - - /** - * calculates position value based on oracle - * @returns : Precision QUOTE_PRECISION - */ - public getPerpPositionValue( - marketIndex: number, - oraclePriceData: Pick, - includeOpenOrders = false - ): BN { - const userPosition = this.getPerpPositionOrEmpty(marketIndex); - const market = this.driftClient.getPerpMarketAccount( - userPosition.marketIndex - ); - return calculateBaseAssetValueWithOracle( - market, - userPosition, - oraclePriceData, - includeOpenOrders - ); - } - - /** - * calculates position liabiltiy value in margin system - * @returns : Precision QUOTE_PRECISION - */ - public getPerpLiabilityValue( - marketIndex: number, - oraclePriceData: OraclePriceData, - includeOpenOrders = false - ): BN { - const userPosition = this.getPerpPositionOrEmpty(marketIndex); - const market = this.driftClient.getPerpMarketAccount( - userPosition.marketIndex - ); - - if (includeOpenOrders) { - return calculateWorstCasePerpLiabilityValue( - userPosition, - market, - oraclePriceData.price - ).worstCaseLiabilityValue; - } else { - return calculatePerpLiabilityValue( - userPosition.baseAssetAmount, - oraclePriceData.price, - isVariant(market.contractType, 'prediction') - ); - } - } - - public getPositionSide( - currentPosition: Pick - ): PositionDirection | undefined { - if (currentPosition.baseAssetAmount.gt(ZERO)) { - return PositionDirection.LONG; - } else if (currentPosition.baseAssetAmount.lt(ZERO)) { - return PositionDirection.SHORT; - } else { - return undefined; - } - } - - /** - * calculates average exit price (optionally for closing up to 100% of position) - * @returns : Precision PRICE_PRECISION - */ - public getPositionEstimatedExitPriceAndPnl( - position: PerpPosition, - amountToClose?: BN, - useAMMClose = false - ): [BN, BN] { - const market = this.driftClient.getPerpMarketAccount(position.marketIndex); - - const entryPrice = calculateEntryPrice(position); - - const oraclePriceData = this.getMMOracleDataForPerpMarket( - position.marketIndex - ); - - if (amountToClose) { - if (amountToClose.eq(ZERO)) { - return [calculateReservePrice(market, oraclePriceData), ZERO]; - } - position = { - baseAssetAmount: amountToClose, - lastCumulativeFundingRate: position.lastCumulativeFundingRate, - marketIndex: position.marketIndex, - quoteAssetAmount: position.quoteAssetAmount, - } as PerpPosition; - } - - let baseAssetValue: BN; - - if (useAMMClose) { - baseAssetValue = calculateBaseAssetValue( - market, - position, - oraclePriceData - ); - } else { - baseAssetValue = calculateBaseAssetValueWithOracle( - market, - position, - oraclePriceData - ); - } - if (position.baseAssetAmount.eq(ZERO)) { - return [ZERO, ZERO]; - } - - const exitPrice = baseAssetValue - .mul(AMM_TO_QUOTE_PRECISION_RATIO) - .mul(PRICE_PRECISION) - .div(position.baseAssetAmount.abs()); - - const pnlPerBase = exitPrice.sub(entryPrice); - const pnl = pnlPerBase - .mul(position.baseAssetAmount) - .div(PRICE_PRECISION) - .div(AMM_TO_QUOTE_PRECISION_RATIO); - - return [exitPrice, pnl]; - } - - /** - * calculates current user leverage which is (total liability size) / (net asset value) - * @returns : Precision TEN_THOUSAND - */ - public getLeverage(includeOpenOrders = true): BN { - return this.calculateLeverageFromComponents( - this.getLeverageComponents(includeOpenOrders) - ); - } - - calculateLeverageFromComponents({ - perpLiabilityValue, - perpPnl, - spotAssetValue, - spotLiabilityValue, - }: { - perpLiabilityValue: BN; - perpPnl: BN; - spotAssetValue: BN; - spotLiabilityValue: BN; - }): BN { - const totalLiabilityValue = perpLiabilityValue.add(spotLiabilityValue); - const totalAssetValue = spotAssetValue.add(perpPnl); - const netAssetValue = totalAssetValue.sub(spotLiabilityValue); - - if (netAssetValue.eq(ZERO)) { - return ZERO; - } - - return totalLiabilityValue.mul(TEN_THOUSAND).div(netAssetValue); - } - - getLeverageComponents( - includeOpenOrders = true, - marginCategory: MarginCategory = undefined - ): { - perpLiabilityValue: BN; - perpPnl: BN; - spotAssetValue: BN; - spotLiabilityValue: BN; - } { - const perpLiability = this.getTotalPerpPositionLiability( - marginCategory, - undefined, - includeOpenOrders - ); - const perpPnl = this.getUnrealizedPNL(true, undefined, marginCategory); - - const { - totalAssetValue: spotAssetValue, - totalLiabilityValue: spotLiabilityValue, - } = this.getSpotMarketAssetAndLiabilityValue( - undefined, - marginCategory, - undefined, - includeOpenOrders - ); - - return { - perpLiabilityValue: perpLiability, - perpPnl, - spotAssetValue, - spotLiabilityValue, - }; - } - - isDustDepositPosition(spotMarketAccount: SpotMarketAccount): boolean { - const marketIndex = spotMarketAccount.marketIndex; - - const spotPosition = this.getSpotPosition(spotMarketAccount.marketIndex); - - if (isSpotPositionAvailable(spotPosition)) { - return false; - } - - const depositAmount = this.getTokenAmount(spotMarketAccount.marketIndex); - - if (depositAmount.lte(ZERO)) { - return false; - } - - const oraclePriceData = this.getOracleDataForSpotMarket(marketIndex); - - const strictOraclePrice = new StrictOraclePrice( - oraclePriceData.price, - oraclePriceData.twap - ); - - const balanceValue = this.getSpotAssetValue( - depositAmount, - strictOraclePrice, - spotMarketAccount - ); - - if (balanceValue.lt(DUST_POSITION_SIZE)) { - return true; - } - - return false; - } - - getSpotMarketAccountsWithDustPosition() { - const spotMarketAccounts = this.driftClient.getSpotMarketAccounts(); - - const dustPositionAccounts: SpotMarketAccount[] = []; - - for (const spotMarketAccount of spotMarketAccounts) { - const isDust = this.isDustDepositPosition(spotMarketAccount); - if (isDust) { - dustPositionAccounts.push(spotMarketAccount); - } - } - - return dustPositionAccounts; - } - - getTotalLiabilityValue(marginCategory?: MarginCategory): BN { - return this.getTotalPerpPositionLiability( - marginCategory, - undefined, - true - ).add( - this.getSpotMarketLiabilityValue( - undefined, - marginCategory, - undefined, - true - ) - ); - } - - getTotalAssetValue(marginCategory?: MarginCategory): BN { - return this.getSpotMarketAssetValue(undefined, marginCategory, true).add( - this.getUnrealizedPNL(true, undefined, marginCategory) - ); - } - - getNetUsdValue(): BN { - const netSpotValue = this.getNetSpotMarketValue(); - const unrealizedPnl = this.getUnrealizedPNL(true, undefined, undefined); - return netSpotValue.add(unrealizedPnl); - } - - /** - * Calculates the all time P&L of the user. - * - * Net withdraws + Net spot market value + Net unrealized P&L - - */ - getTotalAllTimePnl(): BN { - const netUsdValue = this.getNetUsdValue(); - const totalDeposits = this.getUserAccount().totalDeposits; - const totalWithdraws = this.getUserAccount().totalWithdraws; - - const totalPnl = netUsdValue.add(totalWithdraws).sub(totalDeposits); - - return totalPnl; - } - - /** - * calculates max allowable leverage exceeding hitting requirement category - * for large sizes where imf factor activates, result is a lower bound - * @param marginCategory {Initial, Maintenance} - * @param isLp if calculating max leveraging for adding lp, need to add buffer - * @param enterHighLeverageMode can pass this as true to calculate max leverage if the user was to enter high leverage mode - * @returns : Precision TEN_THOUSAND - */ - public getMaxLeverageForPerp( - perpMarketIndex: number, - _marginCategory: MarginCategory = 'Initial', - isLp = false, - enterHighLeverageMode = undefined - ): BN { - const market = this.driftClient.getPerpMarketAccount(perpMarketIndex); - const marketPrice = - this.driftClient.getOracleDataForPerpMarket(perpMarketIndex).price; - - const { perpLiabilityValue, perpPnl, spotAssetValue, spotLiabilityValue } = - this.getLeverageComponents(); - - const totalAssetValue = spotAssetValue.add(perpPnl); - - const netAssetValue = totalAssetValue.sub(spotLiabilityValue); - - if (netAssetValue.eq(ZERO)) { - return ZERO; - } - - const totalLiabilityValue = perpLiabilityValue.add(spotLiabilityValue); - - const lpBuffer = isLp - ? marketPrice.mul(market.amm.orderStepSize).div(AMM_RESERVE_PRECISION) - : ZERO; - - // absolute max fesible size (upper bound) - const maxSizeQuote = BN.max( - BN.min( - this.getMaxTradeSizeUSDCForPerp( - perpMarketIndex, - PositionDirection.LONG, - false, - enterHighLeverageMode || this.isHighLeverageMode('Initial') - ).tradeSize, - this.getMaxTradeSizeUSDCForPerp( - perpMarketIndex, - PositionDirection.SHORT, - false, - enterHighLeverageMode || this.isHighLeverageMode('Initial') - ).tradeSize - ).sub(lpBuffer), - ZERO - ); - - return totalLiabilityValue - .add(maxSizeQuote) - .mul(TEN_THOUSAND) - .div(netAssetValue); - } - - /** - * calculates max allowable leverage exceeding hitting requirement category - * @param spotMarketIndex - * @param direction - * @returns : Precision TEN_THOUSAND - */ - public getMaxLeverageForSpot( - spotMarketIndex: number, - direction: PositionDirection - ): BN { - const { perpLiabilityValue, perpPnl, spotAssetValue, spotLiabilityValue } = - this.getLeverageComponents(); - - const totalLiabilityValue = perpLiabilityValue.add(spotLiabilityValue); - const totalAssetValue = spotAssetValue.add(perpPnl); - - const netAssetValue = totalAssetValue.sub(spotLiabilityValue); - - if (netAssetValue.eq(ZERO)) { - return ZERO; - } - - const currentQuoteAssetValue = this.getSpotMarketAssetValue( - QUOTE_SPOT_MARKET_INDEX - ); - const currentQuoteLiabilityValue = this.getSpotMarketLiabilityValue( - QUOTE_SPOT_MARKET_INDEX - ); - const currentQuoteValue = currentQuoteAssetValue.sub( - currentQuoteLiabilityValue - ); - - const currentSpotMarketAssetValue = - this.getSpotMarketAssetValue(spotMarketIndex); - const currentSpotMarketLiabilityValue = - this.getSpotMarketLiabilityValue(spotMarketIndex); - const currentSpotMarketNetValue = currentSpotMarketAssetValue.sub( - currentSpotMarketLiabilityValue - ); - - const tradeQuoteAmount = this.getMaxTradeSizeUSDCForSpot( - spotMarketIndex, - direction, - currentQuoteAssetValue, - currentSpotMarketNetValue - ); - - let assetValueToAdd = ZERO; - let liabilityValueToAdd = ZERO; - - const newQuoteNetValue = isVariant(direction, 'short') - ? currentQuoteValue.add(tradeQuoteAmount) - : currentQuoteValue.sub(tradeQuoteAmount); - const newQuoteAssetValue = BN.max(newQuoteNetValue, ZERO); - const newQuoteLiabilityValue = BN.min(newQuoteNetValue, ZERO).abs(); - - assetValueToAdd = assetValueToAdd.add( - newQuoteAssetValue.sub(currentQuoteAssetValue) - ); - liabilityValueToAdd = liabilityValueToAdd.add( - newQuoteLiabilityValue.sub(currentQuoteLiabilityValue) - ); - - const newSpotMarketNetValue = isVariant(direction, 'long') - ? currentSpotMarketNetValue.add(tradeQuoteAmount) - : currentSpotMarketNetValue.sub(tradeQuoteAmount); - const newSpotMarketAssetValue = BN.max(newSpotMarketNetValue, ZERO); - const newSpotMarketLiabilityValue = BN.min( - newSpotMarketNetValue, - ZERO - ).abs(); - - assetValueToAdd = assetValueToAdd.add( - newSpotMarketAssetValue.sub(currentSpotMarketAssetValue) - ); - liabilityValueToAdd = liabilityValueToAdd.add( - newSpotMarketLiabilityValue.sub(currentSpotMarketLiabilityValue) - ); - - const finalTotalAssetValue = totalAssetValue.add(assetValueToAdd); - const finalTotalSpotLiability = spotLiabilityValue.add(liabilityValueToAdd); - - const finalTotalLiabilityValue = - totalLiabilityValue.add(liabilityValueToAdd); - - const finalNetAssetValue = finalTotalAssetValue.sub( - finalTotalSpotLiability - ); - - return finalTotalLiabilityValue.mul(TEN_THOUSAND).div(finalNetAssetValue); - } - - /** - * calculates margin ratio: 1 / leverage - * @returns : Precision TEN_THOUSAND - */ - public getMarginRatio(): BN { - const { perpLiabilityValue, perpPnl, spotAssetValue, spotLiabilityValue } = - this.getLeverageComponents(); - - const totalLiabilityValue = perpLiabilityValue.add(spotLiabilityValue); - const totalAssetValue = spotAssetValue.add(perpPnl); - - if (totalLiabilityValue.eq(ZERO)) { - return BN_MAX; - } - - const netAssetValue = totalAssetValue.sub(spotLiabilityValue); - - return netAssetValue.mul(TEN_THOUSAND).div(totalLiabilityValue); - } - - public canBeLiquidated(): { - canBeLiquidated: boolean; - marginRequirement: BN; - totalCollateral: BN; - } { - const liquidationBuffer = this.getLiquidationBuffer(); - - const totalCollateral = this.getTotalCollateral( - 'Maintenance', - undefined, - undefined, - liquidationBuffer - ); - - const marginRequirement = - this.getMaintenanceMarginRequirement(liquidationBuffer); - const canBeLiquidated = totalCollateral.lt(marginRequirement); - - return { - canBeLiquidated, - marginRequirement, - totalCollateral, - }; - } - - public isBeingLiquidated(): boolean { - return ( - (this.getUserAccount().status & - (UserStatus.BEING_LIQUIDATED | UserStatus.BANKRUPT)) > - 0 - ); - } - - public hasStatus(status: UserStatus): boolean { - return (this.getUserAccount().status & status) > 0; - } - - public isBankrupt(): boolean { - return (this.getUserAccount().status & UserStatus.BANKRUPT) > 0; - } - - public isHighLeverageMode(marginCategory: MarginCategory): boolean { - return ( - isVariant(this.getUserAccount().marginMode, 'highLeverage') || - (marginCategory === 'Maintenance' && - isVariant(this.getUserAccount().marginMode, 'highLeverageMaintenance')) - ); - } - - /** - * Checks if any user position cumulative funding differs from respective market cumulative funding - * @returns - */ - public needsToSettleFundingPayment(): boolean { - for (const userPosition of this.getUserAccount().perpPositions) { - if (userPosition.baseAssetAmount.eq(ZERO)) { - continue; - } - - const market = this.driftClient.getPerpMarketAccount( - userPosition.marketIndex - ); - if ( - market.amm.cumulativeFundingRateLong.eq( - userPosition.lastCumulativeFundingRate - ) || - market.amm.cumulativeFundingRateShort.eq( - userPosition.lastCumulativeFundingRate - ) - ) { - continue; - } - - return true; - } - return false; - } - - /** - * Calculate the liquidation price of a spot position - * @param marketIndex - * @returns Precision : PRICE_PRECISION - */ - public spotLiquidationPrice( - marketIndex: number, - positionBaseSizeChange: BN = ZERO - ): BN { - const currentSpotPosition = this.getSpotPosition(marketIndex); - - if (!currentSpotPosition) { - return new BN(-1); - } - - const totalCollateral = this.getTotalCollateral('Maintenance'); - const maintenanceMarginRequirement = this.getMaintenanceMarginRequirement(); - const freeCollateral = BN.max( - ZERO, - totalCollateral.sub(maintenanceMarginRequirement) - ); - - const market = this.driftClient.getSpotMarketAccount(marketIndex); - let signedTokenAmount = getSignedTokenAmount( - getTokenAmount( - currentSpotPosition.scaledBalance, - market, - currentSpotPosition.balanceType - ), - currentSpotPosition.balanceType - ); - signedTokenAmount = signedTokenAmount.add(positionBaseSizeChange); - - if (signedTokenAmount.eq(ZERO)) { - return new BN(-1); - } - - let freeCollateralDelta = this.calculateFreeCollateralDeltaForSpot( - market, - signedTokenAmount - ); - - const oracle = market.oracle; - const perpMarketWithSameOracle = this.driftClient - .getPerpMarketAccounts() - .find((market) => market.amm.oracle.equals(oracle)); - const oraclePrice = - this.driftClient.getOracleDataForSpotMarket(marketIndex).price; - if (perpMarketWithSameOracle) { - const perpPosition = this.getPerpPositionOrEmpty( - perpMarketWithSameOracle.marketIndex - ); - if (perpPosition) { - let freeCollateralDeltaForPerp = - this.calculateFreeCollateralDeltaForPerp( - perpMarketWithSameOracle, - perpPosition, - ZERO, - oraclePrice - ); - - if (freeCollateralDeltaForPerp) { - const { numerator, denominator } = getMultipleBetweenOracleSources( - market.oracleSource, - perpMarketWithSameOracle.amm.oracleSource - ); - freeCollateralDeltaForPerp = freeCollateralDeltaForPerp - .mul(numerator) - .div(denominator); - } - - freeCollateralDelta = freeCollateralDelta.add( - freeCollateralDeltaForPerp || ZERO - ); - } - } - - if (freeCollateralDelta.eq(ZERO)) { - return new BN(-1); - } - - const liqPriceDelta = freeCollateral - .mul(QUOTE_PRECISION) - .div(freeCollateralDelta); - - const liqPrice = oraclePrice.sub(liqPriceDelta); - - if (liqPrice.lt(ZERO)) { - return new BN(-1); - } - - return liqPrice; - } - - /** - * Calculate the liquidation price of a perp position, with optional parameter to calculate the liquidation price after a trade - * @param marketIndex - * @param positionBaseSizeChange // change in position size to calculate liquidation price for : Precision 10^9 - * @param estimatedEntryPrice - * @param marginCategory // allow Initial to be passed in if we are trying to calculate price for DLP de-risking - * @param includeOpenOrders - * @param offsetCollateral // allows calculating the liquidation price after this offset collateral is added to the user's account (e.g. : what will the liquidation price be for this position AFTER I deposit $x worth of collateral) - * @returns Precision : PRICE_PRECISION - */ - public liquidationPrice( - marketIndex: number, - positionBaseSizeChange: BN = ZERO, - estimatedEntryPrice: BN = ZERO, - marginCategory: MarginCategory = 'Maintenance', - includeOpenOrders = false, - offsetCollateral = ZERO, - enteringHighLeverage = undefined - ): BN { - const totalCollateral = this.getTotalCollateral( - marginCategory, - false, - includeOpenOrders - ); - // console.log( - // 'line 2219 old user liq price totalCollateral', - // totalCollateral.toString() - // ); - const marginRequirement = this.getMarginRequirement( - marginCategory, - undefined, - false, - includeOpenOrders, - enteringHighLeverage - ); - - // console.log( - // ' line2150 old user liq price marginRequirement', - // marginRequirement.toString() - // ); - let freeCollateral = BN.max( - ZERO, - totalCollateral.sub(marginRequirement) - ).add(offsetCollateral); - - const oracle = - this.driftClient.getPerpMarketAccount(marketIndex).amm.oracle; - - const oraclePrice = - this.driftClient.getOracleDataForPerpMarket(marketIndex).price; - - const market = this.driftClient.getPerpMarketAccount(marketIndex); - const currentPerpPosition = this.getPerpPositionOrEmpty(marketIndex); - - positionBaseSizeChange = standardizeBaseAssetAmount( - positionBaseSizeChange, - market.amm.orderStepSize - ); - - const freeCollateralChangeFromNewPosition = - this.calculateEntriesEffectOnFreeCollateral( - market, - oraclePrice, - currentPerpPosition, - positionBaseSizeChange, - estimatedEntryPrice, - includeOpenOrders, - enteringHighLeverage - ); - - freeCollateral = freeCollateral.add(freeCollateralChangeFromNewPosition); - - // console.log( - // ' line2266 old user freeCollateral right after calculateEntriesEffectOnFreeCollateral', - // freeCollateral.toString() - // ); - - let freeCollateralDelta = this.calculateFreeCollateralDeltaForPerp( - market, - currentPerpPosition, - positionBaseSizeChange, - oraclePrice, - marginCategory, - includeOpenOrders, - enteringHighLeverage - ); - - // console.log( - // ' line2195 old user freeCollateralDelta', - // freeCollateralDelta?.toString() - // ); - - if (!freeCollateralDelta) { - return new BN(-1); - } - - const spotMarketWithSameOracle = this.driftClient - .getSpotMarketAccounts() - .find((market) => market.oracle.equals(oracle)); - if (spotMarketWithSameOracle) { - const spotPosition = this.getSpotPosition( - spotMarketWithSameOracle.marketIndex - ); - if (spotPosition) { - const signedTokenAmount = getSignedTokenAmount( - getTokenAmount( - spotPosition.scaledBalance, - spotMarketWithSameOracle, - spotPosition.balanceType - ), - spotPosition.balanceType - ); - - let spotFreeCollateralDelta = this.calculateFreeCollateralDeltaForSpot( - spotMarketWithSameOracle, - signedTokenAmount, - marginCategory - ); - - if (spotFreeCollateralDelta) { - const { numerator, denominator } = getMultipleBetweenOracleSources( - market.amm.oracleSource, - spotMarketWithSameOracle.oracleSource - ); - spotFreeCollateralDelta = spotFreeCollateralDelta - .mul(numerator) - .div(denominator); - } - - freeCollateralDelta = freeCollateralDelta.add( - spotFreeCollateralDelta || ZERO - ); - } - } - - // console.log( - // 'line 2324 old user freeCollateralDelta', - // freeCollateralDelta.toString() - // ); - - if (freeCollateralDelta.eq(ZERO)) { - return new BN(-1); - } - - const liqPriceDelta = freeCollateral - .mul(QUOTE_PRECISION) - .div(freeCollateralDelta); - - const liqPrice = oraclePrice.sub(liqPriceDelta); - - if (liqPrice.lt(ZERO)) { - return new BN(-1); - } - - return liqPrice; - } - - calculateEntriesEffectOnFreeCollateral( - market: PerpMarketAccount, - oraclePrice: BN, - perpPosition: PerpPosition, - positionBaseSizeChange: BN, - estimatedEntryPrice: BN, - includeOpenOrders: boolean, - enteringHighLeverage = undefined, - marginCategory: MarginCategory = 'Maintenance' - ): BN { - let freeCollateralChange = ZERO; - - // update free collateral to account for change in pnl from new position - if ( - !estimatedEntryPrice.eq(ZERO) && - !positionBaseSizeChange.eq(ZERO) && - marginCategory === 'Maintenance' - ) { - const costBasis = oraclePrice - .mul(positionBaseSizeChange.abs()) - .div(BASE_PRECISION); - const newPositionValue = estimatedEntryPrice - .mul(positionBaseSizeChange.abs()) - .div(BASE_PRECISION); - if (positionBaseSizeChange.gt(ZERO)) { - freeCollateralChange = costBasis.sub(newPositionValue); - } else { - freeCollateralChange = newPositionValue.sub(costBasis); - } - - // assume worst fee tier - const takerFeeTier = - this.driftClient.getStateAccount().perpFeeStructure.feeTiers[0]; - const takerFee = newPositionValue - .muln(takerFeeTier.feeNumerator) - .divn(takerFeeTier.feeDenominator); - freeCollateralChange = freeCollateralChange.sub(takerFee); - } - - const calculateMarginRequirement = (perpPosition: PerpPosition) => { - let baseAssetAmount: BN; - let liabilityValue: BN; - if (includeOpenOrders) { - const { worstCaseBaseAssetAmount, worstCaseLiabilityValue } = - calculateWorstCasePerpLiabilityValue( - perpPosition, - market, - oraclePrice - ); - baseAssetAmount = worstCaseBaseAssetAmount; - liabilityValue = worstCaseLiabilityValue; - } else { - baseAssetAmount = perpPosition.baseAssetAmount; - liabilityValue = calculatePerpLiabilityValue( - baseAssetAmount, - oraclePrice, - isVariant(market.contractType, 'prediction') - ); - } - - const userCustomMargin = Math.max( - perpPosition.maxMarginRatio, - this.getUserAccount().maxMarginRatio - ); - const marginRatio = calculateMarketMarginRatio( - market, - baseAssetAmount.abs(), - marginCategory, - enteringHighLeverage === false - ? Math.max(market.marginRatioInitial, userCustomMargin) - : userCustomMargin, - this.isHighLeverageMode(marginCategory) || enteringHighLeverage === true - ); - - return liabilityValue.mul(new BN(marginRatio)).div(MARGIN_PRECISION); - }; - - const freeCollateralConsumptionBefore = - calculateMarginRequirement(perpPosition); - - const perpPositionAfter = Object.assign({}, perpPosition); - perpPositionAfter.baseAssetAmount = perpPositionAfter.baseAssetAmount.add( - positionBaseSizeChange - ); - - const freeCollateralConsumptionAfter = - calculateMarginRequirement(perpPositionAfter); - - return freeCollateralChange.sub( - freeCollateralConsumptionAfter.sub(freeCollateralConsumptionBefore) - ); - } - - calculateFreeCollateralDeltaForPerp( - market: PerpMarketAccount, - perpPosition: PerpPosition, - positionBaseSizeChange: BN, - oraclePrice: BN, - marginCategory: MarginCategory = 'Maintenance', - includeOpenOrders = false, - enteringHighLeverage = undefined - ): BN | undefined { - const baseAssetAmount = includeOpenOrders - ? calculateWorstCaseBaseAssetAmount(perpPosition, market, oraclePrice) - : perpPosition.baseAssetAmount; - - // zero if include orders == false - const orderBaseAssetAmount = baseAssetAmount.sub( - perpPosition.baseAssetAmount - ); - - const proposedBaseAssetAmount = baseAssetAmount.add(positionBaseSizeChange); - - const userCustomMargin = Math.max( - perpPosition.maxMarginRatio, - this.getUserAccount().maxMarginRatio - ); - - const marginRatio = calculateMarketMarginRatio( - market, - proposedBaseAssetAmount.abs(), - marginCategory, - enteringHighLeverage === false - ? Math.max(market.marginRatioInitial, userCustomMargin) - : userCustomMargin, - this.isHighLeverageMode(marginCategory) || enteringHighLeverage === true - ); - - const marginRatioQuotePrecision = new BN(marginRatio) - .mul(QUOTE_PRECISION) - .div(MARGIN_PRECISION); - - if (proposedBaseAssetAmount.eq(ZERO)) { - return undefined; - } - - let freeCollateralDelta = ZERO; - if (isVariant(market.contractType, 'prediction')) { - // for prediction market, increase in pnl and margin requirement will net out for position - // open order margin requirement will change with price though - if (orderBaseAssetAmount.gt(ZERO)) { - freeCollateralDelta = marginRatioQuotePrecision.neg(); - } else if (orderBaseAssetAmount.lt(ZERO)) { - freeCollateralDelta = marginRatioQuotePrecision; - } - } else { - if (proposedBaseAssetAmount.gt(ZERO)) { - freeCollateralDelta = QUOTE_PRECISION.sub(marginRatioQuotePrecision) - .mul(proposedBaseAssetAmount) - .div(BASE_PRECISION); - } else { - freeCollateralDelta = QUOTE_PRECISION.neg() - .sub(marginRatioQuotePrecision) - .mul(proposedBaseAssetAmount.abs()) - .div(BASE_PRECISION); - } - - if (!orderBaseAssetAmount.eq(ZERO)) { - freeCollateralDelta = freeCollateralDelta.sub( - marginRatioQuotePrecision - .mul(orderBaseAssetAmount.abs()) - .div(BASE_PRECISION) - ); - } - } - - return freeCollateralDelta; - } - - calculateFreeCollateralDeltaForSpot( - market: SpotMarketAccount, - signedTokenAmount: BN, - marginCategory: MarginCategory = 'Maintenance' - ): BN { - const tokenPrecision = new BN(Math.pow(10, market.decimals)); - - if (signedTokenAmount.gt(ZERO)) { - const assetWeight = calculateAssetWeight( - signedTokenAmount, - this.driftClient.getOracleDataForSpotMarket(market.marketIndex).price, - market, - marginCategory - ); - - return QUOTE_PRECISION.mul(assetWeight) - .div(SPOT_MARKET_WEIGHT_PRECISION) - .mul(signedTokenAmount) - .div(tokenPrecision); - } else { - const liabilityWeight = calculateLiabilityWeight( - signedTokenAmount.abs(), - market, - marginCategory - ); - - return QUOTE_PRECISION.neg() - .mul(liabilityWeight) - .div(SPOT_MARKET_WEIGHT_PRECISION) - .mul(signedTokenAmount.abs()) - .div(tokenPrecision); - } - } - - /** - * Calculates the estimated liquidation price for a position after closing a quote amount of the position. - * @param positionMarketIndex - * @param closeQuoteAmount - * @returns : Precision PRICE_PRECISION - */ - public liquidationPriceAfterClose( - positionMarketIndex: number, - closeQuoteAmount: BN, - estimatedEntryPrice: BN = ZERO - ): BN { - const currentPosition = this.getPerpPositionOrEmpty(positionMarketIndex); - - const closeBaseAmount = currentPosition.baseAssetAmount - .mul(closeQuoteAmount) - .div(currentPosition.quoteAssetAmount.abs()) - .add( - currentPosition.baseAssetAmount - .mul(closeQuoteAmount) - .mod(currentPosition.quoteAssetAmount.abs()) - ) - .neg(); - - return this.liquidationPrice( - positionMarketIndex, - closeBaseAmount, - estimatedEntryPrice - ); - } - - public getMarginUSDCRequiredForTrade( - targetMarketIndex: number, - baseSize: BN, - estEntryPrice?: BN, - perpMarketMaxMarginRatio?: number - ): BN { - const maxMarginRatio = Math.max( - perpMarketMaxMarginRatio, - this.getUserAccount().maxMarginRatio - ); - return calculateMarginUSDCRequiredForTrade( - this.driftClient, - targetMarketIndex, - baseSize, - maxMarginRatio, - undefined, - estEntryPrice - ); - } - - public getCollateralDepositRequiredForTrade( - targetMarketIndex: number, - baseSize: BN, - collateralIndex: number, - perpMarketMaxMarginRatio?: number - ): BN { - const maxMarginRatio = Math.max( - perpMarketMaxMarginRatio, - this.getUserAccount().maxMarginRatio - ); - return calculateCollateralDepositRequiredForTrade( - this.driftClient, - targetMarketIndex, - baseSize, - collateralIndex, - maxMarginRatio, - false // assume user cant be high leverage if they havent created user account ? - ); - } - - /** - * Separates the max trade size into two parts: - * - tradeSize: The maximum trade size for target direction - * - oppositeSideTradeSize: the trade size for closing the opposite direction - * @param targetMarketIndex - * @param tradeSide - * @param isLp - * @returns { tradeSize: BN, oppositeSideTradeSize: BN} : Precision QUOTE_PRECISION - */ - public getMaxTradeSizeUSDCForPerp( - targetMarketIndex: number, - tradeSide: PositionDirection, - isLp = false, - enterHighLeverageMode = undefined, - maxMarginRatio = undefined - ): { tradeSize: BN; oppositeSideTradeSize: BN } { - let tradeSize = ZERO; - let oppositeSideTradeSize = ZERO; - const currentPosition = this.getPerpPositionOrEmpty(targetMarketIndex); - - const targetSide = isVariant(tradeSide, 'short') ? 'short' : 'long'; - - const currentPositionSide = currentPosition?.baseAssetAmount.isNeg() - ? 'short' - : 'long'; - - const targetingSameSide = !currentPosition - ? true - : targetSide === currentPositionSide; - - const oracleData = this.getMMOracleDataForPerpMarket(targetMarketIndex); - - const marketAccount = - this.driftClient.getPerpMarketAccount(targetMarketIndex); - - const lpBuffer = isLp - ? oracleData.price - .mul(marketAccount.amm.orderStepSize) - .div(AMM_RESERVE_PRECISION) - : ZERO; - - // add any position we have on the opposite side of the current trade, because we can "flip" the size of this position without taking any extra leverage. - const oppositeSizeLiabilityValue = targetingSameSide - ? ZERO - : calculatePerpLiabilityValue( - currentPosition.baseAssetAmount, - oracleData.price, - isVariant(marketAccount.contractType, 'prediction') - ); - - const maxPositionSize = this.getPerpBuyingPower( - targetMarketIndex, - lpBuffer, - enterHighLeverageMode, - maxMarginRatio - ); - - if (maxPositionSize.gte(ZERO)) { - if (oppositeSizeLiabilityValue.eq(ZERO)) { - // case 1 : Regular trade where current total position less than max, and no opposite position to account for - // do nothing - tradeSize = maxPositionSize; - } else { - // case 2 : trade where current total position less than max, but need to account for flipping the current position over to the other side - tradeSize = maxPositionSize.add(oppositeSizeLiabilityValue); - oppositeSideTradeSize = oppositeSizeLiabilityValue; - } - } else { - // current leverage is greater than max leverage - can only reduce position size - - if (!targetingSameSide) { - const market = this.driftClient.getPerpMarketAccount(targetMarketIndex); - const perpLiabilityValue = calculatePerpLiabilityValue( - currentPosition.baseAssetAmount, - oracleData.price, - isVariant(market.contractType, 'prediction') - ); - const totalCollateral = this.getTotalCollateral(); - const marginRequirement = this.getInitialMarginRequirement( - enterHighLeverageMode - ); - const marginRatio = Math.max( - currentPosition.maxMarginRatio, - this.getUserAccount().maxMarginRatio - ); - const marginFreedByClosing = perpLiabilityValue - .mul(new BN(marginRatio)) - .div(MARGIN_PRECISION); - const marginRequirementAfterClosing = - marginRequirement.sub(marginFreedByClosing); - - if (marginRequirementAfterClosing.gt(totalCollateral)) { - oppositeSideTradeSize = perpLiabilityValue; - } else { - const freeCollateralAfterClose = totalCollateral.sub( - marginRequirementAfterClosing - ); - - const buyingPowerAfterClose = - this.getPerpBuyingPowerFromFreeCollateralAndBaseAssetAmount( - targetMarketIndex, - freeCollateralAfterClose, - ZERO, - currentPosition.maxMarginRatio - ); - oppositeSideTradeSize = perpLiabilityValue; - tradeSize = buyingPowerAfterClose; - } - } else { - // do nothing if targetting same side - tradeSize = maxPositionSize; - } - } - - const freeCollateral = this.getFreeCollateral( - 'Initial', - enterHighLeverageMode - ); - - let baseTradeSize = - targetSide === 'long' - ? tradeSize.mul(BASE_PRECISION).div(oracleData.price) - : tradeSize.mul(BASE_PRECISION).div(oracleData.price).neg(); - - let freeCollateralChangeFromNewPosition = - this.calculateEntriesEffectOnFreeCollateral( - marketAccount, - oracleData.price, - currentPosition, - baseTradeSize, - oracleData.price, - false, - enterHighLeverageMode, - 'Initial' - ); - - while ( - freeCollateralChangeFromNewPosition.isNeg() && - freeCollateralChangeFromNewPosition.abs().gt(freeCollateral) - ) { - tradeSize = tradeSize.mul(new BN(99)).div(new BN(100)); - baseTradeSize = - targetSide === 'long' - ? tradeSize.mul(BASE_PRECISION).div(oracleData.price) - : tradeSize.mul(BASE_PRECISION).div(oracleData.price).neg(); - freeCollateralChangeFromNewPosition = - this.calculateEntriesEffectOnFreeCollateral( - marketAccount, - oracleData.price, - currentPosition, - baseTradeSize, - oracleData.price, - false, - enterHighLeverageMode, - 'Initial' - ); - } - - return { tradeSize, oppositeSideTradeSize }; - } - - /** - * Get the maximum trade size for a given market, taking into account the user's current leverage, positions, collateral, etc. - * - * @param targetMarketIndex - * @param direction - * @param currentQuoteAssetValue - * @param currentSpotMarketNetValue - * @returns tradeSizeAllowed : Precision QUOTE_PRECISION - */ - public getMaxTradeSizeUSDCForSpot( - targetMarketIndex: number, - direction: PositionDirection, - currentQuoteAssetValue?: BN, - currentSpotMarketNetValue?: BN - ): BN { - const market = this.driftClient.getSpotMarketAccount(targetMarketIndex); - const oraclePrice = - this.driftClient.getOracleDataForSpotMarket(targetMarketIndex).price; - - currentQuoteAssetValue = this.getSpotMarketAssetValue( - QUOTE_SPOT_MARKET_INDEX - ); - - currentSpotMarketNetValue = - currentSpotMarketNetValue ?? this.getSpotPositionValue(targetMarketIndex); - - let freeCollateral = this.getFreeCollateral(); - const marginRatio = calculateSpotMarketMarginRatio( - market, - oraclePrice, - 'Initial', - ZERO, - isVariant(direction, 'long') - ? SpotBalanceType.DEPOSIT - : SpotBalanceType.BORROW, - this.getUserAccount().maxMarginRatio - ); - - let tradeAmount = ZERO; - if (this.getUserAccount().isMarginTradingEnabled) { - // if the user is buying/selling and already short/long, need to account for closing out short/long - if (isVariant(direction, 'long') && currentSpotMarketNetValue.lt(ZERO)) { - tradeAmount = currentSpotMarketNetValue.abs(); - const marginRatio = calculateSpotMarketMarginRatio( - market, - oraclePrice, - 'Initial', - this.getTokenAmount(targetMarketIndex).abs(), - SpotBalanceType.BORROW, - this.getUserAccount().maxMarginRatio - ); - freeCollateral = freeCollateral.add( - tradeAmount.mul(new BN(marginRatio)).div(MARGIN_PRECISION) - ); - } else if ( - isVariant(direction, 'short') && - currentSpotMarketNetValue.gt(ZERO) - ) { - tradeAmount = currentSpotMarketNetValue; - const marginRatio = calculateSpotMarketMarginRatio( - market, - oraclePrice, - 'Initial', - this.getTokenAmount(targetMarketIndex), - SpotBalanceType.DEPOSIT, - this.getUserAccount().maxMarginRatio - ); - freeCollateral = freeCollateral.add( - tradeAmount.mul(new BN(marginRatio)).div(MARGIN_PRECISION) - ); - } - - tradeAmount = tradeAmount.add( - freeCollateral.mul(MARGIN_PRECISION).div(new BN(marginRatio)) - ); - } else if (isVariant(direction, 'long')) { - tradeAmount = BN.min( - currentQuoteAssetValue, - freeCollateral.mul(MARGIN_PRECISION).div(new BN(marginRatio)) - ); - } else { - tradeAmount = BN.max(ZERO, currentSpotMarketNetValue); - } - - return tradeAmount; - } - - /** - * Calculates the max amount of token that can be swapped from inMarket to outMarket - * Assumes swap happens at oracle price - * - * @param inMarketIndex - * @param outMarketIndex - * @param calculateSwap function to similate in to out swa - * @param iterationLimit how long to run appromixation before erroring out - */ - public getMaxSwapAmount({ - inMarketIndex, - outMarketIndex, - calculateSwap, - iterationLimit = 1000, - }: { - inMarketIndex: number; - outMarketIndex: number; - calculateSwap?: (inAmount: BN) => BN; - iterationLimit?: number; - }): { inAmount: BN; outAmount: BN; leverage: BN } { - const inMarket = this.driftClient.getSpotMarketAccount(inMarketIndex); - const outMarket = this.driftClient.getSpotMarketAccount(outMarketIndex); - - const inOraclePriceData = this.getOracleDataForSpotMarket(inMarketIndex); - const inOraclePrice = inOraclePriceData.price; - const outOraclePriceData = this.getOracleDataForSpotMarket(outMarketIndex); - const outOraclePrice = outOraclePriceData.price; - - const inStrictOraclePrice = new StrictOraclePrice(inOraclePrice); - const outStrictOraclePrice = new StrictOraclePrice(outOraclePrice); - - const inPrecision = new BN(10 ** inMarket.decimals); - const outPrecision = new BN(10 ** outMarket.decimals); - - const inSpotPosition = - this.getSpotPosition(inMarketIndex) || - this.getEmptySpotPosition(inMarketIndex); - const outSpotPosition = - this.getSpotPosition(outMarketIndex) || - this.getEmptySpotPosition(outMarketIndex); - - const freeCollateral = this.getFreeCollateral(); - - const inContributionInitial = - this.calculateSpotPositionFreeCollateralContribution( - inSpotPosition, - inStrictOraclePrice - ); - const { - totalAssetValue: inTotalAssetValueInitial, - totalLiabilityValue: inTotalLiabilityValueInitial, - } = this.calculateSpotPositionLeverageContribution( - inSpotPosition, - inStrictOraclePrice - ); - const outContributionInitial = - this.calculateSpotPositionFreeCollateralContribution( - outSpotPosition, - outStrictOraclePrice - ); - const { - totalAssetValue: outTotalAssetValueInitial, - totalLiabilityValue: outTotalLiabilityValueInitial, - } = this.calculateSpotPositionLeverageContribution( - outSpotPosition, - outStrictOraclePrice - ); - const initialContribution = inContributionInitial.add( - outContributionInitial - ); - - const { perpLiabilityValue, perpPnl, spotAssetValue, spotLiabilityValue } = - this.getLeverageComponents(); - - if (!calculateSwap) { - calculateSwap = (inSwap: BN) => { - return inSwap - .mul(outPrecision) - .mul(inOraclePrice) - .div(outOraclePrice) - .div(inPrecision); - }; - } - - let inSwap = ZERO; - let outSwap = ZERO; - const inTokenAmount = this.getTokenAmount(inMarketIndex); - const outTokenAmount = this.getTokenAmount(outMarketIndex); - - const inAssetWeight = calculateAssetWeight( - inTokenAmount, - inOraclePriceData.price, - inMarket, - 'Initial' - ); - const outAssetWeight = calculateAssetWeight( - outTokenAmount, - outOraclePriceData.price, - outMarket, - 'Initial' - ); - - const outSaferThanIn = - // selling asset to close borrow - (inTokenAmount.gt(ZERO) && outTokenAmount.lt(ZERO)) || - // buying asset with higher initial asset weight - inAssetWeight.lte(outAssetWeight); - - if (freeCollateral.lt(PRICE_PRECISION.divn(100))) { - if (outSaferThanIn && inTokenAmount.gt(ZERO)) { - inSwap = inTokenAmount; - outSwap = calculateSwap(inSwap); - } - } else { - let minSwap = ZERO; - let maxSwap = BN.max( - freeCollateral.mul(inPrecision).mul(new BN(100)).div(inOraclePrice), // 100x current free collateral - inTokenAmount.abs().mul(new BN(10)) // 10x current position - ); - inSwap = maxSwap.div(TWO); - const error = freeCollateral.div(new BN(10000)); - - let i = 0; - let freeCollateralAfter = freeCollateral; - while (freeCollateralAfter.gt(error) || freeCollateralAfter.isNeg()) { - outSwap = calculateSwap(inSwap); - - const inPositionAfter = this.cloneAndUpdateSpotPosition( - inSpotPosition, - inSwap.neg(), - inMarket - ); - const outPositionAfter = this.cloneAndUpdateSpotPosition( - outSpotPosition, - outSwap, - outMarket - ); - - const inContributionAfter = - this.calculateSpotPositionFreeCollateralContribution( - inPositionAfter, - inStrictOraclePrice - ); - const outContributionAfter = - this.calculateSpotPositionFreeCollateralContribution( - outPositionAfter, - outStrictOraclePrice - ); - - const contributionAfter = inContributionAfter.add(outContributionAfter); - - const contributionDelta = contributionAfter.sub(initialContribution); - - freeCollateralAfter = freeCollateral.add(contributionDelta); - - if (freeCollateralAfter.gt(error)) { - minSwap = inSwap; - inSwap = minSwap.add(maxSwap).div(TWO); - } else if (freeCollateralAfter.isNeg()) { - maxSwap = inSwap; - inSwap = minSwap.add(maxSwap).div(TWO); - } - - if (i++ > iterationLimit) { - console.log('getMaxSwapAmount iteration limit reached'); - break; - } - } - } - - const inPositionAfter = this.cloneAndUpdateSpotPosition( - inSpotPosition, - inSwap.neg(), - inMarket - ); - const outPositionAfter = this.cloneAndUpdateSpotPosition( - outSpotPosition, - outSwap, - outMarket - ); - - const { - totalAssetValue: inTotalAssetValueAfter, - totalLiabilityValue: inTotalLiabilityValueAfter, - } = this.calculateSpotPositionLeverageContribution( - inPositionAfter, - inStrictOraclePrice - ); - - const { - totalAssetValue: outTotalAssetValueAfter, - totalLiabilityValue: outTotalLiabilityValueAfter, - } = this.calculateSpotPositionLeverageContribution( - outPositionAfter, - outStrictOraclePrice - ); - - const spotAssetValueDelta = inTotalAssetValueAfter - .add(outTotalAssetValueAfter) - .sub(inTotalAssetValueInitial) - .sub(outTotalAssetValueInitial); - const spotLiabilityValueDelta = inTotalLiabilityValueAfter - .add(outTotalLiabilityValueAfter) - .sub(inTotalLiabilityValueInitial) - .sub(outTotalLiabilityValueInitial); - - const spotAssetValueAfter = spotAssetValue.add(spotAssetValueDelta); - const spotLiabilityValueAfter = spotLiabilityValue.add( - spotLiabilityValueDelta - ); - - const leverage = this.calculateLeverageFromComponents({ - perpLiabilityValue, - perpPnl, - spotAssetValue: spotAssetValueAfter, - spotLiabilityValue: spotLiabilityValueAfter, - }); - - return { inAmount: inSwap, outAmount: outSwap, leverage }; - } - - public cloneAndUpdateSpotPosition( - position: SpotPosition, - tokenAmount: BN, - market: SpotMarketAccount - ): SpotPosition { - const clonedPosition = Object.assign({}, position); - if (tokenAmount.eq(ZERO)) { - return clonedPosition; - } - - const preTokenAmount = getSignedTokenAmount( - getTokenAmount(position.scaledBalance, market, position.balanceType), - position.balanceType - ); - - if (sigNum(preTokenAmount).eq(sigNum(tokenAmount))) { - const scaledBalanceDelta = getBalance( - tokenAmount.abs(), - market, - position.balanceType - ); - clonedPosition.scaledBalance = - clonedPosition.scaledBalance.add(scaledBalanceDelta); - return clonedPosition; - } - - const updateDirection = tokenAmount.isNeg() - ? SpotBalanceType.BORROW - : SpotBalanceType.DEPOSIT; - - if (tokenAmount.abs().gte(preTokenAmount.abs())) { - clonedPosition.scaledBalance = getBalance( - tokenAmount.abs().sub(preTokenAmount.abs()), - market, - updateDirection - ); - clonedPosition.balanceType = updateDirection; - } else { - const scaledBalanceDelta = getBalance( - tokenAmount.abs(), - market, - position.balanceType - ); - - clonedPosition.scaledBalance = - clonedPosition.scaledBalance.sub(scaledBalanceDelta); - } - return clonedPosition; - } - - calculateSpotPositionFreeCollateralContribution( - spotPosition: SpotPosition, - strictOraclePrice: StrictOraclePrice - ): BN { - const marginCategory = 'Initial'; - - const spotMarketAccount: SpotMarketAccount = - this.driftClient.getSpotMarketAccount(spotPosition.marketIndex); - - const { freeCollateralContribution } = getWorstCaseTokenAmounts( - spotPosition, - spotMarketAccount, - strictOraclePrice, - marginCategory, - this.getUserAccount().maxMarginRatio - ); - - return freeCollateralContribution; - } - - calculateSpotPositionLeverageContribution( - spotPosition: SpotPosition, - strictOraclePrice: StrictOraclePrice - ): { - totalAssetValue: BN; - totalLiabilityValue: BN; - } { - let totalAssetValue = ZERO; - let totalLiabilityValue = ZERO; - - const spotMarketAccount: SpotMarketAccount = - this.driftClient.getSpotMarketAccount(spotPosition.marketIndex); - - const { tokenValue, ordersValue } = getWorstCaseTokenAmounts( - spotPosition, - spotMarketAccount, - strictOraclePrice, - 'Initial', - this.getUserAccount().maxMarginRatio - ); - - if (tokenValue.gte(ZERO)) { - totalAssetValue = tokenValue; - } else { - totalLiabilityValue = tokenValue.abs(); - } - - if (ordersValue.gt(ZERO)) { - totalAssetValue = totalAssetValue.add(ordersValue); - } else { - totalLiabilityValue = totalLiabilityValue.add(ordersValue.abs()); - } - - return { - totalAssetValue, - totalLiabilityValue, - }; - } - - /** - * Estimates what the user leverage will be after swap - * @param inMarketIndex - * @param outMarketIndex - * @param inAmount - * @param outAmount - */ - public accountLeverageAfterSwap({ - inMarketIndex, - outMarketIndex, - inAmount, - outAmount, - }: { - inMarketIndex: number; - outMarketIndex: number; - inAmount: BN; - outAmount: BN; - }): BN { - const inMarket = this.driftClient.getSpotMarketAccount(inMarketIndex); - const outMarket = this.driftClient.getSpotMarketAccount(outMarketIndex); - - const inOraclePriceData = this.getOracleDataForSpotMarket(inMarketIndex); - const inOraclePrice = inOraclePriceData.price; - const outOraclePriceData = this.getOracleDataForSpotMarket(outMarketIndex); - const outOraclePrice = outOraclePriceData.price; - const inStrictOraclePrice = new StrictOraclePrice(inOraclePrice); - const outStrictOraclePrice = new StrictOraclePrice(outOraclePrice); - - const inSpotPosition = - this.getSpotPosition(inMarketIndex) || - this.getEmptySpotPosition(inMarketIndex); - const outSpotPosition = - this.getSpotPosition(outMarketIndex) || - this.getEmptySpotPosition(outMarketIndex); - - const { - totalAssetValue: inTotalAssetValueInitial, - totalLiabilityValue: inTotalLiabilityValueInitial, - } = this.calculateSpotPositionLeverageContribution( - inSpotPosition, - inStrictOraclePrice - ); - const { - totalAssetValue: outTotalAssetValueInitial, - totalLiabilityValue: outTotalLiabilityValueInitial, - } = this.calculateSpotPositionLeverageContribution( - outSpotPosition, - outStrictOraclePrice - ); - - const { perpLiabilityValue, perpPnl, spotAssetValue, spotLiabilityValue } = - this.getLeverageComponents(); - - const inPositionAfter = this.cloneAndUpdateSpotPosition( - inSpotPosition, - inAmount.abs().neg(), - inMarket - ); - const outPositionAfter = this.cloneAndUpdateSpotPosition( - outSpotPosition, - outAmount.abs(), - outMarket - ); - - const { - totalAssetValue: inTotalAssetValueAfter, - totalLiabilityValue: inTotalLiabilityValueAfter, - } = this.calculateSpotPositionLeverageContribution( - inPositionAfter, - inStrictOraclePrice - ); - - const { - totalAssetValue: outTotalAssetValueAfter, - totalLiabilityValue: outTotalLiabilityValueAfter, - } = this.calculateSpotPositionLeverageContribution( - outPositionAfter, - outStrictOraclePrice - ); - - const spotAssetValueDelta = inTotalAssetValueAfter - .add(outTotalAssetValueAfter) - .sub(inTotalAssetValueInitial) - .sub(outTotalAssetValueInitial); - const spotLiabilityValueDelta = inTotalLiabilityValueAfter - .add(outTotalLiabilityValueAfter) - .sub(inTotalLiabilityValueInitial) - .sub(outTotalLiabilityValueInitial); - - const spotAssetValueAfter = spotAssetValue.add(spotAssetValueDelta); - const spotLiabilityValueAfter = spotLiabilityValue.add( - spotLiabilityValueDelta - ); - - return this.calculateLeverageFromComponents({ - perpLiabilityValue, - perpPnl, - spotAssetValue: spotAssetValueAfter, - spotLiabilityValue: spotLiabilityValueAfter, - }); - } - - // TODO - should this take the price impact of the trade into account for strict accuracy? - - /** - * Returns the leverage ratio for the account after adding (or subtracting) the given quote size to the given position - * @param targetMarketIndex - * @param: targetMarketType - * @param tradeQuoteAmount - * @param tradeSide - * @param includeOpenOrders - * @returns leverageRatio : Precision TEN_THOUSAND - */ - public accountLeverageRatioAfterTrade( - targetMarketIndex: number, - targetMarketType: MarketType, - tradeQuoteAmount: BN, - tradeSide: PositionDirection, - includeOpenOrders = true - ): BN { - const tradeIsPerp = isVariant(targetMarketType, 'perp'); - - if (!tradeIsPerp) { - // calculate new asset/liability values for base and quote market to find new account leverage - const totalLiabilityValue = this.getTotalLiabilityValue(); - const totalAssetValue = this.getTotalAssetValue(); - const spotLiabilityValue = this.getSpotMarketLiabilityValue( - undefined, - undefined, - undefined, - includeOpenOrders - ); - - const currentQuoteAssetValue = this.getSpotMarketAssetValue( - QUOTE_SPOT_MARKET_INDEX, - undefined, - includeOpenOrders - ); - const currentQuoteLiabilityValue = this.getSpotMarketLiabilityValue( - QUOTE_SPOT_MARKET_INDEX, - undefined, - undefined, - includeOpenOrders - ); - const currentQuoteValue = currentQuoteAssetValue.sub( - currentQuoteLiabilityValue - ); - - const currentSpotMarketAssetValue = this.getSpotMarketAssetValue( - targetMarketIndex, - undefined, - includeOpenOrders - ); - const currentSpotMarketLiabilityValue = this.getSpotMarketLiabilityValue( - targetMarketIndex, - undefined, - undefined, - includeOpenOrders - ); - const currentSpotMarketNetValue = currentSpotMarketAssetValue.sub( - currentSpotMarketLiabilityValue - ); - - let assetValueToAdd = ZERO; - let liabilityValueToAdd = ZERO; - - const newQuoteNetValue = - tradeSide == PositionDirection.SHORT - ? currentQuoteValue.add(tradeQuoteAmount) - : currentQuoteValue.sub(tradeQuoteAmount); - const newQuoteAssetValue = BN.max(newQuoteNetValue, ZERO); - const newQuoteLiabilityValue = BN.min(newQuoteNetValue, ZERO).abs(); - - assetValueToAdd = assetValueToAdd.add( - newQuoteAssetValue.sub(currentQuoteAssetValue) - ); - liabilityValueToAdd = liabilityValueToAdd.add( - newQuoteLiabilityValue.sub(currentQuoteLiabilityValue) - ); - - const newSpotMarketNetValue = - tradeSide == PositionDirection.LONG - ? currentSpotMarketNetValue.add(tradeQuoteAmount) - : currentSpotMarketNetValue.sub(tradeQuoteAmount); - const newSpotMarketAssetValue = BN.max(newSpotMarketNetValue, ZERO); - const newSpotMarketLiabilityValue = BN.min( - newSpotMarketNetValue, - ZERO - ).abs(); - - assetValueToAdd = assetValueToAdd.add( - newSpotMarketAssetValue.sub(currentSpotMarketAssetValue) - ); - liabilityValueToAdd = liabilityValueToAdd.add( - newSpotMarketLiabilityValue.sub(currentSpotMarketLiabilityValue) - ); - - const totalAssetValueAfterTrade = totalAssetValue.add(assetValueToAdd); - const totalSpotLiabilityValueAfterTrade = - spotLiabilityValue.add(liabilityValueToAdd); - - const totalLiabilityValueAfterTrade = - totalLiabilityValue.add(liabilityValueToAdd); - - const netAssetValueAfterTrade = totalAssetValueAfterTrade.sub( - totalSpotLiabilityValueAfterTrade - ); - - if (netAssetValueAfterTrade.eq(ZERO)) { - return ZERO; - } - - const newLeverage = totalLiabilityValueAfterTrade - .mul(TEN_THOUSAND) - .div(netAssetValueAfterTrade); - - return newLeverage; - } - - const currentPosition = this.getPerpPositionOrEmpty(targetMarketIndex); - - const perpMarket = this.driftClient.getPerpMarketAccount(targetMarketIndex); - const oracleData = this.getOracleDataForPerpMarket(targetMarketIndex); - - let { - // eslint-disable-next-line prefer-const - worstCaseBaseAssetAmount: worstCaseBase, - worstCaseLiabilityValue: currentPositionQuoteAmount, - } = calculateWorstCasePerpLiabilityValue( - currentPosition, - perpMarket, - oracleData.price - ); - - // current side is short if position base asset amount is negative OR there is no position open but open orders are short - const currentSide = - currentPosition.baseAssetAmount.isNeg() || - (currentPosition.baseAssetAmount.eq(ZERO) && worstCaseBase.isNeg()) - ? PositionDirection.SHORT - : PositionDirection.LONG; - - if (currentSide === PositionDirection.SHORT) - currentPositionQuoteAmount = currentPositionQuoteAmount.neg(); - - if (tradeSide === PositionDirection.SHORT) - tradeQuoteAmount = tradeQuoteAmount.neg(); - - const currentPerpPositionAfterTrade = currentPositionQuoteAmount - .add(tradeQuoteAmount) - .abs(); - - const totalPositionAfterTradeExcludingTargetMarket = - this.getTotalPerpPositionValueExcludingMarket( - targetMarketIndex, - undefined, - undefined, - includeOpenOrders - ); - - const totalAssetValue = this.getTotalAssetValue(); - - const totalPerpPositionLiability = currentPerpPositionAfterTrade - .add(totalPositionAfterTradeExcludingTargetMarket) - .abs(); - - const totalSpotLiability = this.getSpotMarketLiabilityValue( - undefined, - undefined, - undefined, - includeOpenOrders - ); - - const totalLiabilitiesAfterTrade = - totalPerpPositionLiability.add(totalSpotLiability); - - const netAssetValue = totalAssetValue.sub(totalSpotLiability); - - if (netAssetValue.eq(ZERO)) { - return ZERO; - } - - const newLeverage = totalLiabilitiesAfterTrade - .mul(TEN_THOUSAND) - .div(netAssetValue); - - return newLeverage; - } - - public getUserFeeTier(marketType: MarketType, now?: BN) { - const state = this.driftClient.getStateAccount(); - - const feeTierIndex = 0; - if (isVariant(marketType, 'perp')) { - if (this.isHighLeverageMode('Initial')) { - return state.perpFeeStructure.feeTiers[0]; - } - - const userStatsAccount: UserStatsAccount = this.driftClient - .getUserStats() - .getAccount(); - - const total30dVolume = getUser30dRollingVolumeEstimate( - userStatsAccount, - now - ); - const stakedGovAssetAmount = userStatsAccount.ifStakedGovTokenAmount; - - const volumeThresholds = [ - new BN(2_000_000).mul(QUOTE_PRECISION), - new BN(10_000_000).mul(QUOTE_PRECISION), - new BN(20_000_000).mul(QUOTE_PRECISION), - new BN(80_000_000).mul(QUOTE_PRECISION), - new BN(200_000_000).mul(QUOTE_PRECISION), - ]; - const stakeThresholds = [ - new BN(1_000 - 1).mul(QUOTE_PRECISION), - new BN(10_000 - 1).mul(QUOTE_PRECISION), - new BN(50_000 - 1).mul(QUOTE_PRECISION), - new BN(100_000 - 1).mul(QUOTE_PRECISION), - new BN(250_000 - 5).mul(QUOTE_PRECISION), - ]; - const stakeBenefitFrac = [0, 5, 10, 20, 30, 40]; - - let feeTierIndex = 5; - for (let i = 0; i < volumeThresholds.length; i++) { - if (total30dVolume.lt(volumeThresholds[i])) { - feeTierIndex = i; - break; - } - } - - let stakeBenefitIndex = 5; - for (let i = 0; i < stakeThresholds.length; i++) { - if (stakedGovAssetAmount.lt(stakeThresholds[i])) { - stakeBenefitIndex = i; - break; - } - } - - const stakeBenefit = stakeBenefitFrac[stakeBenefitIndex]; - - const tier = { ...state.perpFeeStructure.feeTiers[feeTierIndex] }; - - if (stakeBenefit > 0) { - tier.feeNumerator = (tier.feeNumerator * (100 - stakeBenefit)) / 100; - - tier.makerRebateNumerator = - (tier.makerRebateNumerator * (100 + stakeBenefit)) / 100; - } - - return tier; - } - - return state.spotFeeStructure.feeTiers[feeTierIndex]; - } - - /** - * Calculates how much perp fee will be taken for a given sized trade - * @param quoteAmount - * @returns feeForQuote : Precision QUOTE_PRECISION - */ - public calculateFeeForQuoteAmount( - quoteAmount: BN, - marketIndex?: number, - enteringHighLeverageMode?: boolean - ): BN { - if (marketIndex !== undefined) { - const takerFeeMultiplier = this.driftClient.getMarketFees( - MarketType.PERP, - marketIndex, - this, - enteringHighLeverageMode - ).takerFee; - const feeAmountNum = - BigNum.from(quoteAmount, QUOTE_PRECISION_EXP).toNum() * - takerFeeMultiplier; - return BigNum.fromPrint(feeAmountNum.toString(), QUOTE_PRECISION_EXP).val; - } else { - const feeTier = this.getUserFeeTier(MarketType.PERP); - return quoteAmount - .mul(new BN(feeTier.feeNumerator)) - .div(new BN(feeTier.feeDenominator)); - } - } - - /** - * Calculates a user's max withdrawal amounts for a spot market. If reduceOnly is true, - * it will return the max withdrawal amount without opening a liability for the user - * @param marketIndex - * @returns withdrawalLimit : Precision is the token precision for the chosen SpotMarket - */ - public getWithdrawalLimit(marketIndex: number, reduceOnly?: boolean): BN { - const nowTs = new BN(Math.floor(Date.now() / 1000)); - const spotMarket = this.driftClient.getSpotMarketAccount(marketIndex); - - // eslint-disable-next-line prefer-const - let { borrowLimit, withdrawLimit } = calculateWithdrawLimit( - spotMarket, - nowTs - ); - - const freeCollateral = this.getFreeCollateral(); - const initialMarginRequirement = this.getInitialMarginRequirement(); - const oracleData = this.getOracleDataForSpotMarket(marketIndex); - const { numeratorScale, denominatorScale } = - spotMarket.decimals > 6 - ? { - numeratorScale: new BN(10).pow(new BN(spotMarket.decimals - 6)), - denominatorScale: new BN(1), - } - : { - numeratorScale: new BN(1), - denominatorScale: new BN(10).pow(new BN(6 - spotMarket.decimals)), - }; - - const { canBypass, depositAmount: userDepositAmount } = - this.canBypassWithdrawLimits(marketIndex); - if (canBypass) { - withdrawLimit = BN.max(withdrawLimit, userDepositAmount); - } - - const assetWeight = calculateAssetWeight( - userDepositAmount, - oracleData.price, - spotMarket, - 'Initial' - ); - - let amountWithdrawable; - if (assetWeight.eq(ZERO)) { - amountWithdrawable = userDepositAmount; - } else if (initialMarginRequirement.eq(ZERO)) { - amountWithdrawable = userDepositAmount; - } else { - amountWithdrawable = divCeil( - divCeil(freeCollateral.mul(MARGIN_PRECISION), assetWeight).mul( - PRICE_PRECISION - ), - oracleData.price - ) - .mul(numeratorScale) - .div(denominatorScale); - } - - const maxWithdrawValue = BN.min( - BN.min(amountWithdrawable, userDepositAmount), - withdrawLimit.abs() - ); - - if (reduceOnly) { - return BN.max(maxWithdrawValue, ZERO); - } else { - const weightedAssetValue = this.getSpotMarketAssetValue( - marketIndex, - 'Initial', - false - ); - - const freeCollatAfterWithdraw = userDepositAmount.gt(ZERO) - ? freeCollateral.sub(weightedAssetValue) - : freeCollateral; - - const maxLiabilityAllowed = freeCollatAfterWithdraw - .mul(MARGIN_PRECISION) - .div(new BN(spotMarket.initialLiabilityWeight)) - .mul(PRICE_PRECISION) - .div(oracleData.price) - .mul(numeratorScale) - .div(denominatorScale); - - const maxBorrowValue = BN.min( - maxWithdrawValue.add(maxLiabilityAllowed), - borrowLimit.abs() - ); - - return BN.max(maxBorrowValue, ZERO); - } - } - - public canBypassWithdrawLimits(marketIndex: number): { - canBypass: boolean; - netDeposits: BN; - depositAmount: BN; - maxDepositAmount: BN; - } { - const spotMarket = this.driftClient.getSpotMarketAccount(marketIndex); - const maxDepositAmount = spotMarket.withdrawGuardThreshold.div(new BN(10)); - const position = this.getSpotPosition(marketIndex); - - const netDeposits = this.getUserAccount().totalDeposits.sub( - this.getUserAccount().totalWithdraws - ); - - if (!position) { - return { - canBypass: false, - maxDepositAmount, - depositAmount: ZERO, - netDeposits, - }; - } - - if (isVariant(position.balanceType, 'borrow')) { - return { - canBypass: false, - maxDepositAmount, - netDeposits, - depositAmount: ZERO, - }; - } - - const depositAmount = getTokenAmount( - position.scaledBalance, - spotMarket, - SpotBalanceType.DEPOSIT - ); - - if (netDeposits.lt(ZERO)) { - return { - canBypass: false, - maxDepositAmount, - depositAmount, - netDeposits, - }; - } - - return { - canBypass: depositAmount.lt(maxDepositAmount), - maxDepositAmount, - netDeposits, - depositAmount, - }; - } - - public canMakeIdle(slot: BN): boolean { - const userAccount = this.getUserAccount(); - if (userAccount.idle) { - return false; - } - - const { totalAssetValue, totalLiabilityValue } = - this.getSpotMarketAssetAndLiabilityValue(); - const equity = totalAssetValue.sub(totalLiabilityValue); - - let slotsBeforeIdle: BN; - if (equity.lt(QUOTE_PRECISION.muln(1000))) { - slotsBeforeIdle = new BN(9000); // 1 hour - } else { - slotsBeforeIdle = new BN(1512000); // 1 week - } - - const userLastActiveSlot = userAccount.lastActiveSlot; - const slotsSinceLastActive = slot.sub(userLastActiveSlot); - if (slotsSinceLastActive.lt(slotsBeforeIdle)) { - return false; - } - - if (this.isBeingLiquidated()) { - return false; - } - - for (const perpPosition of userAccount.perpPositions) { - if (!positionIsAvailable(perpPosition)) { - return false; - } - } - - for (const spotPosition of userAccount.spotPositions) { - if ( - isVariant(spotPosition.balanceType, 'borrow') && - spotPosition.scaledBalance.gt(ZERO) - ) { - return false; - } - - if (spotPosition.openOrders !== 0) { - return false; - } - } - - for (const order of userAccount.orders) { - if (isVariant(order.status, 'open')) { - return false; - } - } - - return true; - } - - public getSafestTiers(): { perpTier: number; spotTier: number } { - let safestPerpTier = 4; - let safestSpotTier = 4; - - for (const perpPosition of this.getActivePerpPositions()) { - safestPerpTier = Math.min( - safestPerpTier, - getPerpMarketTierNumber( - this.driftClient.getPerpMarketAccount(perpPosition.marketIndex) - ) - ); - } - - for (const spotPosition of this.getActiveSpotPositions()) { - if (isVariant(spotPosition.balanceType, 'deposit')) { - continue; - } - - safestSpotTier = Math.min( - safestSpotTier, - getSpotMarketTierNumber( - this.driftClient.getSpotMarketAccount(spotPosition.marketIndex) - ) - ); - } - - return { - perpTier: safestPerpTier, - spotTier: safestSpotTier, - }; - } - - public getPerpPositionHealth({ - marginCategory, - perpPosition, - oraclePriceData, - quoteOraclePriceData, - includeOpenOrders = true, - }: { - marginCategory: MarginCategory; - perpPosition: PerpPosition; - oraclePriceData?: OraclePriceData; - quoteOraclePriceData?: OraclePriceData; - includeOpenOrders?: boolean; - }): HealthComponent { - const perpMarket = this.driftClient.getPerpMarketAccount( - perpPosition.marketIndex - ); - const _oraclePriceData = - oraclePriceData || - this.driftClient.getOracleDataForPerpMarket(perpMarket.marketIndex); - const oraclePrice = _oraclePriceData.price; - - let worstCaseBaseAmount; - let worstCaseLiabilityValue; - if (includeOpenOrders) { - const worstCaseIncludeOrders = calculateWorstCasePerpLiabilityValue( - perpPosition, - perpMarket, - oraclePrice - ); - worstCaseBaseAmount = worstCaseIncludeOrders.worstCaseBaseAssetAmount; - worstCaseLiabilityValue = worstCaseIncludeOrders.worstCaseLiabilityValue; - } else { - worstCaseBaseAmount = perpPosition.baseAssetAmount; - worstCaseLiabilityValue = calculatePerpLiabilityValue( - perpPosition.baseAssetAmount, - oraclePrice, - isVariant(perpMarket.contractType, 'prediction') - ); - } - - const userCustomMargin = Math.max( - perpPosition.maxMarginRatio, - this.getUserAccount().maxMarginRatio - ); - const marginRatio = new BN( - calculateMarketMarginRatio( - perpMarket, - worstCaseBaseAmount.abs(), - marginCategory, - userCustomMargin, - this.isHighLeverageMode(marginCategory) - ) - ); - - const _quoteOraclePriceData = - quoteOraclePriceData || - this.driftClient.getOracleDataForSpotMarket(QUOTE_SPOT_MARKET_INDEX); - - let marginRequirement = worstCaseLiabilityValue - .mul(_quoteOraclePriceData.price) - .div(PRICE_PRECISION) - .mul(marginRatio) - .div(MARGIN_PRECISION); - - marginRequirement = marginRequirement.add( - new BN(perpPosition.openOrders).mul(OPEN_ORDER_MARGIN_REQUIREMENT) - ); - - return { - marketIndex: perpMarket.marketIndex, - size: worstCaseBaseAmount, - value: worstCaseLiabilityValue, - weight: marginRatio, - weightedValue: marginRequirement, - }; - } - - public getHealthComponents({ - marginCategory, - }: { - marginCategory: MarginCategory; - }): HealthComponents { - const healthComponents: HealthComponents = { - deposits: [], - borrows: [], - perpPositions: [], - perpPnl: [], - }; - - for (const perpPosition of this.getActivePerpPositions()) { - const perpMarket = this.driftClient.getPerpMarketAccount( - perpPosition.marketIndex - ); - - const oraclePriceData = this.driftClient.getOracleDataForPerpMarket( - perpMarket.marketIndex - ); - - const quoteOraclePriceData = this.driftClient.getOracleDataForSpotMarket( - QUOTE_SPOT_MARKET_INDEX - ); - - healthComponents.perpPositions.push( - this.getPerpPositionHealth({ - marginCategory, - perpPosition, - oraclePriceData, - quoteOraclePriceData, - }) - ); - - const quoteSpotMarket = this.driftClient.getSpotMarketAccount( - perpMarket.quoteSpotMarketIndex - ); - - const positionUnrealizedPnl = calculatePositionPNL( - perpMarket, - perpPosition, - true, - oraclePriceData - ); - - let pnlWeight; - if (positionUnrealizedPnl.gt(ZERO)) { - pnlWeight = calculateUnrealizedAssetWeight( - perpMarket, - quoteSpotMarket, - positionUnrealizedPnl, - marginCategory, - oraclePriceData - ); - } else { - pnlWeight = SPOT_MARKET_WEIGHT_PRECISION; - } - - const pnlValue = positionUnrealizedPnl - .mul(quoteOraclePriceData.price) - .div(PRICE_PRECISION); - - const wegithedPnlValue = pnlValue - .mul(pnlWeight) - .div(SPOT_MARKET_WEIGHT_PRECISION); - - healthComponents.perpPnl.push({ - marketIndex: perpMarket.marketIndex, - size: positionUnrealizedPnl, - value: pnlValue, - weight: pnlWeight, - weightedValue: wegithedPnlValue, - }); - } - - let netQuoteValue = ZERO; - for (const spotPosition of this.getActiveSpotPositions()) { - const spotMarketAccount: SpotMarketAccount = - this.driftClient.getSpotMarketAccount(spotPosition.marketIndex); - - const oraclePriceData = this.getOracleDataForSpotMarket( - spotPosition.marketIndex - ); - - const strictOraclePrice = new StrictOraclePrice(oraclePriceData.price); - - if (spotPosition.marketIndex === QUOTE_SPOT_MARKET_INDEX) { - const tokenAmount = getSignedTokenAmount( - getTokenAmount( - spotPosition.scaledBalance, - spotMarketAccount, - spotPosition.balanceType - ), - spotPosition.balanceType - ); - - netQuoteValue = netQuoteValue.add(tokenAmount); - continue; - } - - const { - tokenAmount: worstCaseTokenAmount, - tokenValue: tokenValue, - weight, - weightedTokenValue: weightedTokenValue, - ordersValue: ordersValue, - } = getWorstCaseTokenAmounts( - spotPosition, - spotMarketAccount, - strictOraclePrice, - marginCategory, - this.getUserAccount().maxMarginRatio - ); - - netQuoteValue = netQuoteValue.add(ordersValue); - - const baseAssetValue = tokenValue.abs(); - const weightedValue = weightedTokenValue.abs(); - - if (weightedTokenValue.lt(ZERO)) { - healthComponents.borrows.push({ - marketIndex: spotMarketAccount.marketIndex, - size: worstCaseTokenAmount, - value: baseAssetValue, - weight: weight, - weightedValue: weightedValue, - }); - } else { - healthComponents.deposits.push({ - marketIndex: spotMarketAccount.marketIndex, - size: worstCaseTokenAmount, - value: baseAssetValue, - weight: weight, - weightedValue: weightedValue, - }); - } - } - - if (!netQuoteValue.eq(ZERO)) { - const spotMarketAccount = this.driftClient.getQuoteSpotMarketAccount(); - const oraclePriceData = this.getOracleDataForSpotMarket( - QUOTE_SPOT_MARKET_INDEX - ); - - const baseAssetValue = getTokenValue( - netQuoteValue, - spotMarketAccount.decimals, - oraclePriceData - ); - - const { weight, weightedTokenValue } = calculateWeightedTokenValue( - netQuoteValue, - baseAssetValue, - oraclePriceData.price, - spotMarketAccount, - marginCategory, - this.getUserAccount().maxMarginRatio - ); - - if (netQuoteValue.lt(ZERO)) { - healthComponents.borrows.push({ - marketIndex: spotMarketAccount.marketIndex, - size: netQuoteValue, - value: baseAssetValue.abs(), - weight: weight, - weightedValue: weightedTokenValue.abs(), - }); - } else { - healthComponents.deposits.push({ - marketIndex: spotMarketAccount.marketIndex, - size: netQuoteValue, - value: baseAssetValue, - weight: weight, - weightedValue: weightedTokenValue, - }); - } - } - - return healthComponents; - } - - /** - * Get the total position value, excluding any position coming from the given target market - * @param marketToIgnore - * @returns positionValue : Precision QUOTE_PRECISION - */ - private getTotalPerpPositionValueExcludingMarket( - marketToIgnore: number, - marginCategory?: MarginCategory, - liquidationBuffer?: BN, - includeOpenOrders?: boolean - ): BN { - const currentPerpPosition = this.getPerpPositionOrEmpty(marketToIgnore); - - const oracleData = this.getOracleDataForPerpMarket(marketToIgnore); - - let currentPerpPositionValueUSDC = ZERO; - if (currentPerpPosition) { - currentPerpPositionValueUSDC = this.getPerpLiabilityValue( - marketToIgnore, - oracleData, - includeOpenOrders - ); - } - - return this.getTotalPerpPositionLiability( - marginCategory, - liquidationBuffer, - includeOpenOrders - ).sub(currentPerpPositionValueUSDC); - } - - private getMMOracleDataForPerpMarket(marketIndex: number): MMOraclePriceData { - return this.driftClient.getMMOracleDataForPerpMarket(marketIndex); - } - - private getOracleDataForPerpMarket(marketIndex: number): OraclePriceData { - return this.driftClient.getOracleDataForPerpMarket(marketIndex); - } - - private getOracleDataForSpotMarket(marketIndex: number): OraclePriceData { - return this.driftClient.getOracleDataForSpotMarket(marketIndex); - } - - /** - * Get the active perp and spot positions of the user. - */ - public getActivePositions(): { - activePerpPositions: number[]; - activeSpotPositions: number[]; - } { - const activePerpMarkets = this.getActivePerpPositions().map( - (position) => position.marketIndex - ); - - const activeSpotMarkets = this.getActiveSpotPositions().map( - (position) => position.marketIndex - ); - - return { - activePerpPositions: activePerpMarkets, - activeSpotPositions: activeSpotMarkets, - }; - } -} From cd692653fd2fbf8d3715df1a9e40e87a7f24d24b Mon Sep 17 00:00:00 2001 From: Lukas deConantsesznak Date: Wed, 12 Nov 2025 16:01:39 -0500 Subject: [PATCH 13/13] fix: dont call get margin calc twice getFreeCollateral --- sdk/src/user.ts | 19 +++++++------------ 1 file changed, 7 insertions(+), 12 deletions(-) diff --git a/sdk/src/user.ts b/sdk/src/user.ts index a9c631ae00..043ac27a3c 100644 --- a/sdk/src/user.ts +++ b/sdk/src/user.ts @@ -537,7 +537,6 @@ export class User { /** * calculates Free Collateral = Total collateral - margin requirement - * TODO: can we not call getMarginCalculation twice? seems annoying... * @returns : Precision QUOTE_PRECISION */ public getFreeCollateral( @@ -545,20 +544,16 @@ export class User { enterHighLeverageMode = false, perpMarketIndex?: number ): BN { - const { totalCollateral } = this.getMarginCalculation(marginCategory, { - enteringHighLeverage: enterHighLeverageMode, - strict: marginCategory === 'Initial', - }); - - const marginCalc = this.getMarginCalculation(marginCategory, { - enteringHighLeverage: enterHighLeverageMode, - strict: marginCategory === 'Initial', - }); + const { totalCollateral, marginRequirement, getIsolatedFreeCollateral } = + this.getMarginCalculation(marginCategory, { + enteringHighLeverage: enterHighLeverageMode, + strict: marginCategory === 'Initial', + }); if (perpMarketIndex !== undefined) { - return marginCalc.getIsolatedFreeCollateral(perpMarketIndex); + return getIsolatedFreeCollateral(perpMarketIndex); } else { - const freeCollateral = totalCollateral.sub(marginCalc.marginRequirement); + const freeCollateral = totalCollateral.sub(marginRequirement); return freeCollateral.gte(ZERO) ? freeCollateral : ZERO; } }