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/src/marginCalculation.ts b/sdk/src/marginCalculation.ts new file mode 100644 index 0000000000..e757feadca --- /dev/null +++ b/sdk/src/marginCalculation.ts @@ -0,0 +1,287 @@ +import { BN } from '@coral-xyz/anchor'; +import { MARGIN_PRECISION, ZERO } 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; + isolatedMarginBuffers: Map; + crossMarginBuffer: BN; + + private constructor(marginType: MarginCategory) { + this.marginType = marginType; + this.mode = { type: 'Standard' }; + this.strict = false; + this.ignoreInvalidDepositOracles = false; + this.isolatedMarginBuffers = new Map(); + } + + static standard(marginType: MarginCategory): MarginContext { + return new MarginContext(marginType); + } + + static liquidation( + crossMarginBuffer: BN, + isolatedMarginBuffers: Map + ): MarginContext { + const ctx = new MarginContext('Maintenance'); + ctx.mode = { type: 'Liquidation' }; + ctx.crossMarginBuffer = crossMarginBuffer; + ctx.isolatedMarginBuffers = isolatedMarginBuffers; + return ctx; + } + + strictMode(strict: boolean): this { + this.strict = strict; + return this; + } + + ignoreInvalidDeposits(ignore: boolean): this { + this.ignoreInvalidDepositOracles = ignore; + return this; + } + + 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; + } +} + +export class IsolatedMarginCalculation { + marginRequirement: BN; + totalCollateral: BN; // deposit + pnl + totalCollateralBuffer: BN; + marginRequirementPlusBuffer: BN; + + constructor() { + this.marginRequirement = ZERO; + this.totalCollateral = ZERO; + this.totalCollateralBuffer = ZERO; + this.marginRequirementPlusBuffer = ZERO; + } + + 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() ? ZERO : shortage; + } +} + +export class MarginCalculation { + context: MarginContext; + totalCollateral: BN; + totalCollateralBuffer: BN; + marginRequirement: BN; + marginRequirementPlusBuffer: BN; + isolatedMarginCalculations: Map; + allDepositOraclesValid: boolean; + allLiabilityOraclesValid: boolean; + withPerpIsolatedLiability: boolean; + withSpotIsolatedLiability: boolean; + totalPerpLiabilityValue: BN; + trackedMarketMarginRequirement: BN; + fuelDeposits: number; + fuelBorrows: number; + fuelPositions: number; + + constructor(context: MarginContext) { + this.context = context; + 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.totalPerpLiabilityValue = ZERO; + this.trackedMarketMarginRequirement = ZERO; + this.fuelDeposits = 0; + this.fuelBorrows = 0; + this.fuelPositions = 0; + } + + addCrossMarginTotalCollateral(delta: BN): void { + const crossMarginBuffer = this.context.crossMarginBuffer; + this.totalCollateral = this.totalCollateral.add(delta); + if (crossMarginBuffer.gt(ZERO) && delta.isNeg()) { + this.totalCollateralBuffer = this.totalCollateralBuffer.add( + delta.mul(crossMarginBuffer).div(MARGIN_PRECISION) + ); + } + } + + addCrossMarginRequirement(marginRequirement: BN, liabilityValue: BN): void { + const crossMarginBuffer = this.context.crossMarginBuffer; + this.marginRequirement = this.marginRequirement.add(marginRequirement); + if (crossMarginBuffer.gt(ZERO)) { + this.marginRequirementPlusBuffer = this.marginRequirementPlusBuffer.add( + marginRequirement.add( + liabilityValue.mul(crossMarginBuffer).div(MARGIN_PRECISION) + ) + ); + } + } + + addIsolatedMarginCalculation( + marketIndex: number, + depositValue: BN, + pnl: BN, + liabilityValue: BN, + marginRequirement: BN + ): void { + const totalCollateral = depositValue.add(pnl); + const isolatedMarginBuffer = + this.context.isolatedMarginBuffers.get(marketIndex) ?? ZERO; + + const totalCollateralBuffer = + isolatedMarginBuffer.gt(ZERO) && pnl.isNeg() + ? pnl.mul(isolatedMarginBuffer).div(MARGIN_PRECISION) + : ZERO; + + const marginRequirementPlusBuffer = isolatedMarginBuffer.gt(ZERO) + ? marginRequirement.add( + liabilityValue.mul(isolatedMarginBuffer).div(MARGIN_PRECISION) + ) + : marginRequirement; + + const iso = new IsolatedMarginCalculation(); + iso.marginRequirement = marginRequirement; + iso.totalCollateral = totalCollateral; + iso.totalCollateralBuffer = totalCollateralBuffer; + iso.marginRequirementPlusBuffer = marginRequirementPlusBuffer; + this.isolatedMarginCalculations.set(marketIndex, iso); + } + + 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; + } + + 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() ? ZERO : 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() ? ZERO : free; + } + + getIsolatedMarginCalculation( + marketIndex: number + ): IsolatedMarginCalculation | undefined { + return this.isolatedMarginCalculations.get(marketIndex); + } + + hasIsolatedMarginCalculation(marketIndex: number): boolean { + return this.isolatedMarginCalculations.has(marketIndex); + } +} 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 61eae83ec1..d05a7382d4 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 = true ): OrderFillSimulation { const tokenAmount = getSignedTokenAmount( getTokenAmount( @@ -50,7 +51,10 @@ 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..83cd78a0e1 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; @@ -1896,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 7d5d78e256..043ac27a3c 100644 --- a/sdk/src/user.ts +++ b/sdk/src/user.ts @@ -14,6 +14,7 @@ import { UserAccount, UserStatus, UserStatsAccount, + AccountLiquidatableStatus, } from './types'; import { calculateEntryPrice, @@ -68,6 +69,7 @@ import { getUser30dRollingVolumeEstimate } from './math/trade'; import { MarketType, PositionDirection, + PositionFlag, SpotBalanceType, SpotMarketAccount, } from './types'; @@ -106,6 +108,13 @@ import { StrictOraclePrice } from './oracles/strictOraclePrice'; import { calculateSpotFuelBonus, calculatePerpFuelBonus } from './math/fuel'; import { grpcUserAccountSubscriber } from './accounts/grpcUserAccountSubscriber'; +import { + IsolatedMarginCalculation, + MarginCalculation, + MarginContext, +} from './marginCalculation'; + +export type MarginType = 'Cross' | 'Isolated'; export class User { driftClient: DriftClient; @@ -343,6 +352,23 @@ 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 + ); + if (perpPosition === undefined) { + return ZERO; + } + return getTokenAmount( + perpPosition.isolatedPositionScaledBalance ?? ZERO, //TODO remove ? later + spotMarket, + SpotBalanceType.DEPOSIT + ); + } + public getClonedPosition(position: PerpPosition): PerpPosition { const clonedPosition = Object.assign({}, position); return clonedPosition; @@ -515,62 +541,130 @@ 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 { totalCollateral, marginRequirement, getIsolatedFreeCollateral } = + this.getMarginCalculation(marginCategory, { + enteringHighLeverage: enterHighLeverageMode, + strict: marginCategory === 'Initial', + }); + + if (perpMarketIndex !== undefined) { + return getIsolatedFreeCollateral(perpMarketIndex); + } else { + 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 + * @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 liquidationBufferMap = new Map(); + if (liquidationBuffer && perpMarketIndex !== undefined) { + liquidationBufferMap.set(perpMarketIndex, liquidationBuffer); + } else if (liquidationBuffer) { + liquidationBufferMap.set('cross', liquidationBuffer); + } + + const marginCalc = this.getMarginCalculation(marginCategory, { strict, - enteringHighLeverage - ).add( - this.getSpotMarketLiabilityValue( - undefined, - marginCategory, - liquidationBuffer, - includeOpenOrders, - strict - ) - ); + includeOpenOrders, + enteringHighLeverage, + liquidationBufferMap, + }); + + // If perpMarketIndex is provided, compute only for that market index + if (perpMarketIndex !== undefined) { + const isolatedMarginCalculation = + marginCalc.isolatedMarginCalculations.get(perpMarketIndex); + if (!isolatedMarginCalculation) return ZERO; + const { marginRequirement, marginRequirementPlusBuffer } = + isolatedMarginCalculation; + + if (liquidationBuffer?.gt(ZERO)) { + return marginRequirementPlusBuffer; + } + return marginRequirement; + } + + // Default: Cross margin requirement + if (liquidationBuffer?.gt(ZERO)) { + return marginCalc.marginRequirementPlusBuffer; + } + 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, 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, + false, // strict default + true, // includeOpenOrders default + false, // enteringHighLeverage default + perpMarketIndex + ); } public getActivePerpPositionsForUserAccount( @@ -580,7 +674,8 @@ export class User { (pos) => !pos.baseAssetAmount.eq(ZERO) || !pos.quoteAssetAmount.eq(ZERO) || - !(pos.openOrders == 0) + !(pos.openOrders == 0) || + pos.isolatedPositionScaledBalance?.gt(ZERO) ); } @@ -1153,46 +1248,93 @@ export class User { marginCategory: MarginCategory = 'Initial', strict = false, includeOpenOrders = true, - liquidationBuffer?: BN + liquidationBuffer?: BN, + perpMarketIndex?: number ): BN { - return this.getSpotMarketAssetValue( - undefined, - marginCategory, + 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, - strict - ).add( - this.getUnrealizedPNL( - true, - undefined, - marginCategory, - strict, - liquidationBuffer - ) - ); + liquidationBufferMap, + }); + + if (perpMarketIndex !== undefined) { + 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; } - 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; } /** * 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 { + if (this.isCrossMarginBeingLiquidated() && !perpMarketIndex) { + return 0; + } + if ( + perpMarketIndex && + this.isIsolatedPositionBeingLiquidated(perpMarketIndex) + ) { return 0; } - const totalCollateral = this.getTotalCollateral('Maintenance'); - const maintenanceMarginReq = this.getMaintenanceMarginRequirement(); + const marginCalc = this.getMarginCalculation('Maintenance'); + + 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; @@ -1491,9 +1633,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 +1663,67 @@ 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 tokenAmount = getTokenAmount( + perpPosition.isolatedPositionScaledBalance ?? ZERO, + quoteSpotMarket, + SpotBalanceType.DEPOSIT + ); + + const spotAssetValue = getStrictTokenValue( + tokenAmount, + quoteSpotMarket.decimals, + strictOracle + ); + + return { + perpLiabilityValue: perpLiability, + perpPnl: positionUnrealizedPnl, + spotAssetValue, + spotLiabilityValue: ZERO, + }; + } + const perpLiability = this.getTotalPerpPositionLiability( marginCategory, undefined, @@ -1817,32 +2013,83 @@ export class User { return netAssetValue.mul(TEN_THOUSAND).div(totalLiabilityValue); } - public canBeLiquidated(): { - canBeLiquidated: boolean; - marginRequirement: BN; - totalCollateral: BN; + public canBeLiquidated(): AccountLiquidatableStatus & { + isolatedPositions: Map; } { - const liquidationBuffer = this.getLiquidationBuffer(); - - const totalCollateral = this.getTotalCollateral( - 'Maintenance', - undefined, - undefined, - liquidationBuffer + // 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, isolatedPositions } + : { + canBeLiquidated: false, + marginRequirement: ZERO, + totalCollateral: ZERO, + isolatedPositions, + }; + } - const marginRequirement = - this.getMaintenanceMarginRequirement(liquidationBuffer); - const canBeLiquidated = totalCollateral.lt(marginRequirement); + /** + * 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, AccountLiquidatableStatus> { + // If not provided, use buffer-aware calc for canBeLiquidated checks + if (!marginCalc) { + const liquidationBufferMap = this.getLiquidationBuffer(); + marginCalc = this.getMarginCalculation('Maintenance', { + liquidationBufferMap, + }); + } - return { - canBeLiquidated, - marginRequirement, - totalCollateral, - }; + const result = new Map<'cross' | number, AccountLiquidatableStatus>(); + + // Cross margin status + const crossTotalCollateral = marginCalc.totalCollateral; + const crossMarginRequirement = marginCalc.marginRequirement; + result.set('cross', { + canBeLiquidated: crossTotalCollateral.lt(crossMarginRequirement), + marginRequirement: crossMarginRequirement, + totalCollateral: crossTotalCollateral, + }); + + // 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 ( + this.isCrossMarginBeingLiquidated() || + this.hasIsolatedPositionBeingLiquidated() + ); + } + + public isCrossMarginBeingLiquidated(): boolean { return ( (this.getUserAccount().status & (UserStatus.BEING_LIQUIDATED | UserStatus.BANKRUPT)) > @@ -1850,6 +2097,55 @@ export class User { ); } + /** Returns true if cross margin is currently below maintenance requirement (no buffer). */ + 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 + ); + } + + 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 + ): number[] { + const liquidatableIsolatedPositions = []; + const calc = marginCalc ?? this.getMarginCalculation('Maintenance'); + for (const [marketIndex, isoCalc] of calc.isolatedMarginCalculations) { + if (this.canIsolatedPositionMarginBeLiquidated(isoCalc)) { + liquidatableIsolatedPositions.push(marketIndex); + } + } + return liquidatableIsolatedPositions; + } + + public canIsolatedPositionMarginBeLiquidated( + isolatedMarginCalculation: IsolatedMarginCalculation + ): boolean { + return isolatedMarginCalculation.totalCollateral.lt( + isolatedMarginCalculation.marginRequirement + ); + } + public hasStatus(status: UserStatus): boolean { return (this.getUserAccount().status & status) > 0; } @@ -2006,13 +2302,68 @@ 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); + if (!isolatedMarginCalculation) return new BN(-1); + 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, includeOpenOrders ); + const marginRequirement = this.getMarginRequirement( marginCategory, undefined, @@ -2020,20 +2371,12 @@ export class User { includeOpenOrders, enteringHighLeverage ); + 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 @@ -3915,4 +4258,311 @@ 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; + liquidationBufferMap?: Map; // margin_buffer analog for buffer mode + } + ): MarginCalculation { + const strict = opts?.strict ?? false; + const enteringHighLeverage = opts?.enteringHighLeverage ?? false; + const liquidationBufferMap = opts?.liquidationBufferMap ?? new Map(); + const includeOpenOrders = opts?.includeOpenOrders ?? true; + + // Equivalent to on-chain user_custom_margin_ratio + const userCustomMarginRatio = + marginCategory === 'Initial' ? this.getUserAccount().maxMarginRatio : 0; + + // 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) + .setCrossMarginBuffer(opts?.liquidationBufferMap?.get('cross') ?? ZERO) + .setIsolatedMarginBuffers(isolatedMarginBuffers); + const calc = new MarginCalculation(ctx); + + // SPOT POSITIONS + 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 + ); + 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 (isQuote) { + const tokenAmount = getSignedTokenAmount( + getTokenAmount( + spotPosition.scaledBalance, + spotMarket, + spotPosition.balanceType + ), + spotPosition.balanceType + ); + if (isVariant(spotPosition.balanceType, 'deposit')) { + // add deposit value to total collateral + const weightedTokenValue = this.getSpotAssetValue( + tokenAmount, + strictOracle, + spotMarket, + marginCategory + ); + calc.addCrossMarginTotalCollateral(weightedTokenValue); + } else { + // borrow on quote contributes to margin requirement + const tokenValueAbs = this.getSpotLiabilityValue( + tokenAmount, + strictOracle, + spotMarket, + marginCategory, + liquidationBufferMap.get('cross') ?? new BN(0) + ).abs(); + calc.addCrossMarginRequirement(tokenValueAbs, tokenValueAbs); + } + continue; + } + + // Non-quote spot: worst-case simulation + const { + tokenAmount: worstCaseTokenAmount, + ordersValue: worstCaseOrdersValue, + } = getWorstCaseTokenAmounts( + spotPosition, + spotMarket, + strictOracle, + marginCategory, + userCustomMarginRatio, + includeOpenOrders + // false + ); + + if (includeOpenOrders) { + // open order IM + calc.addCrossMarginRequirement( + new BN(spotPosition.openOrders).mul(OPEN_ORDER_MARGIN_REQUIREMENT), + ZERO + ); + } + + if (worstCaseTokenAmount.gt(ZERO)) { + const baseAssetValue = this.getSpotAssetValue( + worstCaseTokenAmount, + strictOracle, + spotMarket, + marginCategory + ); + // asset side increases total collateral (weighted) + calc.addCrossMarginTotalCollateral(baseAssetValue); + } else if (worstCaseTokenAmount.lt(ZERO)) { + // liability side increases margin requirement (weighted >= abs(token_value)) + const getSpotLiabilityValue = this.getSpotLiabilityValue( + worstCaseTokenAmount, + strictOracle, + spotMarket, + marginCategory, + liquidationBufferMap.get('cross') + ); + + calc.addCrossMarginRequirement( + getSpotLiabilityValue.abs(), + getSpotLiabilityValue.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.getMMOracleDataForPerpMarket( + market.marketIndex + ); + + const nonMmmOraclePriceData = this.getOracleDataForPerpMarket( + market.marketIndex + ); + + // Worst-case perp liability and weighted pnl + const { worstCaseBaseAssetAmount, worstCaseLiabilityValue } = + calculateWorstCasePerpLiabilityValue( + marketPosition, + market, + nonMmmOraclePriceData.price, + includeOpenOrders + ); + + // margin ratio for this perp + const customMarginRatio = Math.max( + userCustomMarginRatio, + 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 + if (includeOpenOrders) { + 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); + + if (marginCategory !== undefined) { + if (positionUnrealizedPnl.gt(ZERO)) { + positionUnrealizedPnl = positionUnrealizedPnl + .mul( + calculateUnrealizedAssetWeight( + market, + quoteSpotMarket, + positionUnrealizedPnl, + marginCategory, + oraclePriceData + ) + ) + .div(new BN(SPOT_MARKET_WEIGHT_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 ?? ZERO, + quoteSpotMarket, + SpotBalanceType.DEPOSIT + ); + depositValue = getStrictTokenValue( + quoteTokenAmount, + quoteSpotMarket.decimals, + strictQuote + ); + } + calc.addIsolatedMarginCalculation( + market.marketIndex, + depositValue, + positionUnrealizedPnl, + worstCaseLiabilityValue, + perpMarginRequirement + ); + calc.addPerpLiabilityValue(worstCaseLiabilityValue); + } else { + // cross: add to global requirement and collateral + calc.addCrossMarginRequirement( + perpMarginRequirement, + worstCaseLiabilityValue + ); + calc.addCrossMarginTotalCollateral(positionUnrealizedPnl); + } + } + return calc; + } + + private isPerpPositionIsolated(perpPosition: PerpPosition): boolean { + return (perpPosition.positionFlag & PositionFlag.IsolatedPosition) !== 0; + } } 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..8bb8176b89 --- /dev/null +++ b/sdk/tests/user/getMarginCalculation.ts @@ -0,0 +1,361 @@ +import { + BN, + ZERO, + User, + PublicKey, + BASE_PRECISION, + QUOTE_PRECISION, + SPOT_MARKET_BALANCE_PRECISION, + SpotBalanceType, + OPEN_ORDER_MARGIN_REQUIREMENT, + SPOT_MARKET_WEIGHT_PRECISION, + PositionFlag, +} from '../../src'; +import { mockPerpMarkets, mockSpotMarkets } from '../dlob/helpers'; +import { assert } from '../../src/assert/assert'; +import { + mockUserAccount as baseMockUserAccount, + makeMockUser, +} from './helpers'; +import * as _ from 'lodash'; + +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)); + }); + + 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', { + liquidationBufferMap: new Map([['cross', tenPercent]]), + }); + const liability = new BN(110).mul(QUOTE_PRECISION); // $110 + assert(calc.totalCollateral.eq(ZERO)); + 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()}` + ); + }); + + 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 + ); + + 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('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.spotPositions[0].balanceType = SpotBalanceType.DEPOSIT; + myMockUserAccount.spotPositions[0].scaledBalance = new BN(20000000).mul( + SPOT_MARKET_BALANCE_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), + `margin requirement does not equal total collateral: ${calc.marginRequirement.toString()} != ${calc.totalCollateral.toString()}` + ); + }); + + 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', { + liquidationBufferMap: new Map([ + ['cross', tenPct], + [0, new BN(100)], + ]), + }); + 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 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' + ); + }); +});