diff --git a/packages/perps-controller/CHANGELOG.md b/packages/perps-controller/CHANGELOG.md index 1d751d6bc6..f46004086a 100644 --- a/packages/perps-controller/CHANGELOG.md +++ b/packages/perps-controller/CHANGELOG.md @@ -7,6 +7,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added + +- **BREAKING:** Add `PerpsController.previewPositionModify` and `PerpsProvider.previewPositionModify` so clients can read an isolated-margin post-trade projection without placing an order ([#9968](https://github.com/MetaMask/core/pull/9968)) + - Mobile supplies the live position and proposed order; the HyperLiquid provider fetches the asset's margin table and applies selected leverage to the whole resulting position (matching `updateLeverage` before placement). + - The result is a discriminated union (`none` / `unsupported` / `full_close` / `open`) so a non-modifying preview cannot carry a flip kind and a full close cannot report remaining size. Margin and liquidation availability are independent: a missing live liquidation or missing multi-tier table withholds only liquidation. + - Isolated increases, leverage changes (up or down), reductions, flips, and full closes are projected for both longs and shorts. `price` is the expected fill or resting limit; the preview does not distinguish order types. Same-direction `reduceOnly` and increases/flips without a positive price return `{ status: 'none' }`. `resulting.leverage` is mark notional / remaining isolated margin. Liquidation uses the projected mark (not average entry) because isolated `marginUsed` is mark-based equity. A missing margin-table identity withholds liquidation rather than inventing a single-tier schedule. Aggregated providers route by `providerId` / `position.providerId`. Cross-margin returns `{ status: 'unsupported', reason: 'cross_margin' }`. MYX returns `{ status: 'unsupported', reason: 'provider' }`. + - Consumers that implement `PerpsProvider` must add `previewPositionModify`. Clients should use `resulting.direction` (not the order direction) when validating TP/SL against the projected liquidation. + ## [13.0.0] ### Added diff --git a/packages/perps-controller/src/PerpsController-method-action-types.ts b/packages/perps-controller/src/PerpsController-method-action-types.ts index bf36b90cdd..570dbbd1d5 100644 --- a/packages/perps-controller/src/PerpsController-method-action-types.ts +++ b/packages/perps-controller/src/PerpsController-method-action-types.ts @@ -540,6 +540,19 @@ export type PerpsControllerCalculateLiquidationPriceAction = { handler: PerpsController['calculateLiquidationPrice']; }; +/** + * Project the isolated position that would remain after a proposed order. + * Margin and liquidation availability are independent: a missing liquidation + * does not hide a valid margin projection. Cross-margin returns unsupported. + * + * @param params - Live position plus the proposed order. + * @returns Discriminated preview of the resulting position. + */ +export type PerpsControllerPreviewPositionModifyAction = { + type: `PerpsController:previewPositionModify`; + handler: PerpsController['previewPositionModify']; +}; + /** * Calculate maintenance margin for a specific asset * Returns a percentage (e.g., 0.0125 for 1.25%) @@ -1311,6 +1324,7 @@ export type PerpsControllerMethodActions = | PerpsControllerGetAvailableDexsAction | PerpsControllerFetchHistoricalCandlesAction | PerpsControllerCalculateLiquidationPriceAction + | PerpsControllerPreviewPositionModifyAction | PerpsControllerCalculateMaintenanceMarginAction | PerpsControllerGetMaxLeverageAction | PerpsControllerValidateOrderAction diff --git a/packages/perps-controller/src/PerpsController.ts b/packages/perps-controller/src/PerpsController.ts index 9889577d4f..20329495d9 100644 --- a/packages/perps-controller/src/PerpsController.ts +++ b/packages/perps-controller/src/PerpsController.ts @@ -102,6 +102,8 @@ import type { LiquidationPriceParams, LiveDataConfig, MaintenanceMarginParams, + PositionModifyPreviewParams, + PositionModifyPreviewResult, MarginResult, MarketInfo, Order, @@ -976,6 +978,7 @@ const MESSENGER_EXPOSED_METHODS = [ 'markFirstOrderCompleted', 'markTutorialCompleted', 'placeOrder', + 'previewPositionModify', 'reconnect', 'recordMarketViewed', 'refreshEligibility', @@ -4820,6 +4823,26 @@ export class PerpsController extends BaseController< }); } + /** + * Project the isolated position that would remain after a proposed order. + * Margin and liquidation availability are independent: a missing liquidation + * does not hide a valid margin projection. Cross-margin returns unsupported. + * + * @param params - Live position plus the proposed order. + * @returns Discriminated preview of the resulting position. + */ + async previewPositionModify( + params: PositionModifyPreviewParams, + ): Promise { + const provider = this.getActiveProvider(); + const context = this.#createServiceContext('previewPositionModify'); + return this.#marketDataService.previewPositionModify({ + provider, + params, + context, + }); + } + /** * Calculate maintenance margin for a specific asset * Returns a percentage (e.g., 0.0125 for 1.25%) diff --git a/packages/perps-controller/src/index.ts b/packages/perps-controller/src/index.ts index f8ce4dba42..942182cebc 100644 --- a/packages/perps-controller/src/index.ts +++ b/packages/perps-controller/src/index.ts @@ -62,6 +62,7 @@ export type { PerpsControllerCalculateFeesAction, PerpsControllerCalculateLiquidationPriceAction, PerpsControllerCalculateMaintenanceMarginAction, + PerpsControllerPreviewPositionModifyAction, PerpsControllerCancelOrderAction, PerpsControllerCancelOrdersAction, PerpsControllerClearDepositResultAction, @@ -269,6 +270,16 @@ export type { SubscribeOrderBookParams, LiquidationPriceParams, MaintenanceMarginParams, + PositionModifyPreviewParams, + PositionModifyPreviewResult, + PositionModifyPreviewSource, + PositionModifyPreviewKind, + PositionPreviewValue, + PositionModifyPreviewCurrent, + PositionModifyPreviewOpen, + PositionModifyPreviewFullClose, + PositionModifyPreviewUnsupported, + PositionModifyPreviewNone, FeeCalculationParams, FeeCalculationResult, GetOrderCapabilitiesParams, @@ -648,6 +659,14 @@ export { parseAssetName, adaptHyperLiquidLedgerUpdateToUserHistoryItem, } from './utils/index.js'; +export { + previewHyperLiquidIsolatedPositionModify, + resolveHyperLiquidMarginTiers, + buildMaintenanceSchedule, + estimateIsolatedLiquidationPrice, + estimateIsolatedLiquidationPriceAtTier, +} from './utils/index.js'; +export type { HyperLiquidMarginTier } from './utils/index.js'; export { getEnvironment } from './utils/index.js'; export type { FiatRangeConfig } from './utils/index.js'; export { diff --git a/packages/perps-controller/src/providers/AggregatedPerpsProvider.ts b/packages/perps-controller/src/providers/AggregatedPerpsProvider.ts index ae8a61f0d3..cbe06c13fc 100644 --- a/packages/perps-controller/src/providers/AggregatedPerpsProvider.ts +++ b/packages/perps-controller/src/providers/AggregatedPerpsProvider.ts @@ -57,6 +57,8 @@ import type { LiquidationPriceParams, LiveDataConfig, MaintenanceMarginParams, + PositionModifyPreviewParams, + PositionModifyPreviewResult, MarginResult, MarketInfo, Order, @@ -752,6 +754,15 @@ export class AggregatedPerpsProvider implements PerpsProvider { return provider.calculateFees(params); } + async previewPositionModify( + params: PositionModifyPreviewParams, + ): Promise { + const [, provider] = this.#getProviderOrDefault( + params.providerId ?? params.position.providerId, + ); + return provider.previewPositionModify(params); + } + // ============================================================================ // Subscriptions (Multiplex via SubscriptionMultiplexer) // ============================================================================ diff --git a/packages/perps-controller/src/providers/HyperLiquidProvider.ts b/packages/perps-controller/src/providers/HyperLiquidProvider.ts index 909aef544b..a3d8f51654 100644 --- a/packages/perps-controller/src/providers/HyperLiquidProvider.ts +++ b/packages/perps-controller/src/providers/HyperLiquidProvider.ts @@ -109,6 +109,8 @@ import type { LiquidationPriceParams, LiveDataConfig, MaintenanceMarginParams, + PositionModifyPreviewParams, + PositionModifyPreviewResult, MarginResult, MarketInfo, Order, @@ -176,6 +178,10 @@ import { HYPERLIQUID_SCALE_CLOID_MARKER, parseAssetName, } from '../utils/hyperLiquidAdapter.js'; +import { + previewHyperLiquidIsolatedPositionModify, + resolveHyperLiquidMarginTiers, +} from '../utils/hyperLiquidPositionPreview.js'; import { createErrorResult, getMaxOrderValue, @@ -12941,6 +12947,52 @@ export class HyperLiquidProvider implements PerpsProvider { } } + /** + * Project the isolated position that would remain after a proposed order. + * + * Fetches the asset's margin table from cached meta so liquidation uses the + * maintenance tier at the resulting liquidation notional. Cross-margin + * positions return unsupported without a table lookup. + * + * @param params - Live position plus the proposed order. + * @returns Discriminated preview; margin and liquidation are independently available. + */ + async previewPositionModify( + params: PositionModifyPreviewParams, + ): Promise { + if (params.position.leverage.type === 'cross') { + return { status: 'unsupported', reason: 'cross_margin' }; + } + + const { dex: dexName } = parseAssetName(params.position.symbol); + let marginTiers = null; + + try { + const meta = await this.#getCachedMeta({ dexName }); + const assetInfo = meta.universe.find( + (universeItem) => universeItem.name === params.position.symbol, + ); + marginTiers = resolveHyperLiquidMarginTiers({ + marginTableId: assetInfo?.marginTableId, + maxLeverage: assetInfo?.maxLeverage ?? params.position.maxLeverage, + marginTables: meta.marginTables, + }); + } catch (error) { + this.#deps.debugLogger.log( + 'HyperLiquidProvider: margin table unavailable for position preview', + { + symbol: params.position.symbol, + error, + }, + ); + } + + return previewHyperLiquidIsolatedPositionModify({ + ...params, + marginTiers, + }); + } + /** * Calculate liquidation price using HyperLiquid's formula * Formula: liq_price = price - side * margin_available / position_size / (1 - maintenanceMarginRatio * side) diff --git a/packages/perps-controller/src/providers/MYXProvider.ts b/packages/perps-controller/src/providers/MYXProvider.ts index 85f91e0161..9314fd0316 100644 --- a/packages/perps-controller/src/providers/MYXProvider.ts +++ b/packages/perps-controller/src/providers/MYXProvider.ts @@ -55,6 +55,8 @@ import type { LiquidationPriceParams, LiveDataConfig, MaintenanceMarginParams, + PositionModifyPreviewParams, + PositionModifyPreviewResult, MarginResult, MarketInfo, Order, @@ -927,6 +929,12 @@ export class MYXProvider implements PerpsProvider { }; } + async previewPositionModify( + _params: PositionModifyPreviewParams, + ): Promise { + return { status: 'unsupported', reason: 'provider' }; + } + // ============================================================================ // Subscriptions (Stage 1 - No-op) // ============================================================================ diff --git a/packages/perps-controller/src/services/MarketDataService.ts b/packages/perps-controller/src/services/MarketDataService.ts index df8d9758ae..eec95293e8 100644 --- a/packages/perps-controller/src/services/MarketDataService.ts +++ b/packages/perps-controller/src/services/MarketDataService.ts @@ -25,6 +25,8 @@ import type { GetAvailableDexsParams, LiquidationPriceParams, MaintenanceMarginParams, + PositionModifyPreviewParams, + PositionModifyPreviewResult, FeeCalculationParams, FeeCalculationResult, OrderParams, @@ -1208,6 +1210,38 @@ export class MarketDataService { } } + /** + * Project the position that would remain after a proposed order. + * + * @param options - The configuration options. + * @param options.provider - The perps provider instance. + * @param options.params - Live position plus the proposed order. + * @param options.context - The service context for dependencies. + * @returns Discriminated preview of the resulting position. + */ + async previewPositionModify(options: { + provider: PerpsProvider; + params: PositionModifyPreviewParams; + context: ServiceContext; + }): Promise { + const { provider, params } = options; + + try { + return await provider.previewPositionModify(params); + } catch (error) { + this.#deps.logger.error( + ensureError(error, 'MarketDataService.previewPositionModify'), + { + context: { + name: 'MarketDataService.previewPositionModify', + data: { params }, + }, + }, + ); + throw error; + } + } + /** * Calculate maintenance margin for a position * diff --git a/packages/perps-controller/src/types/index.ts b/packages/perps-controller/src/types/index.ts index 585156f595..82e6e037d7 100644 --- a/packages/perps-controller/src/types/index.ts +++ b/packages/perps-controller/src/types/index.ts @@ -1338,6 +1338,123 @@ export type LiquidationPriceParams = { asset?: string; // Optional: for asset-specific maintenance margins }; +/** + * Live position fields required to project a modify. Clients may pass a full + * {@link Position}; extra fields are ignored. + */ +export type PositionModifyPreviewSource = Pick< + Position, + | 'symbol' + | 'size' + | 'marginUsed' + | 'liquidationPrice' + | 'entryPrice' + | 'leverage' + | 'positionValue' + | 'maxLeverage' + | 'providerId' +>; + +/** + * Proposed order plus the live position it would modify. + * + * Isolated-margin previews apply `leverage` to the *whole* resulting + * position, matching `updateLeverage` before placement. Cross-margin + * positions are not projected. + */ +export type PositionModifyPreviewParams = { + position: PositionModifyPreviewSource; + /** Proposed order direction. */ + direction: 'long' | 'short'; + /** Proposed order size in token units. */ + size: string; + /** + * Expected fill price for a marketable order, or the resting limit price. + * Increases and flips require a positive price; a reduce does not. + * Scale, TWAP, and chase orders should pass the expected fill size and price; + * this preview models a single fill. + */ + price: string; + /** + * Isolated leverage the provider will set on the asset before placing. + * Applied to the entire resulting position, not only the added size. + */ + leverage: number; + reduceOnly?: boolean; + /** + * Estimated trading fees in USD. Deducted from isolated margin on + * increases and flips. Omit or pass 0 when unknown. + */ + feeAmountUsd?: number; + /** + * Explicit venue route. Aggregated providers use this, then + * `position.providerId`, then the default provider. + */ + providerId?: PerpsProviderType; +}; + +/** + * Independently available numeric projection. Margin can be known when + * liquidation cannot (missing maintenance-tier data, or no liquidation risk). + */ +export type PositionPreviewValue = + | { available: true; value: number } + | { available: false }; + +export type PositionModifyPreviewKind = 'increase' | 'decrease' | 'flip'; + +export type PositionModifyPreviewCurrent = { + margin: PositionPreviewValue; + liquidationPrice: PositionPreviewValue; +}; + +export type PositionModifyPreviewOpen = { + status: 'open'; + kind: PositionModifyPreviewKind; + current: PositionModifyPreviewCurrent; + resulting: { + direction: 'long' | 'short'; + /** Resulting token size; always > 0 for `open`. */ + size: number; + entryPrice: number; + /** + * Isolated leverage from mark notional / remaining margin, matching + * HyperLiquid's displayed leverage rather than entry notional / margin. + */ + leverage: number; + margin: PositionPreviewValue; + liquidationPrice: PositionPreviewValue; + }; +}; + +export type PositionModifyPreviewFullClose = { + status: 'full_close'; + current: PositionModifyPreviewCurrent; + /** Direction of the position being closed. */ + resultingDirection: 'long' | 'short'; +}; + +export type PositionModifyPreviewUnsupported = { + status: 'unsupported'; + reason: 'cross_margin' | 'provider'; +}; + +export type PositionModifyPreviewNone = { + status: 'none'; +}; + +/** + * Read-only post-trade position projection. + * + * Discriminated on `status` so a non-modifying result cannot carry a + * flip/full-close kind, and a full close cannot report a remaining size. + */ +export type PositionModifyPreviewResult = + | PositionModifyPreviewNone + | PositionModifyPreviewUnsupported + | PositionModifyPreviewFullClose + | PositionModifyPreviewOpen; + export type MaintenanceMarginParams = { asset: string; positionSize?: number; // Optional: for tiered margin systems @@ -1739,6 +1856,15 @@ export type PerpsProvider = { calculateMaintenanceMargin(params: MaintenanceMarginParams): Promise; getMaxLeverage(asset: string): Promise; calculateFees(params: FeeCalculationParams): Promise; + /** + * Read-only projection of the position that would remain after the proposed + * order. Isolated-margin venues apply selected leverage to the whole + * resulting position and use the maintenance tier at liquidation notional. + * Cross-margin returns `{ status: 'unsupported', reason: 'cross_margin' }`. + */ + previewPositionModify( + params: PositionModifyPreviewParams, + ): Promise; // Live data subscriptions → Direct UI (NO Redux, maximum speed) subscribeToPrices(params: SubscribePricesParams): () => void; diff --git a/packages/perps-controller/src/utils/hyperLiquidPositionPreview.ts b/packages/perps-controller/src/utils/hyperLiquidPositionPreview.ts new file mode 100644 index 0000000000..7dcf2d452a --- /dev/null +++ b/packages/perps-controller/src/utils/hyperLiquidPositionPreview.ts @@ -0,0 +1,495 @@ +import type { + PositionModifyPreviewCurrent, + PositionModifyPreviewParams, + PositionModifyPreviewResult, + PositionPreviewValue, +} from '../types/index.js'; + +/** Token-size comparison tolerance (floating-point / szDecimals noise). */ +const SIZE_EPSILON = 1e-10; + +/** + * HyperLiquid documents margin-table IDs below 50 as a single tier whose max + * leverage equals the table id. Multi-tier tables need the `meta.marginTables` + * entry; without it liquidation is withheld. + * + * @see https://hyperliquid.gitbook.io/hyperliquid-docs/trading/margin-and-pnl + */ +const SINGLE_TIER_MARGIN_TABLE_ID_MAX = 50; + +export type HyperLiquidMarginTier = { + /** Inclusive notional lower bound in USD. */ + lowerBound: number; + maxLeverage: number; +}; + +export type PreviewHyperLiquidIsolatedPositionModifyParams = + PositionModifyPreviewParams & { + /** + * Maintenance tiers for the asset, lowest notional first. `null` or empty + * withholds liquidation while still returning margin when it can be known. + */ + marginTiers?: HyperLiquidMarginTier[] | null; + }; + +type MaintenanceScheduleTier = { + lowerBound: number; + upperBound: number; + maxLeverage: number; + maintenanceMarginRate: number; + maintenanceDeduction: number; +}; + +const unavailable = (): PositionPreviewValue => ({ available: false }); + +const available = (value: number): PositionPreviewValue => ({ + available: true, + value, +}); + +const parseFiniteNumber = (value: string | null | undefined): number => + Number.parseFloat(value ?? ''); + +const isPositiveFinite = (value: number): boolean => + Number.isFinite(value) && value > 0; + +const isNonNegativeFinite = (value: number): boolean => + Number.isFinite(value) && value >= 0; + +const currentFromPosition = (params: { + currentMargin: number; + currentLiquidationPrice: number; +}): PositionModifyPreviewCurrent => ({ + margin: isNonNegativeFinite(params.currentMargin) + ? available(params.currentMargin) + : unavailable(), + liquidationPrice: isPositiveFinite(params.currentLiquidationPrice) + ? available(params.currentLiquidationPrice) + : unavailable(), +}); + +/** + * Resolves the HyperLiquid margin table into preview tiers. + * + * Table IDs below 50 are single-tier. IDs at or above 50 require the matching + * `marginTables` row; missing data returns `null` so liquidation is withheld. + * An unknown table id (asset missing from `meta.universe`) also returns `null` + * instead of inventing a single tier from max leverage. + * + * @param params - Margin table id, asset max leverage, and optional tables. + * @param params.marginTableId - HyperLiquid margin table id from `meta.universe`. + * @param params.maxLeverage - Asset max leverage used for single-tier tables. + * @param params.marginTables - `meta.marginTables` rows; required for table ids ≥ 50. + * @returns Tiers for liquidation, or `null` when the table identity is unknown. + */ +export function resolveHyperLiquidMarginTiers(params: { + marginTableId?: number; + maxLeverage?: number; + marginTables?: + | [number, { marginTiers: { lowerBound: string; maxLeverage: number }[] }][] + | null; +}): HyperLiquidMarginTier[] | null { + const { marginTableId, maxLeverage, marginTables } = params; + + if (typeof marginTableId !== 'number' || !Number.isFinite(marginTableId)) { + return null; + } + + if (marginTableId >= SINGLE_TIER_MARGIN_TABLE_ID_MAX) { + const table = marginTables?.find(([id]) => id === marginTableId)?.[1]; + const tiers = table?.marginTiers + ?.map((tier) => ({ + lowerBound: Number.parseFloat(tier.lowerBound), + maxLeverage: tier.maxLeverage, + })) + .filter( + (tier) => + Number.isFinite(tier.lowerBound) && + tier.lowerBound >= 0 && + isPositiveFinite(tier.maxLeverage), + ); + return tiers && tiers.length > 0 ? tiers : null; + } + + if (marginTableId <= 0) { + return null; + } + + const tierMaxLeverage = + typeof maxLeverage === 'number' && isPositiveFinite(maxLeverage) + ? maxLeverage + : marginTableId; + if (!isPositiveFinite(tierMaxLeverage)) { + return null; + } + + return [{ lowerBound: 0, maxLeverage: tierMaxLeverage }]; +} + +/** + * Builds the continuous maintenance-margin schedule from HyperLiquid tiers. + * + * `maintenance_margin = notional * mmr - deduction`, with + * `mmr = 1 / (2 * tierMaxLeverage)` and deduction chosen so the function is + * continuous across tier boundaries. + * + * @param tiers - Notional lower bounds and per-tier max leverage. + * @returns Sorted schedule used to pick the tier at liquidation notional. + */ +export function buildMaintenanceSchedule( + tiers: HyperLiquidMarginTier[], +): MaintenanceScheduleTier[] { + const sorted = [...tiers] + .filter( + (tier) => + Number.isFinite(tier.lowerBound) && + tier.lowerBound >= 0 && + isPositiveFinite(tier.maxLeverage), + ) + .sort((left, right) => left.lowerBound - right.lowerBound); + + const schedule: MaintenanceScheduleTier[] = []; + let deduction = 0; + let previousMmr = 0; + + for (let index = 0; index < sorted.length; index++) { + const tier = sorted[index]; + const maintenanceMarginRate = 1 / (2 * tier.maxLeverage); + if (index > 0) { + deduction += tier.lowerBound * (maintenanceMarginRate - previousMmr); + } + schedule.push({ + lowerBound: tier.lowerBound, + upperBound: + index + 1 < sorted.length ? sorted[index + 1].lowerBound : Infinity, + maxLeverage: tier.maxLeverage, + maintenanceMarginRate, + maintenanceDeduction: deduction, + }); + previousMmr = maintenanceMarginRate; + } + + return schedule; +} + +/** + * Isolated liquidation from mark, margin, size, and a maintenance tier. + * + * HyperLiquid liquidations use mark price, not average entry. Isolated + * `marginUsed` is mark-based equity (includes unrealized PnL), so the + * closed form must use the same reference: + * + * Long: `(mark - margin/size - deduction/size) / (1 - mmr)` + * Short: `(mark + margin/size + deduction/size) / (1 + mmr)` + * + * @param params - Position geometry plus the tier's mmr and deduction. + * @param params.isLong - Whether the remaining position is long. + * @param params.markPrice - Projected mark after the proposed fill. + * @param params.margin - Isolated margin after the proposed fill. + * @param params.positionSize - Absolute remaining size in token units. + * @param params.maintenanceMarginRate - `1 / (2 * tierMaxLeverage)` for the tier. + * @param params.maintenanceDeduction - Continuity deduction at this tier. + * @returns Liquidation price, or `null` when the inputs cannot produce one. + */ +export function estimateIsolatedLiquidationPrice(params: { + isLong: boolean; + markPrice: number; + margin: number; + positionSize: number; + maintenanceMarginRate: number; + maintenanceDeduction?: number; +}): number | null { + const { + isLong, + markPrice, + margin, + positionSize, + maintenanceMarginRate, + maintenanceDeduction = 0, + } = params; + + if ( + !isPositiveFinite(markPrice) || + !isPositiveFinite(margin) || + !isPositiveFinite(positionSize) || + !Number.isFinite(maintenanceMarginRate) || + maintenanceMarginRate < 0 || + !Number.isFinite(maintenanceDeduction) + ) { + return null; + } + + const direction = isLong ? -1 : 1; + const side = isLong ? 1 : -1; + const adjustmentFactor = 1 - maintenanceMarginRate * side; + if (Math.abs(adjustmentFactor) < 0.0001) { + return null; + } + + const liquidationPrice = + (markPrice + + direction * (margin / positionSize) + + direction * (maintenanceDeduction / positionSize)) / + adjustmentFactor; + + if (!isPositiveFinite(liquidationPrice)) { + return null; + } + + return liquidationPrice; +} + +/** + * Picks the maintenance tier whose notional range contains the liquidation + * notional (`size * liqPrice`), including that tier's deduction. + * + * @param params - Resulting geometry and the asset's maintenance schedule. + * @param params.isLong - Whether the remaining position is long. + * @param params.markPrice - Projected mark after the proposed fill. + * @param params.margin - Isolated margin after the proposed fill. + * @param params.positionSize - Absolute remaining size in token units. + * @param params.marginTiers - Maintenance tiers, lowest notional first. + * @returns Liquidation price when a consistent tier exists. + */ +export function estimateIsolatedLiquidationPriceAtTier(params: { + isLong: boolean; + markPrice: number; + margin: number; + positionSize: number; + marginTiers: HyperLiquidMarginTier[] | null | undefined; +}): number | null { + const schedule = buildMaintenanceSchedule(params.marginTiers ?? []); + if (schedule.length === 0) { + return null; + } + + for (const tier of schedule) { + const liquidationPrice = estimateIsolatedLiquidationPrice({ + isLong: params.isLong, + markPrice: params.markPrice, + margin: params.margin, + positionSize: params.positionSize, + maintenanceMarginRate: tier.maintenanceMarginRate, + maintenanceDeduction: tier.maintenanceDeduction, + }); + if (liquidationPrice === null) { + continue; + } + const notionalAtLiquidation = params.positionSize * liquidationPrice; + if ( + notionalAtLiquidation >= tier.lowerBound && + notionalAtLiquidation < tier.upperBound + ) { + return liquidationPrice; + } + } + + return null; +} + +const resultingLeverage = (params: { + notional: number; + margin: number; + fallback: number; +}): number => { + if (params.margin > 0 && params.notional > 0) { + return params.notional / params.margin; + } + return params.fallback; +}; + +/** + * Projects the isolated position that would remain after a proposed order. + * + * Models HyperLiquid's isolated `updateLeverage` (the selected leverage is + * applied to the whole asset before the fill) and maintenance tiers at the + * resulting liquidation notional. Liquidation uses the projected mark, not + * average entry, because isolated `marginUsed` is mark-based equity. + * Cross-margin positions return `{ status: 'unsupported', reason: 'cross_margin' }`. + * + * `price` is the fill or resting-limit price the caller expects. A marketable + * order should pass its execution price; a limit should pass the limit. The + * preview does not distinguish order types itself, does not model whether a + * resting limit would fill, and treats scale/TWAP/chase as one aggregated fill. + * Decrease margin is the remaining isolated collateral after leverage + * reallocation; close fees and realized PnL settle to the account, not the + * leftover margin. + * + * @param params - Live isolated position, proposed order, and optional tiers. + * @returns Discriminated preview; margin and liquidation are independently available. + */ +export function previewHyperLiquidIsolatedPositionModify( + params: PreviewHyperLiquidIsolatedPositionModifyParams, +): PositionModifyPreviewResult { + const { position, direction, reduceOnly = false, marginTiers } = params; + + if (position.leverage.type === 'cross') { + return { status: 'unsupported', reason: 'cross_margin' }; + } + + const currentSize = Math.abs(parseFiniteNumber(position.size)); + const signedSize = parseFiniteNumber(position.size); + const currentMargin = parseFiniteNumber(position.marginUsed); + const currentEntry = parseFiniteNumber(position.entryPrice); + const currentLiquidationPrice = parseFiniteNumber(position.liquidationPrice); + const currentLeverage = position.leverage.value; + const selectedLeverage = params.leverage; + const orderSize = parseFiniteNumber(params.size); + const orderPrice = parseFiniteNumber(params.price); + const feeAmountUsd = + typeof params.feeAmountUsd === 'number' && params.feeAmountUsd > 0 + ? params.feeAmountUsd + : 0; + + if ( + !isPositiveFinite(currentSize) || + !Number.isFinite(signedSize) || + signedSize === 0 || + !isNonNegativeFinite(currentMargin) || + !isPositiveFinite(currentEntry) || + !isPositiveFinite(selectedLeverage) || + !isPositiveFinite(currentLeverage) + ) { + return { status: 'none' }; + } + + const openDirection: 'long' | 'short' = signedSize > 0 ? 'long' : 'short'; + const currentSnapshot = currentFromPosition({ + currentMargin, + currentLiquidationPrice, + }); + + if (!isPositiveFinite(orderSize)) { + return { status: 'none' }; + } + + const positionValue = parseFiniteNumber(position.positionValue); + const currentNotional = isPositiveFinite(positionValue) + ? positionValue + : currentSize * currentEntry; + + const leverageChanged = + Math.abs(selectedLeverage - currentLeverage) > SIZE_EPSILON; + const existingMarginAfterLeverage = leverageChanged + ? currentNotional / selectedLeverage + : currentMargin; + + const withResultingLiquidation = (preview: { + kind: 'increase' | 'decrease' | 'flip'; + resultingDirection: 'long' | 'short'; + resultingSize: number; + resultingEntryPrice: number; + resultingMarkPrice: number; + resultingNotional: number; + newMargin: number; + }): PositionModifyPreviewResult => { + const liquidationPrice = estimateIsolatedLiquidationPriceAtTier({ + isLong: preview.resultingDirection === 'long', + markPrice: preview.resultingMarkPrice, + margin: preview.newMargin, + positionSize: preview.resultingSize, + marginTiers, + }); + + return { + status: 'open', + kind: preview.kind, + current: currentSnapshot, + resulting: { + direction: preview.resultingDirection, + size: preview.resultingSize, + entryPrice: preview.resultingEntryPrice, + leverage: resultingLeverage({ + notional: preview.resultingNotional, + margin: preview.newMargin, + fallback: selectedLeverage, + }), + margin: available(preview.newMargin), + liquidationPrice: + liquidationPrice === null + ? unavailable() + : available(liquidationPrice), + }, + }; + }; + + const isSameDirection = openDirection === direction; + const fillPrice = isPositiveFinite(orderPrice) ? orderPrice : null; + + // Reduce-only in the position's own direction cannot add size and does not + // close it, so there is no resulting position to project. + if (isSameDirection && reduceOnly) { + return { status: 'none' }; + } + + if (isSameDirection) { + if (fillPrice === null) { + return { status: 'none' }; + } + const orderMargin = (orderSize * fillPrice) / selectedLeverage; + const resultingSize = currentSize + orderSize; + const resultingEntryPrice = + (currentSize * currentEntry + orderSize * fillPrice) / resultingSize; + const newMargin = Math.max( + 0, + existingMarginAfterLeverage + orderMargin - feeAmountUsd, + ); + + return withResultingLiquidation({ + kind: 'increase', + resultingDirection: openDirection, + resultingSize, + resultingEntryPrice, + resultingMarkPrice: fillPrice, + resultingNotional: resultingSize * fillPrice, + newMargin, + }); + } + + if (orderSize + SIZE_EPSILON < currentSize) { + const remainingRatio = (currentSize - orderSize) / currentSize; + const resultingSize = currentSize - orderSize; + const newMargin = Math.max(0, existingMarginAfterLeverage * remainingRatio); + const currentMarkPrice = currentNotional / currentSize; + const resultingMarkPrice = fillPrice ?? currentMarkPrice; + + return withResultingLiquidation({ + kind: 'decrease', + resultingDirection: openDirection, + resultingSize, + resultingEntryPrice: currentEntry, + resultingMarkPrice, + resultingNotional: resultingSize * resultingMarkPrice, + newMargin, + }); + } + + const leftover = orderSize - currentSize; + if (leftover > SIZE_EPSILON && !reduceOnly) { + if (fillPrice === null) { + return { status: 'none' }; + } + const orderMargin = (orderSize * fillPrice) / selectedLeverage; + const leftoverRatio = leftover / orderSize; + const leftoverMargin = Math.max( + 0, + (orderMargin - feeAmountUsd) * leftoverRatio, + ); + + return withResultingLiquidation({ + kind: 'flip', + resultingDirection: direction, + resultingSize: leftover, + resultingEntryPrice: fillPrice, + resultingMarkPrice: fillPrice, + resultingNotional: leftover * fillPrice, + newMargin: leftoverMargin, + }); + } + + return { + status: 'full_close', + current: currentSnapshot, + resultingDirection: openDirection, + }; +} diff --git a/packages/perps-controller/src/utils/index.ts b/packages/perps-controller/src/utils/index.ts index 0f37853de1..c1104121ab 100644 --- a/packages/perps-controller/src/utils/index.ts +++ b/packages/perps-controller/src/utils/index.ts @@ -24,6 +24,7 @@ export { adaptHyperLiquidLedgerUpdateToUserHistoryItem, } from './hyperLiquidAdapter.js'; export * from './hyperLiquidOrderBookProcessor.js'; +export * from './hyperLiquidPositionPreview.js'; export * from './hyperLiquidValidation.js'; export * from './idUtils.js'; export * from './marketDataTransform.js'; diff --git a/packages/perps-controller/tests/helpers/providerMocks.ts b/packages/perps-controller/tests/helpers/providerMocks.ts index 6dfa308def..397fb67212 100644 --- a/packages/perps-controller/tests/helpers/providerMocks.ts +++ b/packages/perps-controller/tests/helpers/providerMocks.ts @@ -51,6 +51,7 @@ export const createMockHyperLiquidProvider = calculateMaintenanceMargin: jest.fn(), getMaxLeverage: jest.fn(), calculateFees: jest.fn(), + previewPositionModify: jest.fn(), getMarketDataWithPrices: jest.fn(), getBlockExplorerUrl: jest.fn(), getOrderFills: jest.fn(), diff --git a/packages/perps-controller/tests/src/PerpsController.configuration.test.ts b/packages/perps-controller/tests/src/PerpsController.configuration.test.ts index 846a8bd8c5..ad2170dc31 100644 --- a/packages/perps-controller/tests/src/PerpsController.configuration.test.ts +++ b/packages/perps-controller/tests/src/PerpsController.configuration.test.ts @@ -139,6 +139,7 @@ const mockMarketDataServiceInstance = { calculateLiquidationPrice: jest.fn(), getMaxLeverage: jest.fn(), calculateFees: jest.fn().mockResolvedValue({ totalFee: 0 }), + previewPositionModify: jest.fn(), getAvailableDexs: jest.fn().mockResolvedValue([]), getBlockExplorerUrl: jest.fn(), getOrderFills: jest.fn(), diff --git a/packages/perps-controller/tests/src/PerpsController.lifecycle.test.ts b/packages/perps-controller/tests/src/PerpsController.lifecycle.test.ts index ac0ead49b6..b77fc7239f 100644 --- a/packages/perps-controller/tests/src/PerpsController.lifecycle.test.ts +++ b/packages/perps-controller/tests/src/PerpsController.lifecycle.test.ts @@ -110,6 +110,7 @@ const mockMarketDataServiceInstance = { calculateLiquidationPrice: jest.fn(), getMaxLeverage: jest.fn(), calculateFees: jest.fn().mockResolvedValue({ totalFee: 0 }), + previewPositionModify: jest.fn(), getAvailableDexs: jest.fn().mockResolvedValue([]), getBlockExplorerUrl: jest.fn(), getOrderFills: jest.fn(), diff --git a/packages/perps-controller/tests/src/PerpsController.operations.test.ts b/packages/perps-controller/tests/src/PerpsController.operations.test.ts index 4370454f64..86f4e21853 100644 --- a/packages/perps-controller/tests/src/PerpsController.operations.test.ts +++ b/packages/perps-controller/tests/src/PerpsController.operations.test.ts @@ -115,6 +115,7 @@ const mockMarketDataServiceInstance = { calculateLiquidationPrice: jest.fn(), getMaxLeverage: jest.fn(), calculateFees: jest.fn().mockResolvedValue({ totalFee: 0 }), + previewPositionModify: jest.fn(), getAvailableDexs: jest.fn().mockResolvedValue([]), getBlockExplorerUrl: jest.fn(), getOrderFills: jest.fn(), diff --git a/packages/perps-controller/tests/src/PerpsController.providers-cache.test.ts b/packages/perps-controller/tests/src/PerpsController.providers-cache.test.ts index 9fe1bd8eb2..cc02cd6c6c 100644 --- a/packages/perps-controller/tests/src/PerpsController.providers-cache.test.ts +++ b/packages/perps-controller/tests/src/PerpsController.providers-cache.test.ts @@ -137,6 +137,7 @@ const mockMarketDataServiceInstance = { calculateLiquidationPrice: jest.fn(), getMaxLeverage: jest.fn(), calculateFees: jest.fn().mockResolvedValue({ totalFee: 0 }), + previewPositionModify: jest.fn(), getAvailableDexs: jest.fn().mockResolvedValue([]), getBlockExplorerUrl: jest.fn(), getOrderFills: jest.fn(), diff --git a/packages/perps-controller/tests/src/PerpsController.state.test.ts b/packages/perps-controller/tests/src/PerpsController.state.test.ts index 2e6ae88871..85bae8ca31 100644 --- a/packages/perps-controller/tests/src/PerpsController.state.test.ts +++ b/packages/perps-controller/tests/src/PerpsController.state.test.ts @@ -110,6 +110,7 @@ const mockMarketDataServiceInstance = { calculateLiquidationPrice: jest.fn(), getMaxLeverage: jest.fn(), calculateFees: jest.fn().mockResolvedValue({ totalFee: 0 }), + previewPositionModify: jest.fn(), getAvailableDexs: jest.fn().mockResolvedValue([]), getBlockExplorerUrl: jest.fn(), getOrderFills: jest.fn(), diff --git a/packages/perps-controller/tests/src/PerpsController.subscriptions.test.ts b/packages/perps-controller/tests/src/PerpsController.subscriptions.test.ts index 3731e2fdc3..db820d4f05 100644 --- a/packages/perps-controller/tests/src/PerpsController.subscriptions.test.ts +++ b/packages/perps-controller/tests/src/PerpsController.subscriptions.test.ts @@ -6,7 +6,10 @@ /* eslint-disable @typescript-eslint/no-explicit-any */ -import { createMockHyperLiquidProvider } from '../helpers/providerMocks.js'; +import { + createMockHyperLiquidProvider, + createMockPosition, +} from '../helpers/providerMocks.js'; import { createMockInfrastructure, createMockMessenger, @@ -106,6 +109,7 @@ const mockMarketDataServiceInstance = { calculateLiquidationPrice: jest.fn(), getMaxLeverage: jest.fn(), calculateFees: jest.fn().mockResolvedValue({ totalFee: 0 }), + previewPositionModify: jest.fn(), getAvailableDexs: jest.fn().mockResolvedValue([]), getBlockExplorerUrl: jest.fn(), getOrderFills: jest.fn(), @@ -655,6 +659,37 @@ describe('PerpsController', () => { }); }); + describe('previewPositionModify', () => { + it('delegates to MarketDataService', async () => { + const params = { + position: createMockPosition({ + leverage: { type: 'isolated' as const, value: 5 }, + }), + direction: 'long' as const, + size: '0.1', + price: '50000', + leverage: 10, + }; + + markControllerAsInitialized(); + controller.testSetProviders(new Map([['hyperliquid', mockProvider]])); + jest + .spyOn(mockMarketDataServiceInstance, 'previewPositionModify') + .mockResolvedValue({ status: 'none' }); + + const result = await controller.previewPositionModify(params); + + expect(result).toEqual({ status: 'none' }); + expect( + mockMarketDataServiceInstance.previewPositionModify, + ).toHaveBeenCalledWith({ + provider: mockProvider, + params, + context: expect.any(Object), + }); + }); + }); + describe('getMaxLeverage', () => { it('gets max leverage successfully', async () => { const asset = 'BTC'; diff --git a/packages/perps-controller/tests/src/PerpsController.trading.test.ts b/packages/perps-controller/tests/src/PerpsController.trading.test.ts index 427c8aec0f..c6e39fc449 100644 --- a/packages/perps-controller/tests/src/PerpsController.trading.test.ts +++ b/packages/perps-controller/tests/src/PerpsController.trading.test.ts @@ -106,6 +106,7 @@ const mockMarketDataServiceInstance = { calculateLiquidationPrice: jest.fn(), getMaxLeverage: jest.fn(), calculateFees: jest.fn().mockResolvedValue({ totalFee: 0 }), + previewPositionModify: jest.fn(), getAvailableDexs: jest.fn().mockResolvedValue([]), getBlockExplorerUrl: jest.fn(), getOrderFills: jest.fn(), diff --git a/packages/perps-controller/tests/src/providers/AggregatedPerpsProvider.test.ts b/packages/perps-controller/tests/src/providers/AggregatedPerpsProvider.test.ts index 074b63e2cb..f170b96131 100644 --- a/packages/perps-controller/tests/src/providers/AggregatedPerpsProvider.test.ts +++ b/packages/perps-controller/tests/src/providers/AggregatedPerpsProvider.test.ts @@ -10,6 +10,7 @@ import type { Order, ChaseOrder, TwapOrder, + FeeCalculationParams, } from '../../../src/types/index.js'; import { WebSocketConnectionState } from '../../../src/types/index.js'; import { STRATEGY_ORDER_TYPES } from '../../../src/utils/orderTypes.js'; @@ -100,6 +101,7 @@ const createMockProvider = ( calculateMaintenanceMargin: jest.fn().mockResolvedValue(0.05), getMaxLeverage: jest.fn().mockResolvedValue(50), calculateFees: jest.fn().mockResolvedValue({ feeRate: 0.001 }), + previewPositionModify: jest.fn().mockResolvedValue({ status: 'none' }), // Subscriptions subscribeToPrices: jest.fn().mockReturnValue(() => undefined), @@ -1104,6 +1106,98 @@ describe('AggregatedPerpsProvider', () => { expect(mockHLProvider.calculateFees).toHaveBeenCalled(); }); + it('delegates previewPositionModify to default provider', async () => { + mockHLProvider.previewPositionModify.mockResolvedValue({ + status: 'none', + }); + + const params = { + position: createMockPosition('BTC', '1'), + direction: 'long' as const, + size: '0.1', + price: '50000', + leverage: 10, + }; + + await aggregatedProvider.previewPositionModify(params); + + expect(mockHLProvider.previewPositionModify).toHaveBeenCalledWith(params); + }); + + it('routes previewPositionModify to an explicit provider', async () => { + mockMYXProvider.previewPositionModify.mockResolvedValue({ + status: 'unsupported', + reason: 'provider', + }); + + const params = { + position: createMockPosition('RHEA', '1'), + direction: 'long' as const, + size: '0.1', + price: '1', + leverage: 5, + providerId: 'myx' as const, + }; + + await expect( + aggregatedProvider.previewPositionModify(params), + ).resolves.toStrictEqual({ + status: 'unsupported', + reason: 'provider', + }); + expect(mockMYXProvider.previewPositionModify).toHaveBeenCalledWith( + params, + ); + expect(mockHLProvider.previewPositionModify).not.toHaveBeenCalled(); + }); + + it('routes previewPositionModify from position.providerId', async () => { + mockMYXProvider.previewPositionModify.mockResolvedValue({ + status: 'unsupported', + reason: 'provider', + }); + + const params = { + position: { + ...createMockPosition('RHEA', '1'), + providerId: 'myx' as const, + }, + direction: 'long' as const, + size: '0.1', + price: '1', + leverage: 5, + }; + + await expect( + aggregatedProvider.previewPositionModify(params), + ).resolves.toStrictEqual({ + status: 'unsupported', + reason: 'provider', + }); + expect(mockMYXProvider.previewPositionModify).toHaveBeenCalledWith( + params, + ); + expect(mockHLProvider.previewPositionModify).not.toHaveBeenCalled(); + }); + + it('rejects an unregistered previewPositionModify route', async () => { + aggregatedProvider.removeProvider('myx'); + + await expect( + aggregatedProvider.previewPositionModify({ + position: createMockPosition('BTC', '1'), + direction: 'long', + size: '0.1', + price: '50000', + leverage: 10, + providerId: 'myx', + }), + ).rejects.toThrow(PERPS_ERROR_CODES.PROVIDER_NOT_FOUND); + + expect(mockHLProvider.previewPositionModify).not.toHaveBeenCalled(); + expect(mockMYXProvider.previewPositionModify).not.toHaveBeenCalled(); + }); + it('accepts an ordinary fee request held as the routed parameter type', async () => { const params: FeeCalculationParams = { orderType: 'market', diff --git a/packages/perps-controller/tests/src/providers/HyperLiquidProvider.validation.test.ts b/packages/perps-controller/tests/src/providers/HyperLiquidProvider.validation.test.ts index a95ac3667f..f99a0c027d 100644 --- a/packages/perps-controller/tests/src/providers/HyperLiquidProvider.validation.test.ts +++ b/packages/perps-controller/tests/src/providers/HyperLiquidProvider.validation.test.ts @@ -1059,6 +1059,113 @@ describe('HyperLiquidProvider', () => { }); }); + describe('previewPositionModify', () => { + const isolatedPosition = { + symbol: 'ETH', + size: '1', + entryPrice: '2000', + positionValue: '2000', + marginUsed: '400', + leverage: { type: 'isolated' as const, value: 5 }, + liquidationPrice: '1640', + maxLeverage: 25, + }; + + it('returns unsupported for cross-margin positions without fetching meta', async () => { + const result = await provider.previewPositionModify({ + position: { + ...isolatedPosition, + leverage: { type: 'cross', value: 5 }, + }, + direction: 'long', + size: '0.5', + price: '2000', + leverage: 5, + }); + + expect(result).toStrictEqual({ + status: 'unsupported', + reason: 'cross_margin', + }); + expect(mockClientService.getInfoClient).not.toHaveBeenCalled(); + }); + + it('uses cached meta margin tables for an isolated increase', async () => { + mockClientService.getInfoClient = jest.fn().mockReturnValue( + createMockInfoClient({ + meta: jest.fn().mockResolvedValue({ + universe: [ + { + name: 'ETH', + szDecimals: 4, + maxLeverage: 25, + marginTableId: 25, + }, + ], + marginTables: [], + }), + }), + ); + + const result = await provider.previewPositionModify({ + position: isolatedPosition, + direction: 'long', + size: '0.5', + price: '2000', + leverage: 10, + }); + + expect(result.status).toBe('open'); + if (result.status !== 'open') { + return; + } + expect(result.kind).toBe('increase'); + expect(result.resulting.margin).toStrictEqual({ + available: true, + value: 300, + }); + expect(result.resulting.direction).toBe('long'); + }); + + it('withholds liquidation when the asset is missing from meta', async () => { + mockClientService.getInfoClient = jest.fn().mockReturnValue( + createMockInfoClient({ + meta: jest.fn().mockResolvedValue({ + universe: [ + { + name: 'BTC', + szDecimals: 5, + maxLeverage: 40, + marginTableId: 40, + }, + ], + marginTables: [], + }), + }), + ); + + const result = await provider.previewPositionModify({ + position: isolatedPosition, + direction: 'long', + size: '0.5', + price: '2000', + leverage: 10, + }); + + expect(result.status).toBe('open'); + if (result.status !== 'open') { + return; + } + expect(result.resulting.margin).toStrictEqual({ + available: true, + value: 300, + }); + expect(result.resulting.liquidationPrice).toStrictEqual({ + available: false, + }); + }); + }); + describe('getMaxLeverage', () => { it('returns max leverage for an asset', async () => { mockClientService.getInfoClient = jest.fn().mockReturnValue( diff --git a/packages/perps-controller/tests/src/providers/MYXProvider.test.ts b/packages/perps-controller/tests/src/providers/MYXProvider.test.ts index c4b00c3a04..d66062faa0 100644 --- a/packages/perps-controller/tests/src/providers/MYXProvider.test.ts +++ b/packages/perps-controller/tests/src/providers/MYXProvider.test.ts @@ -736,6 +736,27 @@ describe('MYXProvider', () => { protocolFeeRate: 0.0005, }); }); + + it('previewPositionModify returns unsupported', async () => { + expect( + await provider.previewPositionModify({ + position: { + symbol: 'RHEA', + size: '1', + entryPrice: '1', + positionValue: '1', + marginUsed: '1', + leverage: { type: 'isolated', value: 5 }, + liquidationPrice: '0.5', + maxLeverage: 20, + }, + direction: 'long', + size: '0.1', + price: '1', + leverage: 5, + }), + ).toEqual({ status: 'unsupported', reason: 'provider' }); + }); }); // ========================================================================== diff --git a/packages/perps-controller/tests/src/services/MarketDataService.test.ts b/packages/perps-controller/tests/src/services/MarketDataService.test.ts index 1fab3de302..3cd3fa5e8e 100644 --- a/packages/perps-controller/tests/src/services/MarketDataService.test.ts +++ b/packages/perps-controller/tests/src/services/MarketDataService.test.ts @@ -730,6 +730,32 @@ describe('MarketDataService', () => { }); }); + describe('previewPositionModify', () => { + it('delegates to the provider', async () => { + const params = { + position: createMockPosition({ + leverage: { type: 'isolated' as const, value: 5 }, + }), + direction: 'long' as const, + size: '0.1', + price: '50000', + leverage: 10, + }; + mockProvider.previewPositionModify.mockResolvedValue({ + status: 'none', + }); + + const result = await marketDataService.previewPositionModify({ + provider: mockProvider, + params, + context: mockContext, + }); + + expect(result).toEqual({ status: 'none' }); + expect(mockProvider.previewPositionModify).toHaveBeenCalledWith(params); + }); + }); + describe('calculateMaintenanceMargin', () => { it('calculates maintenance margin successfully', async () => { const params = { diff --git a/packages/perps-controller/tests/src/utils/hyperLiquidPositionPreview.test.ts b/packages/perps-controller/tests/src/utils/hyperLiquidPositionPreview.test.ts new file mode 100644 index 0000000000..ff832636b8 --- /dev/null +++ b/packages/perps-controller/tests/src/utils/hyperLiquidPositionPreview.test.ts @@ -0,0 +1,857 @@ +import type { + PositionModifyPreviewSource, + PositionPreviewValue, +} from '../../../src/types/index.js'; +import { + buildMaintenanceSchedule, + estimateIsolatedLiquidationPrice, + estimateIsolatedLiquidationPriceAtTier, + previewHyperLiquidIsolatedPositionModify, + resolveHyperLiquidMarginTiers, +} from '../../../src/utils/hyperLiquidPositionPreview.js'; + +const isolatedPosition = ( + overrides: Partial = {}, +): PositionModifyPreviewSource => ({ + symbol: 'ETH', + size: '1', + entryPrice: '2000', + positionValue: '2000', + marginUsed: '400', + leverage: { type: 'isolated', value: 5 }, + liquidationPrice: '1640', + maxLeverage: 25, + ...overrides, +}); + +const singleTier25x = [{ lowerBound: 0, maxLeverage: 25 }]; + +const availablePreviewValue = (preview: PositionPreviewValue): number => { + expect(preview.available).toBe(true); + if (!preview.available) { + throw new Error('Expected an available preview value'); + } + return preview.value; +}; + +/** Testnet ETH maintenance tiers. */ +const testnetEthTiers = [ + { lowerBound: 0, maxLeverage: 25 }, + { lowerBound: 20_000, maxLeverage: 10 }, + { lowerBound: 50_000, maxLeverage: 5 }, + { lowerBound: 200_000, maxLeverage: 3 }, +]; + +describe('resolveHyperLiquidMarginTiers', () => { + it('treats table ids below 50 as a single tier', () => { + expect( + resolveHyperLiquidMarginTiers({ + marginTableId: 25, + maxLeverage: 25, + marginTables: [], + }), + ).toStrictEqual([{ lowerBound: 0, maxLeverage: 25 }]); + }); + + it('returns the matching multi-tier table', () => { + expect( + resolveHyperLiquidMarginTiers({ + marginTableId: 50, + maxLeverage: 25, + marginTables: [ + [ + 50, + { + marginTiers: [ + { lowerBound: '0', maxLeverage: 25 }, + { lowerBound: '20000', maxLeverage: 10 }, + ], + }, + ], + ], + }), + ).toStrictEqual([ + { lowerBound: 0, maxLeverage: 25 }, + { lowerBound: 20_000, maxLeverage: 10 }, + ]); + }); + + it('returns null when the margin-table id is unknown', () => { + expect( + resolveHyperLiquidMarginTiers({ + maxLeverage: 25, + marginTables: [], + }), + ).toBeNull(); + }); + + it('returns null when a multi-tier table is required but missing', () => { + expect( + resolveHyperLiquidMarginTiers({ + marginTableId: 50, + maxLeverage: 25, + marginTables: [], + }), + ).toBeNull(); + }); +}); + +describe('buildMaintenanceSchedule', () => { + it('applies the HyperLiquid maintenance deduction at each tier', () => { + const schedule = buildMaintenanceSchedule(testnetEthTiers); + + expect(schedule[0]).toMatchObject({ + lowerBound: 0, + upperBound: 20_000, + maintenanceMarginRate: 1 / 50, + maintenanceDeduction: 0, + }); + expect(schedule[1].maintenanceMarginRate).toBeCloseTo(1 / 20); + expect(schedule[1].maintenanceDeduction).toBeCloseTo( + 20_000 * (1 / 20 - 1 / 50), + ); + }); +}); + +describe('estimateIsolatedLiquidationPrice', () => { + it('matches the single-tier closed form for a long', () => { + const liq = estimateIsolatedLiquidationPrice({ + isLong: true, + markPrice: 2000, + margin: 400, + positionSize: 1, + maintenanceMarginRate: 1 / 50, + }); + + expect(liq).toBeCloseTo((2000 - 400) / (1 - 1 / 50)); + }); + + it('matches the single-tier closed form for a short', () => { + const liq = estimateIsolatedLiquidationPrice({ + isLong: false, + markPrice: 2000, + margin: 400, + positionSize: 1, + maintenanceMarginRate: 1 / 50, + }); + + expect(liq).toBeCloseTo((2000 + 400) / (1 + 1 / 50)); + }); +}); + +describe('previewHyperLiquidIsolatedPositionModify', () => { + it('returns unsupported for cross-margin positions', () => { + const result = previewHyperLiquidIsolatedPositionModify({ + position: isolatedPosition({ + leverage: { type: 'cross', value: 5 }, + }), + direction: 'long', + size: '0.5', + price: '2000', + leverage: 5, + marginTiers: singleTier25x, + }); + + expect(result).toStrictEqual({ + status: 'unsupported', + reason: 'cross_margin', + }); + }); + + it('returns none when there is no order size', () => { + const result = previewHyperLiquidIsolatedPositionModify({ + position: isolatedPosition(), + direction: 'long', + size: '0', + price: '2000', + leverage: 5, + marginTiers: singleTier25x, + }); + + expect(result.status).toBe('none'); + }); + + it('projects an isolated increase at the current leverage', () => { + const result = previewHyperLiquidIsolatedPositionModify({ + position: isolatedPosition(), + direction: 'long', + size: '0.5', + price: '2000', + leverage: 5, + marginTiers: singleTier25x, + }); + + expect(result.status).toBe('open'); + if (result.status !== 'open') { + return; + } + expect(result.kind).toBe('increase'); + expect(result.resulting.direction).toBe('long'); + expect(result.resulting.size).toBeCloseTo(1.5); + expect(result.resulting.entryPrice).toBeCloseTo(2000); + expect(result.resulting.margin).toStrictEqual({ + available: true, + value: 600, + }); + expect(result.resulting.leverage).toBeCloseTo(5); + }); + + it('deducts fees from isolated margin on an increase', () => { + const result = previewHyperLiquidIsolatedPositionModify({ + position: isolatedPosition(), + direction: 'long', + size: '0.5', + price: '2000', + leverage: 5, + feeAmountUsd: 2, + marginTiers: singleTier25x, + }); + + expect(result.status).toBe('open'); + if (result.status !== 'open') { + return; + } + expect(result.resulting.margin).toStrictEqual({ + available: true, + value: 598, + }); + }); + + it('reallocates the existing isolated position when order leverage differs', () => { + const result = previewHyperLiquidIsolatedPositionModify({ + position: isolatedPosition(), + direction: 'long', + size: '0.5', + price: '2000', + leverage: 10, + marginTiers: singleTier25x, + }); + + expect(result.status).toBe('open'); + if (result.status !== 'open') { + return; + } + expect(result.kind).toBe('increase'); + // Existing 5x $400 is reset to $200 at 10x, then $100 is added for the order. + expect(result.resulting.margin).toStrictEqual({ + available: true, + value: 300, + }); + expect(result.resulting.leverage).toBeCloseTo(10); + const liquidationPrice = availablePreviewValue( + result.resulting.liquidationPrice, + ); + const overstatedMarginLiq = estimateIsolatedLiquidationPrice({ + isLong: true, + markPrice: 2000, + margin: 500, + positionSize: 1.5, + maintenanceMarginRate: 1 / 50, + }); + expect(overstatedMarginLiq).not.toBeNull(); + expect(liquidationPrice).toBeGreaterThan(overstatedMarginLiq ?? 0); + }); + + it('reports mark-based leverage when entry differs from mark after a leverage change', () => { + const result = previewHyperLiquidIsolatedPositionModify({ + position: isolatedPosition({ + entryPrice: '2000', + positionValue: '2500', + marginUsed: '500', + }), + direction: 'long', + size: '0.5', + price: '2500', + leverage: 10, + marginTiers: singleTier25x, + }); + + expect(result.status).toBe('open'); + if (result.status !== 'open') { + return; + } + expect(result.resulting.margin).toStrictEqual({ + available: true, + value: 375, + }); + expect(result.resulting.leverage).toBeCloseTo(10); + expect(result.resulting.entryPrice).toBeCloseTo(2166.6666667); + // Mark-based liq: (2500 - 375/1.5) / (1 - 1/50) = 2295.918... + // Entry-based liq would be ~1955.78 and is wrong for TP/SL. + expect( + availablePreviewValue(result.resulting.liquidationPrice), + ).toBeCloseTo(2295.9183673469); + }); + + it('projects a partial decrease using the remaining position direction', () => { + const result = previewHyperLiquidIsolatedPositionModify({ + position: isolatedPosition(), + direction: 'short', + size: '0.4', + price: '2000', + leverage: 5, + reduceOnly: true, + marginTiers: singleTier25x, + }); + + expect(result.status).toBe('open'); + if (result.status !== 'open') { + return; + } + expect(result.kind).toBe('decrease'); + expect(result.resulting.direction).toBe('long'); + expect(result.resulting.size).toBeCloseTo(0.6); + expect(result.resulting.entryPrice).toBeCloseTo(2000); + expect(result.resulting.margin).toStrictEqual({ + available: true, + value: 240, + }); + }); + + it('marks a partial decrease at the expected fill, not live mark', () => { + const result = previewHyperLiquidIsolatedPositionModify({ + position: isolatedPosition(), + direction: 'short', + size: '0.4', + price: '1800', + leverage: 5, + reduceOnly: true, + marginTiers: singleTier25x, + }); + + expect(result.status).toBe('open'); + if (result.status !== 'open') { + return; + } + expect(result.kind).toBe('decrease'); + expect(result.resulting.size).toBeCloseTo(0.6); + expect(result.resulting.margin).toStrictEqual({ + available: true, + value: 240, + }); + // Remaining 0.6 marked at 1800 → notional 1080 / margin 240 = 4.5x. + expect(result.resulting.leverage).toBeCloseTo(4.5); + // (1800 - 240/0.6) / (1 - 1/50) = 1428.571... + expect( + availablePreviewValue(result.resulting.liquidationPrice), + ).toBeCloseTo(1428.5714285714); + }); + + it('reallocates before a partial decrease when leverage changes', () => { + const result = previewHyperLiquidIsolatedPositionModify({ + position: isolatedPosition(), + direction: 'short', + size: '0.4', + price: '2000', + leverage: 10, + marginTiers: singleTier25x, + }); + + expect(result.status).toBe('open'); + if (result.status !== 'open') { + return; + } + expect(result.resulting.margin).toStrictEqual({ + available: true, + value: 120, + }); + expect(result.resulting.direction).toBe('long'); + }); + + it('projects a flip leftover at the selected leverage and order direction', () => { + const result = previewHyperLiquidIsolatedPositionModify({ + position: isolatedPosition(), + direction: 'short', + size: '1.5', + price: '2000', + leverage: 10, + marginTiers: singleTier25x, + }); + + expect(result.status).toBe('open'); + if (result.status !== 'open') { + return; + } + expect(result.kind).toBe('flip'); + expect(result.resulting.direction).toBe('short'); + expect(result.resulting.size).toBeCloseTo(0.5); + expect(result.resulting.entryPrice).toBeCloseTo(2000); + expect(result.resulting.margin).toStrictEqual({ + available: true, + value: 100, + }); + }); + + it('returns full_close without a remaining size', () => { + const result = previewHyperLiquidIsolatedPositionModify({ + position: isolatedPosition(), + direction: 'short', + size: '1', + price: '2000', + leverage: 5, + reduceOnly: true, + marginTiers: singleTier25x, + }); + + expect(result).toStrictEqual({ + status: 'full_close', + current: { + margin: { available: true, value: 400 }, + liquidationPrice: { available: true, value: 1640 }, + }, + resultingDirection: 'long', + }); + }); + + it('treats a reduce-only overshoot as a full close rather than a flip', () => { + const result = previewHyperLiquidIsolatedPositionModify({ + position: isolatedPosition(), + direction: 'short', + size: '2', + price: '2000', + leverage: 5, + reduceOnly: true, + marginTiers: singleTier25x, + }); + + expect(result.status).toBe('full_close'); + }); + + it('keeps margin available when the live liquidation price is missing', () => { + const result = previewHyperLiquidIsolatedPositionModify({ + position: isolatedPosition({ liquidationPrice: null }), + direction: 'long', + size: '0.5', + price: '2000', + leverage: 5, + marginTiers: singleTier25x, + }); + + expect(result.status).toBe('open'); + if (result.status !== 'open') { + return; + } + expect(result.current.liquidationPrice).toStrictEqual({ + available: false, + }); + expect(result.current.margin).toStrictEqual({ + available: true, + value: 400, + }); + expect(result.resulting.margin.available).toBe(true); + expect(result.resulting.liquidationPrice.available).toBe(true); + }); + + it('withholds liquidation and keeps margin when tier data is missing', () => { + const result = previewHyperLiquidIsolatedPositionModify({ + position: isolatedPosition(), + direction: 'long', + size: '0.5', + price: '2000', + leverage: 5, + marginTiers: null, + }); + + expect(result.status).toBe('open'); + if (result.status !== 'open') { + return; + } + expect(result.resulting.margin).toStrictEqual({ + available: true, + value: 600, + }); + expect(result.resulting.liquidationPrice).toStrictEqual({ + available: false, + }); + }); + + it('uses the maintenance tier at liquidation notional, including the deduction', () => { + const result = previewHyperLiquidIsolatedPositionModify({ + position: isolatedPosition({ + size: '20', + entryPrice: '2500', + positionValue: '50000', + marginUsed: '5000', + leverage: { type: 'isolated', value: 10 }, + liquidationPrice: '2200', + }), + direction: 'long', + size: '0.0001', + price: '2500', + leverage: 10, + marginTiers: testnetEthTiers, + }); + + expect(result.status).toBe('open'); + if (result.status !== 'open') { + return; + } + + const expected = estimateIsolatedLiquidationPriceAtTier({ + isLong: true, + markPrice: 2500, + margin: result.resulting.margin.available + ? result.resulting.margin.value + : 0, + positionSize: result.resulting.size, + marginTiers: testnetEthTiers, + }); + const singleTier = estimateIsolatedLiquidationPrice({ + isLong: true, + markPrice: 2500, + margin: result.resulting.margin.available + ? result.resulting.margin.value + : 0, + positionSize: result.resulting.size, + maintenanceMarginRate: 1 / 50, + }); + + expect(result.resulting.liquidationPrice.available).toBe(true); + const liquidationPrice = availablePreviewValue( + result.resulting.liquidationPrice, + ); + expect(expected).not.toBeNull(); + expect(singleTier).not.toBeNull(); + expect(liquidationPrice).toBeCloseTo(expected ?? 0); + expect(liquidationPrice).toBeGreaterThan(singleTier ?? 0); + }); + + it('averages entry and posts order margin at a limit price away from entry', () => { + const result = previewHyperLiquidIsolatedPositionModify({ + position: isolatedPosition(), + direction: 'long', + size: '1', + price: '1800', + leverage: 5, + marginTiers: singleTier25x, + }); + + expect(result.status).toBe('open'); + if (result.status !== 'open') { + return; + } + expect(result.kind).toBe('increase'); + expect(result.resulting.size).toBeCloseTo(2); + expect(result.resulting.entryPrice).toBeCloseTo(1900); + // Existing $400 at 5x plus 1 * 1800 / 5 = $360. + expect(result.resulting.margin).toStrictEqual({ + available: true, + value: 760, + }); + // After fill the whole position is marked at 1800, not live mark plus fill. + expect(result.resulting.leverage).toBeCloseTo(3600 / 760); + }); + + it('does not project an increase or flip when the fill price is missing', () => { + const increase = previewHyperLiquidIsolatedPositionModify({ + position: isolatedPosition(), + direction: 'long', + size: '0.5', + price: '0', + leverage: 5, + marginTiers: singleTier25x, + }); + const flip = previewHyperLiquidIsolatedPositionModify({ + position: isolatedPosition(), + direction: 'short', + size: '1.5', + price: '', + leverage: 5, + marginTiers: singleTier25x, + }); + + expect(increase.status).toBe('none'); + expect(flip.status).toBe('none'); + }); + + it('still projects a reduce when the fill price is missing', () => { + const result = previewHyperLiquidIsolatedPositionModify({ + position: isolatedPosition(), + direction: 'short', + size: '0.4', + price: '0', + leverage: 5, + marginTiers: singleTier25x, + }); + + expect(result.status).toBe('open'); + if (result.status !== 'open') { + return; + } + expect(result.kind).toBe('decrease'); + expect(result.resulting.direction).toBe('long'); + }); + + it('does not treat a same-direction reduce-only order as a decrease', () => { + const result = previewHyperLiquidIsolatedPositionModify({ + position: isolatedPosition(), + direction: 'long', + size: '0.4', + price: '2000', + leverage: 5, + reduceOnly: true, + marginTiers: singleTier25x, + }); + + expect(result.status).toBe('none'); + }); + + it('keeps extra isolated margin when leverage is unchanged', () => { + const result = previewHyperLiquidIsolatedPositionModify({ + position: isolatedPosition({ marginUsed: '800' }), + direction: 'long', + size: '0.5', + price: '2000', + leverage: 5, + marginTiers: singleTier25x, + }); + + expect(result.status).toBe('open'); + if (result.status !== 'open') { + return; + } + expect(result.resulting.margin).toStrictEqual({ + available: true, + value: 1000, + }); + }); + + it('strips extra isolated margin when leverage increases', () => { + const result = previewHyperLiquidIsolatedPositionModify({ + position: isolatedPosition({ marginUsed: '800' }), + direction: 'long', + size: '0.5', + price: '2000', + leverage: 10, + marginTiers: singleTier25x, + }); + + expect(result.status).toBe('open'); + if (result.status !== 'open') { + return; + } + expect(result.resulting.margin).toStrictEqual({ + available: true, + value: 300, + }); + }); + + it('adds isolated margin when selected leverage is lower than the position', () => { + const result = previewHyperLiquidIsolatedPositionModify({ + position: isolatedPosition({ + leverage: { type: 'isolated', value: 10 }, + marginUsed: '200', + }), + direction: 'long', + size: '0.5', + price: '2000', + leverage: 5, + marginTiers: singleTier25x, + }); + + expect(result.status).toBe('open'); + if (result.status !== 'open') { + return; + } + // Existing $2000 / 5 = $400, plus 0.5 * 2000 / 5 = $200. + expect(result.resulting.margin).toStrictEqual({ + available: true, + value: 600, + }); + expect(result.resulting.leverage).toBeCloseTo(5); + }); + + it('projects a short increase, keeping liquidation above entry', () => { + const result = previewHyperLiquidIsolatedPositionModify({ + position: isolatedPosition({ + size: '-1', + liquidationPrice: '2360', + }), + direction: 'short', + size: '0.5', + price: '2000', + leverage: 5, + marginTiers: singleTier25x, + }); + + expect(result.status).toBe('open'); + if (result.status !== 'open') { + return; + } + expect(result.kind).toBe('increase'); + expect(result.resulting.direction).toBe('short'); + expect(result.resulting.size).toBeCloseTo(1.5); + expect(result.resulting.margin).toStrictEqual({ + available: true, + value: 600, + }); + expect( + availablePreviewValue(result.resulting.liquidationPrice), + ).toBeGreaterThan(2000); + }); + + it('reallocates a short when increasing at higher leverage', () => { + const result = previewHyperLiquidIsolatedPositionModify({ + position: isolatedPosition({ + size: '-1', + liquidationPrice: '2360', + }), + direction: 'short', + size: '0.5', + price: '2000', + leverage: 10, + marginTiers: singleTier25x, + }); + + expect(result.status).toBe('open'); + if (result.status !== 'open') { + return; + } + expect(result.resulting.margin).toStrictEqual({ + available: true, + value: 300, + }); + expect(result.resulting.direction).toBe('short'); + }); + + it('averages a short increase at a limit above entry', () => { + const result = previewHyperLiquidIsolatedPositionModify({ + position: isolatedPosition({ + size: '-1', + liquidationPrice: '2360', + }), + direction: 'short', + size: '1', + price: '2200', + leverage: 5, + marginTiers: singleTier25x, + }); + + expect(result.status).toBe('open'); + if (result.status !== 'open') { + return; + } + expect(result.resulting.entryPrice).toBeCloseTo(2100); + expect(result.resulting.margin).toStrictEqual({ + available: true, + value: 840, + }); + }); + + it('projects a partial cover of a short using the remaining short direction', () => { + const result = previewHyperLiquidIsolatedPositionModify({ + position: isolatedPosition({ + size: '-1', + liquidationPrice: '2360', + }), + direction: 'long', + size: '0.4', + price: '2000', + leverage: 5, + reduceOnly: true, + marginTiers: singleTier25x, + }); + + expect(result.status).toBe('open'); + if (result.status !== 'open') { + return; + } + expect(result.kind).toBe('decrease'); + expect(result.resulting.direction).toBe('short'); + expect(result.resulting.size).toBeCloseTo(0.6); + expect(result.resulting.margin).toStrictEqual({ + available: true, + value: 240, + }); + }); + + it('flips a short leftover into a long at the fill price', () => { + const result = previewHyperLiquidIsolatedPositionModify({ + position: isolatedPosition({ + size: '-1', + liquidationPrice: '2360', + }), + direction: 'long', + size: '1.5', + price: '1900', + leverage: 10, + marginTiers: singleTier25x, + }); + + expect(result.status).toBe('open'); + if (result.status !== 'open') { + return; + } + expect(result.kind).toBe('flip'); + expect(result.resulting.direction).toBe('long'); + expect(result.resulting.size).toBeCloseTo(0.5); + expect(result.resulting.entryPrice).toBeCloseTo(1900); + expect(result.resulting.margin).toStrictEqual({ + available: true, + value: 95, + }); + expect( + availablePreviewValue(result.resulting.liquidationPrice), + ).toBeLessThan(1900); + }); + + it('fully closes a short without a remaining size', () => { + const result = previewHyperLiquidIsolatedPositionModify({ + position: isolatedPosition({ + size: '-1', + liquidationPrice: '2360', + }), + direction: 'long', + size: '1', + price: '2000', + leverage: 5, + reduceOnly: true, + marginTiers: singleTier25x, + }); + + expect(result).toMatchObject({ + status: 'full_close', + resultingDirection: 'short', + }); + }); + + it('flips a long leftover into a short at a limit away from entry', () => { + const result = previewHyperLiquidIsolatedPositionModify({ + position: isolatedPosition(), + direction: 'short', + size: '1.5', + price: '1800', + leverage: 10, + marginTiers: singleTier25x, + }); + + expect(result.status).toBe('open'); + if (result.status !== 'open') { + return; + } + expect(result.kind).toBe('flip'); + expect(result.resulting.direction).toBe('short'); + expect(result.resulting.size).toBeCloseTo(0.5); + expect(result.resulting.entryPrice).toBeCloseTo(1800); + expect(result.resulting.margin).toStrictEqual({ + available: true, + value: 90, + }); + expect( + availablePreviewValue(result.resulting.liquidationPrice), + ).toBeGreaterThan(1800); + }); + + it('returns none for a negative order size', () => { + const result = previewHyperLiquidIsolatedPositionModify({ + position: isolatedPosition(), + direction: 'long', + size: '-0.5', + price: '2000', + leverage: 5, + marginTiers: singleTier25x, + }); + + expect(result.status).toBe('none'); + }); +});